React SSR服務(wù)端渲染的實現(xiàn)示例
前言
這篇文章和大家一起來聊一聊 React SSR,本文更偏向于實戰(zhàn)。你可以從中學(xué)到:
- 從 0 到 1 搭建 React SSR
- 服務(wù)端渲染需要注意什么
- react 18 的流式渲染如何使用
一、認(rèn)識服務(wù)端渲染
1.1 基本概念
Server Side Rendering
即服務(wù)端渲染。在服務(wù)端渲染成 HTM L片段 ,發(fā)送到瀏覽器端,瀏覽器端完成狀態(tài)與事件的綁定,達(dá)到頁面完全可交互的過程。
現(xiàn)階段我們說的 ssr 渲染是現(xiàn)代化的服務(wù)端渲染,將傳統(tǒng)服務(wù)端渲染和客戶端渲染的優(yōu)點結(jié)合起來,既能降低首屏耗時,又能有 SPA 的開發(fā)體驗。這種渲染又可以稱為”同構(gòu)渲染”,將內(nèi)容的展示和交互寫成一套代碼,這一套代碼運行兩次,一次在服務(wù)端運行,來實現(xiàn)服務(wù)端渲染,讓 html 頁面具有內(nèi)容,另一次在客戶端運行,用于客戶端綁定交互事件。
1.2 簡單的服務(wù)端渲染
了解基本概念后,我們開始手寫實現(xiàn)一個 ssr 渲染。先來看一個簡單的服務(wù)端渲染,創(chuàng)建一個 node-server
文件夾, 使用 express
搭建一個服務(wù),返回一個 HTML 字符串。
const express = require('express') const app = express() app.get('/', (req, res) => { res.send(` <html> <head> <title>hello</title> </head> <body> <div id="root">hello, 小柒</div> </body> </html> `) }) app.listen(3000, () => { console.log('Server started on port 3000') })
運行起來, 頁面顯示如下,查看網(wǎng)頁源代碼, body 中就包含頁面中顯示的內(nèi)容,這就是一個簡單的服務(wù)端渲染。
對于客戶端渲染,我們就比較熟悉了,像 React 腳手架運行起來的 demo 就是一個csr。(這里小柒直接使用之前手動搭建的 react 腳手架模版)。啟動之后,打開網(wǎng)頁源代碼,可以看到 html
文件中的 body
標(biāo)簽中只有一個id 為root
的標(biāo)簽,沒有其他的內(nèi)容。網(wǎng)頁中的內(nèi)容是加載 script
文件后,動態(tài)添加DOM
后展現(xiàn)的。
一個 React ssr
項目永不止上述那么簡單,那么對于日常的一個 React
項目來說,如何實現(xiàn) SSR
呢?接下來小柒將手把手演示。
二、服務(wù)端渲染的前置準(zhǔn)備
在實現(xiàn)服務(wù)端渲染前,我們先做好項目的前置準(zhǔn)備。
目錄結(jié)構(gòu)改造
編譯配置改造
2.1 目錄結(jié)構(gòu)改造
React SSR 的核心即服務(wù)端客戶端執(zhí)行同一份代碼。 那我們先來改造一下模版內(nèi)容(??模版地址),將服務(wù)端代碼和客戶端代碼放到一個項目中。創(chuàng)建 client
和 server
目錄,分別用來放置客戶端代碼和服務(wù)端代碼。創(chuàng)建 compoment
目錄來存放公共組件,對于客戶端和服務(wù)端所能執(zhí)行的同一份代碼那一定是組件代碼,只有組件才是公共的。目錄結(jié)構(gòu)如下:
compoment/home
文件的內(nèi)容很簡單,即網(wǎng)頁中顯示的內(nèi)容。
import * as React from 'react' export const Home: React.FC = () => { const handleClick = () => { console.log('hello 小柒') } return ( <div className="wrapper" onClick={handleClick}> hello 小柒 </div> ) }
2.2 打包環(huán)境區(qū)分
對于服務(wù)端代碼的編譯我們也借助 webpack
,在 script 目錄中 創(chuàng)建 webpack.serve.js
文件,目標(biāo)編譯為 node
,打包輸出目錄為 build。為了避免 webpack 重復(fù)打包,使用 webpack-node-externals
,排除 node 中的內(nèi)置模塊和 node\_modules
中的第三方庫,比如 fs
、path
等。
const path = require('path') const { merge } = require('webpack-merge') const base = require('./webpack.base.js') const nodeExternals = require('webpack-node-externals') // 排除 node 中的內(nèi)置模塊和node_modules中的第三方庫,比如 fs、path等, module.exports = merge(base, { target: 'node', entry: path.resolve(__dirname, '../src/server/index.js'), output: { filename: '[name].js', clean: true, // 打包前清除 dist 目錄, path: path.resolve(__dirname, '../build'), }, externals: [nodeExternals()], // 避免重復(fù)打包 module: { rules: [ { test: /\.(css|less)$/, use: [ 'css-loader', { loader: 'postcss-loader', options: { // 它可以幫助我們將一些現(xiàn)代的 CSS 特性,轉(zhuǎn)成大多數(shù)瀏覽器認(rèn)識的 CSS,并且會根據(jù)目標(biāo)瀏覽器或運行時環(huán)境添加所需的 polyfill; // 也包括會自動幫助我們添加 autoprefixer postcssOptions: { plugins: ['postcss-preset-env'], }, }, }, 'less-loader', ], // 排除 node_modules 目錄 exclude: /node_modules/, }, ], }, })
為項目啟動方便,安裝 npm run all
來實現(xiàn)同時運行多個腳本,我們修改下 package.json
文件中 scripts 屬性,pnpm run dev
先執(zhí)行服務(wù)端代碼再執(zhí)行客戶端代碼,最后運行打包的服務(wù)端代碼。
"scripts": { "dev": "npm-run-all --parallel build:*", "build:serve": "cross-env NODE_ENV=production webpack -c scripts/webpack.serve.js --watch", "build:client": "cross-env NODE_ENV=production webpack -c scripts/webpack.prod.js --watch", "build:node": "nodemon --watch build --exec node \"./build/main.js\"", },
到這里項目前置準(zhǔn)備搭建完畢。
三、實現(xiàn) React SSR 應(yīng)用
3.1 簡單的React 組件的服務(wù)端渲染
接下來我們開始一步一步實現(xiàn)同構(gòu),讓我們回憶一下前面說的同構(gòu)的核心步驟:同一份代碼先在服務(wù)端執(zhí)行一遍生成 html 文件,再到客戶端執(zhí)行一遍,加載 js 代碼完成事件綁定。
第一步:我們引入 conpoment/home
組件到 server.js 中,服務(wù)端要做的就是將 Home 組件中的 jsx 內(nèi)容轉(zhuǎn)為 html 字符串返回給瀏覽器,我們可以利用 react-dom/server
中的 renderToString
方法來實現(xiàn),這個方法會將 jsx 對應(yīng)的虛擬dom 進(jìn)行編譯,轉(zhuǎn)換為 html 字符串。
import express from 'express' import { renderToString } from 'react-dom/server' import { Home } from '../component/home' const app = express() app.get('/', (req, res) => { const content = renderToString(<Home />) res.send(` <html> <head> <title>React SSR</title> </head> <body> <div id="root">${content}</div> </body> </html> `) }) app.listen(3000, () => { console.log('Server started on port 3000') })
第二步:使用 ReactDOM.hydrateRoot
渲染 React 組件。
ReactDOM.hydrateRoot 可以直接接管由服務(wù)端生成的HTML字符串,不會二次加載,客戶端只會進(jìn)行事件綁定,這樣避免了閃爍,提高了首屏加載的體驗。
import * as React from 'react' import * as ReactDOM from 'react-dom/client' import App from './App' // hydrateRoot 不會二次渲染,只會綁定事件 ReactDOM.hydrateRoot(document.getElementById('root')!, <App />)
注意:hydrateRoot 需要保證服務(wù)端和客戶端渲染的組件內(nèi)容相同,否則會報錯。
運行pnpm run dev
,即可以看到 Home 組件的內(nèi)容顯示在頁面上。
但細(xì)心的你一定會發(fā)現(xiàn),點擊事件并不生效。原因很簡單:服務(wù)端只負(fù)責(zé)將 html 代碼返回到瀏覽器,這只是一個靜態(tài)的頁面。而事件的綁定則需要客戶端生成的 js 代碼來實現(xiàn),這就需要同構(gòu)核心步驟的第二點,將同一份代碼在客戶端也執(zhí)行一遍,這就是所謂的“注水”。
dist/main.bundle.js
為客戶端打包的 js 代碼,修改 server/index.js
代碼,加上對 js 文件的引入。注意這里添加 app.use(express.static('dist'))
這段代碼,添加一個中間件,來提供靜態(tài)文件,即可以通過 http://localhost:3000/main.bundle.js
來訪問, 否則會 404。
import express from 'express' import { renderToString } from 'react-dom/server' import { Home } from '../component/home' const app = express() app.use(express.static('dist')) app.get('/', (req, res) => { const content = renderToString(<Home />) res.send(` <html> <head> <title>React SSR</title> <script defer src='main.bundle.js'></script> </head> <body> <div id="root">${content}</div> </body> </html> `) }) // ...
一般來說打包的文件都是用hash 值結(jié)尾的,不好直接寫死, 我們可以讀取 dist
中以.js
結(jié)尾的文件,實現(xiàn)動態(tài)引入。
// 省略... app.get('/', (req, res) => { // 讀取dist文件夾中js 文件 const jsFiles = fs.readdirSync(path.join(__dirname, '../dist')).filter((file) => file.endsWith('.js')) const jsScripts = jsFiles.map((file) => `<script src="${file}" defer></script>`).join('\n') const content = renderToString(<Home />) res.send(` <html> <head> <title>React SSR</title> ${jsScripts} </head> <body> <div id="root">${content}</div> </body> </html> `) }) // 省略...
點擊文案,控制臺有內(nèi)容打印,這樣事件的綁定就成功啦。
以上僅僅是一個最簡單的 react ssr 應(yīng)用,而 ssr 項目需要注意的地方還有很多。接下來我們繼續(xù)探索同構(gòu)中的其他問題。
3.2 路由問題
先來看看從輸入URL地址,瀏覽器是如何顯示出界面的?
1、在瀏覽器輸入 http://localhost:3000/ 地址
2、服務(wù)端路由要找到對應(yīng)的組件,通過 renderToString 將轉(zhuǎn)化為字符串,拼接到 HTML 輸出
3、瀏覽器加載 js 文件后,解析前端路由,輸出對應(yīng)的前端組件,如果發(fā)現(xiàn)是服務(wù)端渲染,不會二次渲染,只會綁定事件,之后的點擊跳轉(zhuǎn)都是前端路由,與服務(wù)端路由沒有關(guān)系。
同構(gòu)中的路由問題即: 服務(wù)端路由和前端路由是不同的,在代碼處理上也不相同。服務(wù)端代碼采用StaticRouter
實現(xiàn),前端路由采用BrowserRouter
實現(xiàn)。
注意:StaticRouter 與 BrowserRouter 的區(qū)別如下:
BrowserRouter 的原理使用了瀏覽器的 history API ,而服務(wù)端是不能使用瀏覽器中的
API ,而StaticRouter 則是利用初始傳入url 地址,來尋找對應(yīng)的組件。
接下來對代碼進(jìn)行改造,需要提前安裝 react-router-dom
。
- 新增一個
detail
組件
import * as React from 'react' export const Detail = () => { return <div>這是詳情頁</div> }
- 新增路由文件
src/routes.ts
// src/routes.ts import { Home } from './component/home' import { Detail } from './component/detail' export default [ { key: 'home', path: '/', exact: true, component: Home, }, { key: 'detail', path: '/detail', exact: true, component: Detail, }, ]
- 前端路由改造
// App.jsx import * as React from 'react' import { BrowserRouter, Routes, Route, Link } from 'react-router-dom' import routes from '@/routes' const App: React.FC = () => { return ( <BrowserRouter> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </BrowserRouter> ) } export default App
- 服務(wù)端路由改造
import express from 'express' import React from 'react' const fs = require('fs') const path = require('path') import { renderToString } from 'react-dom/server' import { StaticRouter } from 'react-router-dom/server' import { Routes, Route, Link } from 'react-router-dom' import routes from '../routes' const app = express() app.use(express.static('dist')) app.get('*', (req, res) => { // ... 省略 const content = renderToString( <StaticRouter location={req.url}> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </StaticRouter> ) // ... 省略 }) // ... 省略
pnpm run dev
運行項目,可以看到如下內(nèi)容,說明 ssr 路由渲染成功。
3.2 狀態(tài)管理問題
在ssr
中,store
的問題有兩點需要注意:
與客戶端渲染不同,在服務(wù)器端,一旦組件內(nèi)容確定 ,就沒法重新
render
,所以必須在確定組件內(nèi)容前將store
的數(shù)據(jù)準(zhǔn)備好,然后和組件的內(nèi)容組合成 HTML 一起下發(fā)。store
的實例只能有一個。
狀態(tài)管理我們使用 Redux Toolkit,安裝依賴 pnpm i @reduxjs/toolkit react-redux
,添加 store 文件夾,編寫一個userSlice
,兩個狀態(tài)status
、list
。
其中list
的有一個初始值:
export const userSlice = createSlice({ name: 'users', initialState: { status: 'idle', list: [ { id: 1, name: 'xiaoqi', first_name: 'xiao', last_name: 'qi', }, ], } as UserState, reducers: {}, })
store/user-slice.ts
文件完整代碼:
// store/user-slice.ts // https://www.reduxjs.cn/tutorials/fundamentals/part-8-modern-redux/ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' import axios from 'axios' interface User { id: number name: string first_name: string last_name: string } // 定義初始狀態(tài) export interface UserState { status: 'idle' | 'loading' | 'succeeded' | 'failed' list: User[] } export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => { const response = await axios.get('https://reqres.in/api/users') return response.data.data }) export const userSlice = createSlice({ name: 'users', initialState: { status: 'idle', list: [ { id: 1, name: 'xiaoqi', first_name: 'xiao', last_name: 'qi', }, ], } as UserState, reducers: {}, }) export default userSlice.reducer // store/index.ts import { configureStore } from '@reduxjs/toolkit' import usersReducer, { UserState } from './user-slice' export const getStore = () => { return configureStore({ // reducer是必需的,它指定了應(yīng)用程序的根reducer reducer: { users: usersReducer, }, }) } // 全局State類型 export type RootState = ReturnType<ReturnType<typeof getStore>['getState']> export type AppDispatch = ReturnType<typeof getStore>['dispatch']
在store/index.ts
中我們導(dǎo)出了一個getStore
方法用于創(chuàng)建store
。
注意:到上述獲取store 實例時,我們采用的是 getStore 方法來獲取。原因是在服務(wù)端,store 不能是單例的,如果直接導(dǎo)出store,用戶就會共享store,這肯定不行。
改造客戶端,并在home
組件中顯示初始list
:
// App.tsx // ...省略 import { Provider } from 'react-redux' import { getStore } from '../store' const App: React.FC = () => { return ( <Provider store={getStore()}> <BrowserRouter> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </BrowserRouter> </Provider> ) } export default App // home.tsx import * as React from 'react' import styles from './index.less' import { useAppSelector } from '@/hooks' export const Home = () => { const userList = useAppSelector((state) => state.users?.list) const handleClick = () => { console.log('hello 小柒') } return ( <div className={styles.wrapper} onClick={handleClick}> hello 小柒 {userList?.map((user) => ( <div key={user.id}>{user.first_name + user.last_name}</div> ))} </div> ) }
改造服務(wù)端:
// ...省略 import { Provider } from 'react-redux' import { getStore } from '../store' // ...省略 app.get('*', (req, res) => { const store = getStore() //...省略 const content = renderToString( <Provider store={store}> <StaticRouter location={req.url}> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </StaticRouter> </Provider> ) // ...省略 }) // ...省略
改造完畢,效果如下,初始值顯示出來了。
3.3 異步數(shù)據(jù)的處理
上述例子中,已經(jīng)添加了store
,但如果初始的userList
數(shù)據(jù)是通過接口拿到的,服務(wù)端又該如何處理呢?
我們先來看下如果是客戶端渲染是什么流程:
1、創(chuàng)建store
2、根據(jù)路由顯示組件
3、觸發(fā)Action
獲取數(shù)據(jù)
4、更新store
的數(shù)據(jù)
5、組件Rerender
改造 userSlice.ts
文件,添加異步請求:
// ... 省略 // 1、添加異步請求 export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => { const response = await axios.get('https://reqres.in/api/users') return response.data.data }) export const userSlice = createSlice({ name: 'users', initialState: { status: 'idle', list: [], } as UserState, reducers: {}, // 2、更新 store extraReducers: (builder) => { builder .addCase(fetchUsers.pending, (state) => { state.status = 'loading' }) .addCase(fetchUsers.fulfilled, (state, action) => { state.status = 'succeeded' state.list = action.payload }) .addCase(fetchUsers.rejected, (state, action) => { state.status = 'failed' }) }, }) export default userSlice.reducer
改造客戶端: 在 Home 組件中,新增 useEffect
調(diào)用 dispatch
更新數(shù)據(jù)。
// ... 省略 import { useAppDispatch, useAppSelector } from '@/hooks' import { fetchUsers } from '../../store/user-slice' export const Home = () => { const dispatch = useAppDispatch() const userList = useAppSelector((state) => state.users?.list) // ... 省略 React.useEffect(() => { dispatch(fetchUsers()) }, []) return ( <div className={styles.wrapper} onClick={handleClick}> hello 小柒 {userList?.map((user) => ( <div key={user.id}>{user.first_name + user.last_name}</div> ))} </div> ) }
從效果上可以發(fā)現(xiàn)list
數(shù)據(jù)渲染會從無到有,有明顯的空白閃爍。
這是因為useEffect
只會在客戶端執(zhí)行,服務(wù)端不會執(zhí)行。如果要解決這個問題,服務(wù)端也要生成好這個數(shù)據(jù),然后將數(shù)據(jù)和組件一起生成 HTML。
在服務(wù)端生成 HTML 之前要實現(xiàn)流程如下:
1、創(chuàng)建store
2、根據(jù)路由分析store
中需要的數(shù)據(jù)
3、觸發(fā)Action
獲取數(shù)據(jù)
4、更新store
的數(shù)據(jù)
5、結(jié)合數(shù)據(jù)和組件生成HTML
改造服務(wù)端,即我們需要在現(xiàn)有的基礎(chǔ)上,實現(xiàn) 2、3 就行。
matchRoutes
可以幫助我們分析路由,服務(wù)端要想觸發(fā)Action
,也需要有一個類似useEffect
方法用于服務(wù)端獲取數(shù)據(jù)。我們可以給組件添加loadData
方法,并修改路由配置。
// Home.tsx export const Home = () => { // ... 省略 } Home.loadData = (store: any) => { return store.dispatch(fetchUsers()) } // 路由配置 import { Home } from './component/home' import { Detail } from './component/detail' export default [ { key: 'home', path: '/', exact: true, component: Home, loadData: Home.loadData, // 新增 loadData 方法 }, { key: 'detail', path: '/detail', exact: true, component: Detail, }, ]
服務(wù)端代碼如下:
import express from 'express' import React from 'react' import { Provider } from 'react-redux' import { getStore } from '../store' const fs = require('fs') const path = require('path') import { renderToString } from 'react-dom/server' import { StaticRouter } from 'react-router-dom/server' import { Routes, Route, Link, matchRoutes } from 'react-router-dom' import routes from '../routes' const app = express() app.use(express.static('dist')) app.get('*', (req, res) => { // 1、創(chuàng)建store const store = getStore() const promises = [] // 2、matchRoutes 分析路由組件,分析 store 中需要的數(shù)據(jù) const matchedRoutes = matchRoutes(routes, req.url) // https://reactrouter.com/6.28.0/hooks/use-routes matchedRoutes?.forEach((item) => { if (item.route.loadData) { const promise = new Promise((resolve) => { // 3/4、觸發(fā) Action 獲取數(shù)據(jù)、更新 store 的數(shù)據(jù) item.route.loadData(store).then(resolve).catch(resolve) }) promises.push(promise) } }) // ... 省略 // 5、結(jié)合數(shù)據(jù)和組件生成HTML Promise.all(promises).then(() => { const content = renderToString( <Provider store={store}> <StaticRouter location={req.url}> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </StaticRouter> </Provider> ) res.send(` <!doctype html> <html> <head> <title>React SSR</title> ${jsScripts} </head> <body> <div id="root">${content}</div> </body> </html> `) }) }) app.listen(3000, () => { console.log('Server started on port 3000') })
matchedRoutes
方法分析路由,當(dāng)路由中有loadData
方法時,將store
作為參數(shù)傳入,執(zhí)行loadData
方法。將結(jié)果放入
promises
數(shù)組中,結(jié)合Promise.all
方法來實現(xiàn)等待異步數(shù)據(jù)獲取之后,再將數(shù)據(jù)和組件生成 HTML
效果如下,你會發(fā)現(xiàn),即使服務(wù)端已經(jīng)返回了初始數(shù)據(jù),頁面還是閃爍明顯,并且控制臺還會出現(xiàn)報錯。
3.4 數(shù)據(jù)的脫水和注水
由于客戶端的初始store
數(shù)據(jù)還是空數(shù)組,導(dǎo)致服務(wù)端和客戶端渲染的結(jié)果不一樣,造成了閃屏。我們需要讓客戶端渲染時也能拿到服務(wù)端中store
的數(shù)據(jù),可以通過在window
上掛載一個INITIAL\_STATE
,和 HTML 一起下發(fā),這個過程也叫做“注水”。
// server/index.js res.send(` <!doctype html> <html> <head> <title>React SSR</title> ${jsScripts} <script> window.INITIAL_STATE =${JSON.stringify(store.getState())} </script> </head> <body> <div id="root">${content}</div> </body> </html> `)
在客戶端創(chuàng)建store
時,將它作為初始值傳給state
,即可拿到數(shù)據(jù)進(jìn)行渲染,這個過程也叫做”脫水”。
// store/index.ts export const getStore = () => { return configureStore({ // reducer是必需的,它指定了應(yīng)用程序的根reducer reducer: { users: usersReducer, }, // 對象,它包含應(yīng)用程序的初始狀態(tài) preloadedState: { users: typeof window !== 'undefined' ? window.INITIAL_STATE?.users : ({ status: 'idle', list: [], } as UserState), }, }) }
這樣頁面就不會出現(xiàn)閃爍現(xiàn)象,控制臺也不會出現(xiàn)報錯了。
3.5 css 處理
客戶端渲染時,一般有兩種方法引入樣式:
style-loader
: 將css
樣式通過style
標(biāo)簽插入到DOM
中MiniCssExtractPlugin
: 插件將樣式打包到單獨的文件,并使用link
標(biāo)簽引入.
對于服務(wù)端渲染來說,這兩種方式都不能使用。
服務(wù)端不能操作
DOM
,不能使用style-loader
服務(wù)端輸出的是靜態(tài)頁面,等待瀏覽器加載
css
文件,如果樣式文件較大,必定會導(dǎo)致頁面閃爍。
對于服務(wù)端來說,我們可以使用isomorphic-style-loader
來解決。isomorphic-style-loader
利用context Api
,結(jié)合useStyles hooks Api
在渲染組件渲染的拿到組件的 css 樣式,最終插入 HTML 中。
isomorphic-style-loader 這個 loader 利用了 loader的 pitch 方法的特性,返回三個方法供樣式文件使用。關(guān)于 loader 的執(zhí)行機制可以戳 → loader 調(diào)用鏈。
- _getContent:數(shù)組,可以獲取用戶使用的類名等信息
- _getCss:獲取 css 樣式
- _insertCss :將 css 插入到 style 標(biāo)簽中
服務(wù)端改造:定義insertCss
方法, 該方法調(diào)用 \_getCss
方法獲取將組件樣式添加到css Set
中, 通過context
將insertCss
方法傳遞給每一個組件,當(dāng)insertCss
方法被調(diào)用時,則樣式將被添加到css Set
中,最后通過[...css].join('')
獲取頁面的樣式,放入<style>
標(biāo)簽中。
import StyleContext from 'isomorphic-style-loader/StyleContext' // ... 省略 app.get('*', (req, res) => { // ... 省略 // 1、新增css set const css = new Set() // CSS for all rendered React components // 2、定義 insertCss 方法,調(diào)用 _getCss 方法獲取將組件樣式添加到 css Set 中 const insertCss = (...styles) => styles.forEach((style) => css.add(style._getCss())) // ... 省略 // 3、使用 StyleContext,傳入insertCss 方法 Promise.all(promises).then(() => { const content = renderToString( <Provider store={store}> <StyleContext.Provider value={{ insertCss }}> <StaticRouter location={req.url}> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </StaticRouter> </StyleContext.Provider> </Provider> ) res.send(` <!doctype html> <html> <head> <title>React SSR</title> ${jsScripts} <script> window.INITIAL_STATE =${JSON.stringify(store.getState())} </script> <!-- 獲取頁面的樣式,放入 <style> 標(biāo)簽中 --> <style>${[...css].join('')}</style> </head> <body> <div id="root">${content}</div> </body> </html> `) }) }) // ...省略
對于客戶端也要進(jìn)行處理:
- 在 App 組件中使用定義使用
StyleContext
,定義insertCss
方法,與服務(wù)端不同的是insertCss
方法中調(diào)用_insertCss
,_insertCss
方法會操作DOM,將樣式插入HTML 中,功能類似于style-loader
。 - 在對應(yīng)的組件中引入
useStyle
傳入樣式文件。
// .. 省略 import StyleContext from 'isomorphic-style-loader/StyleContext' const App: React.FC = () => { const insertCss = (...styles: any[]) => { const removeCss = styles.map((style) => style._insertCss()) return () => removeCss.forEach((dispose) => dispose()) } return ( <Provider store={getStore()}> <StyleContext.Provider value={{ insertCss }}> <BrowserRouter> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </BrowserRouter> </StyleContext.Provider> </Provider> ) } export default App // Home.tsx import useStyles from 'isomorphic-style-loader/useStyles' import styles from './index.less' // ...省略 export const Home = () => { useStyles(styles) // ...省略 return ( <div className={styles.wrapper} onClick={handleClick}> hello 小柒 {userList?.map((user) => ( <div key={user.id}>{user.first_name + user.last_name}</div> ))} </div> ) }
服務(wù)端/客戶端的編譯配置要注意,isomorphic-style-loader
需要配合css module
,css-loader
的配置要開啟css module
, 否則會報錯。
module: { rules: [ { test: /\.(css|less)$/, use: [ 'isomorphic-style-loader', // 服務(wù)端渲染時,需要使用 isomorphic-style-loader 來處理樣式 { loader: 'css-loader', options: { modules: { localIdentName: '[name]_[local]_[hash:base64:5]', // 開啟 css module }, esModule: false, // 啟用 CommonJS 模塊語法 }, }, { loader: 'postcss-loader', options: { // 它可以幫助我們將一些現(xiàn)代的 CSS 特性,轉(zhuǎn)成大多數(shù)瀏覽器認(rèn)識的 CSS,并且會根據(jù)目標(biāo)瀏覽器或運行時環(huán)境添加所需的 polyfill; // 也包括會自動幫助我們添加 autoprefixer postcssOptions: { plugins: ['postcss-preset-env'], }, }, }, 'less-loader', ], // 排除 node_modules 目錄 exclude: /node_modules/, }, ], },
注意:這里服務(wù)端和客戶端都是使用 isomorphic-style-loader 去實現(xiàn)樣式的引入。
最終效果如下,不會造成樣式閃爍:
3.6 流式SSR渲染
前面的例子我們可以發(fā)現(xiàn) 3個問題:
必須在發(fā)送HTML之前拿到所有的數(shù)據(jù)
上述例子中我們需要獲取到 user 的數(shù)據(jù)之后 ,才能開始渲染。 假設(shè)我們還需要獲取評論信息,那么我們只有獲取到這兩部分的數(shù)據(jù)之后,才能開始渲染。而在實際場景中接口的速度也不同,等到接口慢的數(shù)據(jù)獲取到之后再開始渲染,務(wù)必會影響首屏的速度。
必須等待所有的 JavaScript 內(nèi)容加載完才能開始吸水
上述例子中我們提到過,客戶端渲染的組件樹要和服務(wù)端渲染的組件樹保持一致,否則React就無法匹配,客戶端換渲染會代替服務(wù)端渲染。假如組件樹的加載和執(zhí)行的執(zhí)行比較長,那么吸水也需要等待所有組件樹都加載執(zhí)行完。
必須等所有的組件都吸水完才能開始頁面交互
React DOM Root 只會吸水一次,一旦開始吸水,就不會停止,只有等到吸水完畢中后才能交互。假如 js 的執(zhí)行時間很長,那么用戶交互在這段時間內(nèi)就得不到響應(yīng),務(wù)必就會給用戶一種卡頓的感覺,留下不好的體驗。
react 18 以前上面3個問題都是我們在 ssr 渲染過程中需要考慮的問題,而 react 18 給 ssr 提供的新特性可以幫助我們解決。
支持服務(wù)端流式輸出 HTML(
renderToPipeableStream
)。支持客戶端選擇性吸水。使用
Suspense
包裹對應(yīng)的組件。
接下來開始進(jìn)行代碼改造:
1、新增Comment
組件
import * as React from 'react' import useStyles from 'isomorphic-style-loader/useStyles' import styles from './index.less' const Comment = () => { useStyles(styles) return <div className={styles.comment}>這是相關(guān)評論</div> } export default Comment
2、在Home
組件中使用 Suspense
包裹Comment
組件。Suspense
組件必須結(jié)合lazy
、 use
、useTransition
等一起使用,這里使用 lazy
懶加載 Comment
組件。
// Home.tsx const Comment = React.lazy(() => { return new Promise((resolve) => { setTimeout(() => { resolve(import('../Comment')) }, 3000) }) }) export const Home = () => { // ... 省略 return ( <div className={styles.wrapper} onClick={handleClick}> hello 小柒 {userList?.map((user) => ( <div key={user.id}>{user.first_name + user.last_name}</div> ))} <div className={styles.comment}> <React.Suspense fallback={<div>loading...</div>}> <Comment /> </React.Suspense> </div> </div> ) }
3、服務(wù)端將renderToString
替換為renderToPipeableStream
。
有兩種方式替換,第一種官方推薦寫法,需要自己寫一個組件傳遞給renderToPipeableStream
:
import * as React from 'react' import { Provider } from 'react-redux' import { StaticRouter } from 'react-router-dom/server' import { Routes, Route, Link } from 'react-router-dom' import StyleContext from 'isomorphic-style-loader/StyleContext' import routes from '../routes' export const HTML = ({ store, insertCss, req }) => { return ( <html lang="en"> <head> <meta charSet="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>React SSR</title> </head> <body> <div id="root"> <Provider store={store}> <StyleContext.Provider value={{ insertCss }}> <StaticRouter location={req.url}> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </StaticRouter> </StyleContext.Provider> </Provider> </div> </body> </html> ) }
服務(wù)端改造: 這種方式?jīng)]法直接傳遞css
,需要我們拼接下。
// ... 省略 import { renderToPipeableStream } from 'react-dom/server' import { Transform } from 'stream' app.get('*', (req, res) => { Promise.all(promises).then(() => { const { pipe, abort } = renderToPipeableStream( <HTML store={store} insertCss={insertCss} req={req} />, { bootstrapScripts: jsFiles, bootstrapScriptContent: `window.INITIAL_STATE = ${JSON.stringify(store.getState())}`, onShellReady: () => { res.setHeader('content-type', 'text/html') let isShellStream = true const injectTemplateTransform = new Transform({ transform(chunk, _encoding, callback) { if (isShellStream) { // 拼接 css const chunkString = chunk.toString() let curStr = '' const titleIndex = chunkString.indexOf('</title>') if (titleIndex !== -1) { const styleTag = `<style>${[...css].join('')}</style>` curStr = chunkString.slice(0, titleIndex + 8) + styleTag + chunkString.slice(titleIndex + 8) } this.push(curStr) isShellStream = false } else { this.push(chunk) } callback() }, }) pipe(injectTemplateTransform).pipe(res) }, onErrorShell() { // 錯誤發(fā)生時替換外殼 res.statusCode = 500 res.send('<!doctype><p>Error</p>') }, } ) setTimeout(abort, 10_000) }) }
方式二:自己拼接 HTML 字符串。
// ..。省略 import { renderToPipeableStream } from 'react-dom/server' import { Transform } from 'stream' // ... 省略 app.get('*', (req, res) => { // ... 省略 const jsFiles = fs.readdirSync(path.join(__dirname, '../dist')).filter((file) => file.endsWith('.js')) // 5、結(jié)合數(shù)據(jù)和組件生成HTML Promise.all(promises).then(() => { console.log('store', [...css].join('')) const { pipe, abort } = renderToPipeableStream( <Provider store={store}> <StyleContext.Provider value={{ insertCss }}> <StaticRouter location={req.url}> <Link to="/">首頁</Link> <Link to="/detail">detail</Link> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} Component={route.component} /> ))} </Routes> </StaticRouter> </StyleContext.Provider> </Provider>, { bootstrapScripts: jsFiles, bootstrapScriptContent: `window.INITIAL_STATE = ${JSON.stringify(store.getState())}`, onShellReady: () => { res.setHeader('content-type', 'text/html') // headTpl 代表 <html><head>...</head><body><div id='root'> 部分的模版 // tailTpl 代表 </div></body></html> 部分的模版 const headTpl = ` <html lang="en"> <head> <meta charSet="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>React SSR</title> <style>${[...css].join('')}</style> </head> <body> <div id="root">` const tailTpl = ` </div> </body> </html> ` let isShellStream = true const injectTemplateTransform = new Transform({ transform(chunk, _encoding, callback) { if (isShellStream) { this.push(`${headTpl}${chunk.toString()}`) isShellStream = false } else { this.push(chunk) } callback() }, flush(callback) { // end觸發(fā)前執(zhí)行 this.push(tailTpl) callback() }, }) pipe(injectTemplateTransform).pipe(res) }, onErrorShell() { // 錯誤發(fā)生時替換外殼 res.statusCode = 500 res.send('<!doctype><p>Error</p>') }, } ) setTimeout(abort, 10_000) }) })
兩種方式都可以,這里需要注意 js 的處理:
bootstrapScripts
:一個 URL 字符串?dāng)?shù)組,它們將被轉(zhuǎn)化為
當(dāng)看到評論組件能異步加載出來,并且模版文件中出現(xiàn)占位符即成功。
簡單介紹下 ssr 流式替換的流程:先使用占位符,再替換為真實的內(nèi)容。
第一次訪問頁面:ssr 第 1 段數(shù)據(jù)傳輸,Suspense
組件包裹的部分先是使用<templte id="B:0"></template
>標(biāo)簽占位children
,注釋 <!—$?—> 和 <!—/$—>
中間的內(nèi)容表示異步渲染出來的,并展示fallback
中的內(nèi)容。
<div class="index_wrapper_RPDqO"> hello 小柒<div>GeorgeBluth</div> <div>JanetWeaver</div> <div>EmmaWong</div> <div>EveHolt</div> <div>CharlesMorris</div> <div>TraceyRamos</div> <div class="index_comment_kem02"> <!--$?--> <template id="B:0"></template> <div>loading...</div> <!--/$--> </div>
傳輸?shù)牡?2 段數(shù)據(jù),經(jīng)過格式化后,如下:
<div hidden id="S:0"> <div>這是相關(guān)評論</div> </div> <script> function $RC(a, b) { a = document.getElementById(a); b = document.getElementById(b); b.parentNode.removeChild(b); if (a) { a = a.previousSibling; var f = a.parentNode , c = a.nextSibling , e = 0; do { if (c && 8 === c.nodeType) { var d = c.data; if ("/$" === d) if (0 === e) break; else e--; else "$" !== d && "$?" !== d && "$!" !== d || e++ } d = c.nextSibling; f.removeChild(c); c = d } while (c); for (; b.firstChild; ) f.insertBefore(b.firstChild, c); a.data = "$"; a._reactRetry && a._reactRetry() } } ;$RC("B:0", "S:0") </script>
id="S:0"
的 div 是 Suspense
的 children
的渲染結(jié)果,不過這個div
設(shè)置了hidden
屬性。接下來的$RC
函數(shù),會負(fù)責(zé)將這個div
插入到第 1 段數(shù)據(jù)中template
標(biāo)簽所在的位置,同時刪除template
標(biāo)簽。
第二次訪問頁面:html
的內(nèi)容不會分段傳輸,評論組件也不會異步加載,而是一次性返回。這是因為Comment
組件對應(yīng)的 js 模塊已經(jīng)被加入到服務(wù)端的緩存模塊中了,再一次請求時,加載Comment
組件是一個同步的過程,所以整個渲染就是同步的。即只有當(dāng) Suspense
中包裹的組件需要異步渲染時,ssr
返回的HTML
內(nèi)容才會分段傳輸。
四、小結(jié)
本文講述了關(guān)于如何實現(xiàn)一個基本的 React SSR 應(yīng)用,希望能幫助大家更好的理解服務(wù)端渲染。
到此這篇關(guān)于React SSR服務(wù)端渲染的實現(xiàn)示例的文章就介紹到這了,更多相關(guān)React SSR服務(wù)端渲染內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React?Hooks之useDeferredValue鉤子用法示例詳解
useDeferredValue鉤子的主要目的是在React的并發(fā)模式中提供更流暢的用戶體驗,特別是在有高優(yōu)先級和低優(yōu)先級更新的情況下,本文主要講解一些常見的使用場景及其示例2023-09-09如何使用React的VideoPlayer構(gòu)建視頻播放器
本文介紹了如何使用React構(gòu)建一個基礎(chǔ)的視頻播放器組件,并探討了常見問題和易錯點,通過組件化思想和合理管理狀態(tài),可以實現(xiàn)功能豐富且性能優(yōu)化的視頻播放器2025-01-01React實現(xiàn)動態(tài)調(diào)用的彈框組件
這篇文章主要為大家詳細(xì)介紹了React實現(xiàn)動態(tài)調(diào)用的彈框組件,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-08-08