React SSR服務(wù)端渲染的實(shí)現(xiàn)示例
前言
這篇文章和大家一起來聊一聊 React SSR,本文更偏向于實(shí)戰(zhàn)。你可以從中學(xué)到:
- 從 0 到 1 搭建 React SSR
- 服務(wù)端渲染需要注意什么
- react 18 的流式渲染如何使用
一、認(rèn)識(shí)服務(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)點(diǎn)結(jié)合起來,既能降低首屏耗時(shí),又能有 SPA 的開發(fā)體驗(yàn)。這種渲染又可以稱為”同構(gòu)渲染”,將內(nèi)容的展示和交互寫成一套代碼,這一套代碼運(yùn)行兩次,一次在服務(wù)端運(yùn)行,來實(shí)現(xiàn)服務(wù)端渲染,讓 html 頁面具有內(nèi)容,另一次在客戶端運(yùn)行,用于客戶端綁定交互事件。
1.2 簡單的服務(wù)端渲染
了解基本概念后,我們開始手寫實(shí)現(xiàn)一個(gè) ssr 渲染。先來看一個(gè)簡單的服務(wù)端渲染,創(chuàng)建一個(gè) node-server文件夾, 使用 express 搭建一個(gè)服務(wù),返回一個(gè) 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')
})
運(yùn)行起來, 頁面顯示如下,查看網(wǎng)頁源代碼, body 中就包含頁面中顯示的內(nèi)容,這就是一個(gè)簡單的服務(wù)端渲染。

對(duì)于客戶端渲染,我們就比較熟悉了,像 React 腳手架運(yùn)行起來的 demo 就是一個(gè)csr。(這里小柒直接使用之前手動(dòng)搭建的 react 腳手架模版)。啟動(dòng)之后,打開網(wǎng)頁源代碼,可以看到 html 文件中的 body 標(biāo)簽中只有一個(gè)id 為root 的標(biāo)簽,沒有其他的內(nèi)容。網(wǎng)頁中的內(nèi)容是加載 script 文件后,動(dòng)態(tài)添加DOM后展現(xiàn)的。

一個(gè) React ssr 項(xiàng)目永不止上述那么簡單,那么對(duì)于日常的一個(gè) React 項(xiàng)目來說,如何實(shí)現(xiàn) SSR 呢?接下來小柒將手把手演示。
二、服務(wù)端渲染的前置準(zhǔn)備
在實(shí)現(xiàn)服務(wù)端渲染前,我們先做好項(xiàng)目的前置準(zhǔn)備。
目錄結(jié)構(gòu)改造
編譯配置改造
2.1 目錄結(jié)構(gòu)改造
React SSR 的核心即服務(wù)端客戶端執(zhí)行同一份代碼。 那我們先來改造一下模版內(nèi)容(??模版地址),將服務(wù)端代碼和客戶端代碼放到一個(gè)項(xiàng)目中。創(chuàng)建 client 和 server 目錄,分別用來放置客戶端代碼和服務(wù)端代碼。創(chuàng)建 compoment 目錄來存放公共組件,對(duì)于客戶端和服務(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ū)分
對(duì)于服務(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)識(shí)的 CSS,并且會(huì)根據(jù)目標(biāo)瀏覽器或運(yùn)行時(shí)環(huán)境添加所需的 polyfill;
// 也包括會(huì)自動(dòng)幫助我們添加 autoprefixer
postcssOptions: {
plugins: ['postcss-preset-env'],
},
},
},
'less-loader',
],
// 排除 node_modules 目錄
exclude: /node_modules/,
},
],
},
})
為項(xiàng)目啟動(dòng)方便,安裝 npm run all 來實(shí)現(xiàn)同時(shí)運(yùn)行多個(gè)腳本,我們修改下 package.json文件中 scripts 屬性,pnpm run dev 先執(zhí)行服務(wù)端代碼再執(zhí)行客戶端代碼,最后運(yùn)行打包的服務(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\"",
},
到這里項(xiàng)目前置準(zhǔn)備搭建完畢。
三、實(shí)現(xiàn) React SSR 應(yīng)用
3.1 簡單的React 組件的服務(wù)端渲染
接下來我們開始一步一步實(shí)現(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 方法來實(shí)現(xiàn),這個(gè)方法會(huì)將 jsx 對(duì)應(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字符串,不會(huì)二次加載,客戶端只會(huì)進(jìn)行事件綁定,這樣避免了閃爍,提高了首屏加載的體驗(yàn)。
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import App from './App'
// hydrateRoot 不會(huì)二次渲染,只會(huì)綁定事件
ReactDOM.hydrateRoot(document.getElementById('root')!, <App />)
注意:hydrateRoot 需要保證服務(wù)端和客戶端渲染的組件內(nèi)容相同,否則會(huì)報(bào)錯(cuò)。
運(yùn)行pnpm run dev,即可以看到 Home 組件的內(nèi)容顯示在頁面上。

但細(xì)心的你一定會(huì)發(fā)現(xiàn),點(diǎn)擊事件并不生效。原因很簡單:服務(wù)端只負(fù)責(zé)將 html 代碼返回到瀏覽器,這只是一個(gè)靜態(tài)的頁面。而事件的綁定則需要客戶端生成的 js 代碼來實(shí)現(xiàn),這就需要同構(gòu)核心步驟的第二點(diǎn),將同一份代碼在客戶端也執(zhí)行一遍,這就是所謂的“注水”。
dist/main.bundle.js 為客戶端打包的 js 代碼,修改 server/index.js 代碼,加上對(duì) js 文件的引入。注意這里添加 app.use(express.static('dist')) 這段代碼,添加一個(gè)中間件,來提供靜態(tài)文件,即可以通過 http://localhost:3000/main.bundle.js 來訪問, 否則會(huì) 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é)尾的文件,實(shí)現(xiàn)動(dòng)態(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>
`)
})
// 省略...
點(diǎn)擊文案,控制臺(tái)有內(nèi)容打印,這樣事件的綁定就成功啦。

以上僅僅是一個(gè)最簡單的 react ssr 應(yīng)用,而 ssr 項(xiàng)目需要注意的地方還有很多。接下來我們繼續(xù)探索同構(gòu)中的其他問題。
3.2 路由問題
先來看看從輸入U(xiǎn)RL地址,瀏覽器是如何顯示出界面的?
1、在瀏覽器輸入 http://localhost:3000/ 地址
2、服務(wù)端路由要找到對(duì)應(yīng)的組件,通過 renderToString 將轉(zhuǎn)化為字符串,拼接到 HTML 輸出
3、瀏覽器加載 js 文件后,解析前端路由,輸出對(duì)應(yīng)的前端組件,如果發(fā)現(xiàn)是服務(wù)端渲染,不會(huì)二次渲染,只會(huì)綁定事件,之后的點(diǎn)擊跳轉(zhuǎn)都是前端路由,與服務(wù)端路由沒有關(guān)系。
同構(gòu)中的路由問題即: 服務(wù)端路由和前端路由是不同的,在代碼處理上也不相同。服務(wù)端代碼采用StaticRouter實(shí)現(xiàn),前端路由采用BrowserRouter實(shí)現(xiàn)。
注意:StaticRouter 與 BrowserRouter 的區(qū)別如下:
BrowserRouter 的原理使用了瀏覽器的 history API ,而服務(wù)端是不能使用瀏覽器中的
API ,而StaticRouter 則是利用初始傳入url 地址,來尋找對(duì)應(yīng)的組件。
接下來對(duì)代碼進(jìn)行改造,需要提前安裝 react-router-dom。
- 新增一個(gè)
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運(yùn)行項(xiàng)目,可以看到如下內(nèi)容,說明 ssr 路由渲染成功。

3.2 狀態(tài)管理問題
在ssr中,store的問題有兩點(diǎn)需要注意:
與客戶端渲染不同,在服務(wù)器端,一旦組件內(nèi)容確定 ,就沒法重新
render,所以必須在確定組件內(nèi)容前將store的數(shù)據(jù)準(zhǔn)備好,然后和組件的內(nèi)容組合成 HTML 一起下發(fā)。store的實(shí)例只能有一個(gè)。
狀態(tài)管理我們使用 Redux Toolkit,安裝依賴 pnpm i @reduxjs/toolkit react-redux,添加 store 文件夾,編寫一個(gè)userSlice,兩個(gè)狀態(tài)status、list。
其中list的有一個(gè)初始值:
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)出了一個(gè)getStore方法用于創(chuàng)建store。
注意:到上述獲取store 實(shí)例時(shí),我們采用的是 getStore 方法來獲取。原因是在服務(wù)端,store 不能是單例的,如果直接導(dǎo)出store,用戶就會(huì)共享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文件,添加異步請(qǐng)求:
// ... 省略
// 1、添加異步請(qǐng)求
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ù)渲染會(huì)從無到有,有明顯的空白閃爍。

這是因?yàn)?code>useEffect只會(huì)在客戶端執(zhí)行,服務(wù)端不會(huì)執(zhí)行。如果要解決這個(gè)問題,服務(wù)端也要生成好這個(gè)數(shù)據(jù),然后將數(shù)據(jù)和組件一起生成 HTML。
在服務(wù)端生成 HTML 之前要實(shí)現(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ǔ)上,實(shí)現(xiàn) 2、3 就行。
matchRoutes可以幫助我們分析路由,服務(wù)端要想觸發(fā)Action,也需要有一個(gè)類似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方法時(shí),將store作為參數(shù)傳入,執(zhí)行loadData方法。將結(jié)果放入
promises數(shù)組中,結(jié)合Promise.all方法來實(shí)現(xiàn)等待異步數(shù)據(jù)獲取之后,再將數(shù)據(jù)和組件生成 HTML
效果如下,你會(huì)發(fā)現(xiàn),即使服務(wù)端已經(jīng)返回了初始數(shù)據(jù),頁面還是閃爍明顯,并且控制臺(tái)還會(huì)出現(xiàn)報(bào)錯(cuò)。


3.4 數(shù)據(jù)的脫水和注水
由于客戶端的初始store數(shù)據(jù)還是空數(shù)組,導(dǎo)致服務(wù)端和客戶端渲染的結(jié)果不一樣,造成了閃屏。我們需要讓客戶端渲染時(shí)也能拿到服務(wù)端中store的數(shù)據(jù),可以通過在window上掛載一個(gè)INITIAL\_STATE,和 HTML 一起下發(fā),這個(gè)過程也叫做“注水”。
// 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時(shí),將它作為初始值傳給state,即可拿到數(shù)據(jù)進(jìn)行渲染,這個(gè)過程也叫做”脫水”。
// store/index.ts
export const getStore = () => {
return configureStore({
// reducer是必需的,它指定了應(yīng)用程序的根reducer
reducer: {
users: usersReducer,
},
// 對(duì)象,它包含應(yīng)用程序的初始狀態(tài)
preloadedState: {
users:
typeof window !== 'undefined'
? window.INITIAL_STATE?.users
: ({
status: 'idle',
list: [],
} as UserState),
},
})
}
這樣頁面就不會(huì)出現(xiàn)閃爍現(xiàn)象,控制臺(tái)也不會(huì)出現(xiàn)報(bào)錯(cuò)了。

3.5 css 處理
客戶端渲染時(shí),一般有兩種方法引入樣式:
style-loader: 將css樣式通過style標(biāo)簽插入到DOM中MiniCssExtractPlugin: 插件將樣式打包到單獨(dú)的文件,并使用link標(biāo)簽引入.
對(duì)于服務(wù)端渲染來說,這兩種方式都不能使用。
服務(wù)端不能操作
DOM,不能使用style-loader服務(wù)端輸出的是靜態(tài)頁面,等待瀏覽器加載
css文件,如果樣式文件較大,必定會(huì)導(dǎo)致頁面閃爍。
對(duì)于服務(wù)端來說,我們可以使用isomorphic-style-loader來解決。isomorphic-style-loader利用context Api,結(jié)合useStyles hooks Api 在渲染組件渲染的拿到組件的 css 樣式,最終插入 HTML 中。
isomorphic-style-loader 這個(gè) loader 利用了 loader的 pitch 方法的特性,返回三個(gè)方法供樣式文件使用。關(guān)于 loader 的執(zhí)行機(jī)制可以戳 → loader 調(diào)用鏈。
- _getContent:數(shù)組,可以獲取用戶使用的類名等信息
- _getCss:獲取 css 樣式
- _insertCss :將 css 插入到 style 標(biāo)簽中
服務(wù)端改造:定義insertCss方法, 該方法調(diào)用 \_getCss 方法獲取將組件樣式添加到css Set中, 通過context將insertCss方法傳遞給每一個(gè)組件,當(dāng)insertCss方法被調(diào)用時(shí),則樣式將被添加到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>
`)
})
})
// ...省略
對(duì)于客戶端也要進(jìn)行處理:
- 在 App 組件中使用定義使用
StyleContext,定義insertCss方法,與服務(wù)端不同的是insertCss方法中調(diào)用_insertCss,_insertCss方法會(huì)操作DOM,將樣式插入HTML 中,功能類似于style-loader。 - 在對(duì)應(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, 否則會(huì)報(bào)錯(cuò)。
module: {
rules: [
{
test: /\.(css|less)$/,
use: [
'isomorphic-style-loader', // 服務(wù)端渲染時(shí),需要使用 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)識(shí)的 CSS,并且會(huì)根據(jù)目標(biāo)瀏覽器或運(yùn)行時(shí)環(huán)境添加所需的 polyfill;
// 也包括會(huì)自動(dòng)幫助我們添加 autoprefixer
postcssOptions: {
plugins: ['postcss-preset-env'],
},
},
},
'less-loader',
],
// 排除 node_modules 目錄
exclude: /node_modules/,
},
],
},
注意:這里服務(wù)端和客戶端都是使用 isomorphic-style-loader 去實(shí)現(xiàn)樣式的引入。
最終效果如下,不會(huì)造成樣式閃爍:

3.6 流式SSR渲染
前面的例子我們可以發(fā)現(xiàn) 3個(gè)問題:
必須在發(fā)送HTML之前拿到所有的數(shù)據(jù)
上述例子中我們需要獲取到 user 的數(shù)據(jù)之后 ,才能開始渲染。 假設(shè)我們還需要獲取評(píng)論信息,那么我們只有獲取到這兩部分的數(shù)據(jù)之后,才能開始渲染。而在實(shí)際場景中接口的速度也不同,等到接口慢的數(shù)據(jù)獲取到之后再開始渲染,務(wù)必會(huì)影響首屏的速度。
必須等待所有的 JavaScript 內(nèi)容加載完才能開始吸水
上述例子中我們提到過,客戶端渲染的組件樹要和服務(wù)端渲染的組件樹保持一致,否則React就無法匹配,客戶端換渲染會(huì)代替服務(wù)端渲染。假如組件樹的加載和執(zhí)行的執(zhí)行比較長,那么吸水也需要等待所有組件樹都加載執(zhí)行完。
必須等所有的組件都吸水完才能開始頁面交互
React DOM Root 只會(huì)吸水一次,一旦開始吸水,就不會(huì)停止,只有等到吸水完畢中后才能交互。假如 js 的執(zhí)行時(shí)間很長,那么用戶交互在這段時(shí)間內(nèi)就得不到響應(yīng),務(wù)必就會(huì)給用戶一種卡頓的感覺,留下不好的體驗(yàn)。
react 18 以前上面3個(gè)問題都是我們在 ssr 渲染過程中需要考慮的問題,而 react 18 給 ssr 提供的新特性可以幫助我們解決。
支持服務(wù)端流式輸出 HTML(
renderToPipeableStream)。支持客戶端選擇性吸水。使用
Suspense包裹對(duì)應(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)評(píng)論</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。
有兩種方式替換,第一種官方推薦寫法,需要自己寫一個(gè)組件傳遞給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() {
// 錯(cuò)誤發(fā)生時(shí)替換外殼
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() {
// 錯(cuò)誤發(fā)生時(shí)替換外殼
res.statusCode = 500
res.send('<!doctype><p>Error</p>')
},
}
)
setTimeout(abort, 10_000)
})
})
兩種方式都可以,這里需要注意 js 的處理:
bootstrapScripts:一個(gè) URL 字符串?dāng)?shù)組,它們將被轉(zhuǎn)化為
當(dāng)看到評(píng)論組件能異步加載出來,并且模版文件中出現(xiàn)占位符即成功。

簡單介紹下 ssr 流式替換的流程:先使用占位符,再替換為真實(shí)的內(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)評(píng)論</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é)果,不過這個(gè)div設(shè)置了hidden屬性。接下來的$RC 函數(shù),會(huì)負(fù)責(zé)將這個(gè)div插入到第 1 段數(shù)據(jù)中template標(biāo)簽所在的位置,同時(shí)刪除template標(biāo)簽。
第二次訪問頁面:html的內(nèi)容不會(huì)分段傳輸,評(píng)論組件也不會(huì)異步加載,而是一次性返回。這是因?yàn)?code>Comment組件對(duì)應(yīng)的 js 模塊已經(jīng)被加入到服務(wù)端的緩存模塊中了,再一次請(qǐng)求時(shí),加載Comment組件是一個(gè)同步的過程,所以整個(gè)渲染就是同步的。即只有當(dāng) Suspense中包裹的組件需要異步渲染時(shí),ssr 返回的HTML內(nèi)容才會(huì)分段傳輸。
四、小結(jié)
本文講述了關(guān)于如何實(shí)現(xiàn)一個(gè)基本的 React SSR 應(yīng)用,希望能幫助大家更好的理解服務(wù)端渲染。
到此這篇關(guān)于React SSR服務(wù)端渲染的實(shí)現(xiàn)示例的文章就介紹到這了,更多相關(guān)React SSR服務(wù)端渲染內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
React?Native項(xiàng)目設(shè)置路徑別名示例
這篇文章主要為大家介紹了React?Native項(xiàng)目設(shè)置路徑別名實(shí)現(xiàn)示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-05-05
React?Hooks之useDeferredValue鉤子用法示例詳解
useDeferredValue鉤子的主要目的是在React的并發(fā)模式中提供更流暢的用戶體驗(yàn),特別是在有高優(yōu)先級(jí)和低優(yōu)先級(jí)更新的情況下,本文主要講解一些常見的使用場景及其示例2023-09-09
如何使用React的VideoPlayer構(gòu)建視頻播放器
本文介紹了如何使用React構(gòu)建一個(gè)基礎(chǔ)的視頻播放器組件,并探討了常見問題和易錯(cuò)點(diǎn),通過組件化思想和合理管理狀態(tài),可以實(shí)現(xiàn)功能豐富且性能優(yōu)化的視頻播放器2025-01-01
React實(shí)現(xiàn)動(dòng)態(tài)調(diào)用的彈框組件
這篇文章主要為大家詳細(xì)介紹了React實(shí)現(xiàn)動(dòng)態(tài)調(diào)用的彈框組件,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-08-08

