前言
这篇文章和大家一起来聊一聊 React SSR,本文更偏向于实战。你可以从中学到:
-
从 0 到 1 搭建 React SSR
-
服务端渲染需要注意什么
-
react 18 的流式渲染如何使用
文章如有误,欢迎指出,大家一起学习交流~。 👇项目地址 ,期待大家的一键三连 💗
一、认识服务端渲染
1.1 基本概念
Server Side Rendering
即服务端渲染。在服务端渲染成 HTM L片段 ,发送到浏览器端,浏览器端完成状态与事件的绑定,达到页面完全可交互的过程。
现阶段我们说的 ssr 渲染是现代化的服务端渲染,将传统服务端渲染和客户端渲染的优点结合起来,既能降低首屏耗时,又能有 SPA 的开发体验。这种渲染又可以称为”同构渲染”,将内容的展示和交互写成一套代码,这一套代码运行两次,一次在服务端运行,来实现服务端渲染,让 html 页面具有内容,另一次在客户端运行,用于客户端绑定交互事件。
1.2 简单的服务端渲染
了解基本概念后,我们开始手写实现一个 ssr 渲染。先来看一个简单的服务端渲染,创建一个 node-server
文件夹, 使用 express
搭建一个服务,返回一个 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')
})
运行起来, 页面显示如下,查看网页源代码, body 中就包含页面中显示的内容,这就是一个简单的服务端渲染。
对于客户端渲染,我们就比较熟悉了,像 React 脚手架运行起来的 demo 就是一个csr。(这里小柒直接使用之前手动搭建的 react 脚手架模版)。启动之后,打开网页源代码,可以看到 html
文件中的 body
标签中只有一个id 为root
的标签,没有其他的内容。网页中的内容是加载 script
文件后,动态添加DOM
后展现的。
一个 React ssr
项目永不止上述那么简单,那么对于日常的一个 React
项目来说,如何实现 SSR
呢?接下来小柒将手把手演示。
二、服务端渲染的前置准备
在实现服务端渲染前,我们先做好项目的前置准备。
-
目录结构改造
-
编译配置改造
2.1 目录结构改造
React SSR 的核心即服务端客户端执行同一份代码。 那我们先来改造一下模版内容(👇模版地址),将服务端代码和客户端代码放到一个项目中。创建 client
和 server
目录,分别用来放置客户端代码和服务端代码。创建 compoment
目录来存放公共组件,对于客户端和服务端所能执行的同一份代码那一定是组件代码,只有组件才是公共的。目录结构如下:
compoment/home
文件的内容很简单,即网页中显示的内容。
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 打包环境区分
对于服务端代码的编译我们也借助 webpack
,在 script 目录中 创建 webpack.serve.js
文件,目标编译为 node
,打包输出目录为 build。为了避免 webpack 重复打包,使用 webpack-node-externals
,排除 node 中的内置模块和 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 中的内置模块和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()], // 避免重复打包module: {rules: [{test: /\.(css|less)$/,use: ['css-loader',{loader: 'postcss-loader',options: {// 它可以帮助我们将一些现代的 CSS 特性,转成大多数浏览器认识的 CSS,并且会根据目标浏览器或运行时环境添加所需的 polyfill;// 也包括会自动帮助我们添加 autoprefixerpostcssOptions: {plugins: ['postcss-preset-env'],},},},'less-loader',],// 排除 node_modules 目录exclude: /node_modules/,},],},
})
为项目启动方便,安装 npm run all
来实现同时运行多个脚本,我们修改下 package.json
文件中 scripts 属性,pnpm run dev
先执行服务端代码再执行客户端代码,最后运行打包的服务端代码。
"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\"",},
到这里项目前置准备搭建完毕。
三、实现 React SSR 应用
3.1 简单的React 组件的服务端渲染
接下来我们开始一步一步实现同构,让我们回忆一下前面说的同构的核心步骤:同一份代码先在服务端执行一遍生成 html 文件,再到客户端执行一遍,加载 js 代码完成事件绑定。
第一步:我们引入 conpoment/home
组件到 server.js 中,服务端要做的就是将 Home 组件中的 jsx 内容转为 html 字符串返回给浏览器,我们可以利用 react-dom/server
中的 renderToString
方法来实现,这个方法会将 jsx 对应的虚拟dom 进行编译,转换为 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 可以直接接管由服务端生成的HTML字符串,不会二次加载,客户端只会进行事件绑定,这样避免了闪烁,提高了首屏加载的体验。
import * as React from 'react'
import * as ReactDOM from 'react-dom/client'import App from './App'// hydrateRoot 不会二次渲染,只会绑定事件
ReactDOM.hydrateRoot(document.getElementById('root')!, <App />)
注意:hydrateRoot 需要保证服务端和客户端渲染的组件内容相同,否则会报错。
运行pnpm run dev
,即可以看到 Home 组件的内容显示在页面上。
但细心的你一定会发现,点击事件并不生效。原因很简单:服务端只负责将 html 代码返回到浏览器,这只是一个静态的页面。而事件的绑定则需要客户端生成的 js 代码来实现,这就需要同构核心步骤的第二点,将同一份代码在客户端也执行一遍,这就是所谓的“注水”。
dist/main.bundle.js
为客户端打包的 js 代码,修改 server/index.js
代码,加上对 js 文件的引入。注意这里添加 app.use(express.static('dist'))
这段代码,添加一个中间件,来提供静态文件,即可以通过 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 值结尾的,不好直接写死, 我们可以读取 dist
中以.js
结尾的文件,实现动态引入。
// 省略...
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>`)
})
// 省略...
点击文案,控制台有内容打印,这样事件的绑定就成功啦。
以上仅仅是一个最简单的 react ssr 应用,而 ssr 项目需要注意的地方还有很多。接下来我们继续探索同构中的其他问题。
3.2 路由问题
先来看看从输入URL地址,浏览器是如何显示出界面的?
1、在浏览器输入 http://localhost:3000/ 地址
2、服务端路由要找到对应的组件,通过 renderToString 将转化为字符串,拼接到 HTML 输出
3、浏览器加载 js 文件后,解析前端路由,输出对应的前端组件,如果发现是服务端渲染,不会二次渲染,只会绑定事件,之后的点击跳转都是前端路由,与服务端路由没有关系。
同构中的路由问题即: 服务端路由和前端路由是不同的,在代码处理上也不相同。服务端代码采用StaticRouter
实现,前端路由采用BrowserRouter
实现。
注意:StaticRouter 与 BrowserRouter 的区别如下:
BrowserRouter 的原理使用了浏览器的 history API ,而服务端是不能使用浏览器中的
API ,而StaticRouter 则是利用初始传入url 地址,来寻找对应的组件。
接下来对代码进行改造,需要提前安装 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
- 服务端路由改造
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
运行项目,可以看到如下内容,说明 ssr 路由渲染成功。
3.2 状态管理问题
在ssr
中,store
的问题有两点需要注意:
-
与客户端渲染不同,在服务器端,一旦组件内容确定 ,就没法重新
render
,所以必须在确定组件内容前将store
的数据准备好,然后和组件的内容组合成 HTML 一起下发。 -
store
的实例只能有一个。
状态管理我们使用 Redux Toolkit,安装依赖 pnpm i @reduxjs/toolkit react-redux
,添加 store 文件夹,编写一个userSlice
,两个状态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: numbername: stringfirst_name: stringlast_name: string
}// 定义初始状态
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是必需的,它指定了应用程序的根reducerreducer: {users: usersReducer,},})
}// 全局State类型
export type RootState = ReturnType<ReturnType<typeof getStore>['getState']>export type AppDispatch = ReturnType<typeof getStore>['dispatch']
在store/index.ts
中我们导出了一个getStore
方法用于创建store
。
注意:到上述获取store 实例时,我们采用的是 getStore 方法来获取。原因是在服务端,store 不能是单例的,如果直接导出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>)
}
改造服务端:
// ...省略
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 异步数据的处理
上述例子中,已经添加了store
,但如果初始的userList
数据是通过接口拿到的,服务端又该如何处理呢?
我们先来看下如果是客户端渲染是什么流程:
1、创建store
2、根据路由显示组件
3、触发Action
获取数据
4、更新store
的数据
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、更新 storeextraReducers: (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
调用 dispatch
更新数据。
// ... 省略
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>)
}
从效果上可以发现list
数据渲染会从无到有,有明显的空白闪烁。
这是因为useEffect
只会在客户端执行,服务端不会执行。如果要解决这个问题,服务端也要生成好这个数据,然后将数据和组件一起生成 HTML。
在服务端生成 HTML 之前要实现流程如下:
1、创建store
2、根据路由分析store
中需要的数据
3、触发Action
获取数据
4、更新store
的数据
5、结合数据和组件生成HTML
改造服务端,即我们需要在现有的基础上,实现 2、3 就行。
matchRoutes
可以帮助我们分析路由,服务端要想触发Action
,也需要有一个类似useEffect
方法用于服务端获取数据。我们可以给组件添加loadData
方法,并修改路由配置。
// Home.tsxexport 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,},
]
服务端代码如下:
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、创建storeconst store = getStore()const promises = []// 2、matchRoutes 分析路由组件,分析 store 中需要的数据const matchedRoutes = matchRoutes(routes, req.url)// https://reactrouter.com/6.28.0/hooks/use-routesmatchedRoutes?.forEach((item) => {if (item.route.loadData) {const promise = new Promise((resolve) => {// 3/4、触发 Action 获取数据、更新 store 的数据item.route.loadData(store).then(resolve).catch(resolve)})promises.push(promise)}})// ... 省略// 5、结合数据和组件生成HTMLPromise.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
方法分析路由,当路由中有loadData
方法时,将store
作为参数传入,执行loadData
方法。 -
将结果放入
promises
数组中,结合Promise.all
方法来实现等待异步数据获取之后,再将数据和组件生成 HTML
效果如下,你会发现,即使服务端已经返回了初始数据,页面还是闪烁明显,并且控制台还会出现报错。
3.4 数据的脱水和注水
由于客户端的初始store
数据还是空数组,导致服务端和客户端渲染的结果不一样,造成了闪屏。我们需要让客户端渲染时也能拿到服务端中store
的数据,可以通过在window
上挂载一个INITIAL\_STATE
,和 HTML 一起下发,这个过程也叫做“注水”。
// server/index.jsres.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>`)
在客户端创建store
时,将它作为初始值传给state
,即可拿到数据进行渲染,这个过程也叫做”脱水”。
// store/index.ts
export const getStore = () => {return configureStore({// reducer是必需的,它指定了应用程序的根reducerreducer: {users: usersReducer,},// 对象,它包含应用程序的初始状态preloadedState: {users:typeof window !== 'undefined'? window.INITIAL_STATE?.users: ({status: 'idle',list: [],} as UserState),},})
}
这样页面就不会出现闪烁现象,控制台也不会出现报错了。
3.5 css 处理
客户端渲染时,一般有两种方法引入样式:
-
style-loader
: 将css
样式通过style
标签插入到DOM
中 -
MiniCssExtractPlugin
: 插件将样式打包到单独的文件,并使用link
标签引入.
对于服务端渲染来说,这两种方式都不能使用。
-
服务端不能操作
DOM
,不能使用style-loader
-
服务端输出的是静态页面,等待浏览器加载
css
文件,如果样式文件较大,必定会导致页面闪烁。
对于服务端来说,我们可以使用isomorphic-style-loader
来解决。isomorphic-style-loader
利用context Api
,结合useStyles hooks Api
在渲染组件渲染的拿到组件的 css 样式,最终插入 HTML 中。
isomorphic-style-loader 这个 loader 利用了 loader的 pitch 方法的特性,返回三个方法供样式文件使用。关于 loader 的执行机制可以戳 → loader 调用链。
- _getContent:数组,可以获取用户使用的类名等信息
- _getCss:获取 css 样式
- _insertCss :将 css 插入到 style 标签中
服务端改造:定义insertCss
方法, 该方法调用 \_getCss
方法获取将组件样式添加到css Set
中, 通过context
将insertCss
方法传递给每一个组件,当insertCss
方法被调用时,则样式将被添加到css Set
中,最后通过[...css].join('')
获取页面的样式,放入<style>
标签中。
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 方法,调用 _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> 标签中 --><style>${[...css].join('')}</style></head><body><div id="root">${content}</div></body></html>`)})
})
// ...省略
对于客户端也要进行处理:
- 在 App 组件中使用定义使用
StyleContext
,定义insertCss
方法,与服务端不同的是insertCss
方法中调用_insertCss
,_insertCss
方法会操作DOM,将样式插入HTML 中,功能类似于style-loader
。 - 在对应的组件中引入
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>)
}
服务端/客户端的编译配置要注意,isomorphic-style-loader
需要配合css module
,css-loader
的配置要开启css module
, 否则会报错。
module: {rules: [{test: /\.(css|less)$/,use: ['isomorphic-style-loader', // 服务端渲染时,需要使用 isomorphic-style-loader 来处理样式{loader: 'css-loader',options: {modules: {localIdentName: '[name]_[local]_[hash:base64:5]', // 开启 css module},esModule: false, // 启用 CommonJS 模块语法},},{loader: 'postcss-loader',options: {// 它可以帮助我们将一些现代的 CSS 特性,转成大多数浏览器认识的 CSS,并且会根据目标浏览器或运行时环境添加所需的 polyfill;// 也包括会自动帮助我们添加 autoprefixerpostcssOptions: {plugins: ['postcss-preset-env'],},},},'less-loader',],// 排除 node_modules 目录exclude: /node_modules/,},],},
注意:这里服务端和客户端都是使用 isomorphic-style-loader 去实现样式的引入。
最终效果如下,不会造成样式闪烁:
3.6 流式SSR渲染
前面的例子我们可以发现 3个问题:
-
必须在发送HTML之前拿到所有的数据
上述例子中我们需要获取到 user 的数据之后 ,才能开始渲染。 假设我们还需要获取评论信息,那么我们只有获取到这两部分的数据之后,才能开始渲染。而在实际场景中接口的速度也不同,等到接口慢的数据获取到之后再开始渲染,务必会影响首屏的速度。
-
必须等待所有的 JavaScript 内容加载完才能开始吸水
上述例子中我们提到过,客户端渲染的组件树要和服务端渲染的组件树保持一致,否则React就无法匹配,客户端换渲染会代替服务端渲染。假如组件树的加载和执行的执行比较长,那么吸水也需要等待所有组件树都加载执行完。
-
必须等所有的组件都吸水完才能开始页面交互
React DOM Root 只会吸水一次,一旦开始吸水,就不会停止,只有等到吸水完毕中后才能交互。假如 js 的执行时间很长,那么用户交互在这段时间内就得不到响应,务必就会给用户一种卡顿的感觉,留下不好的体验。
react 18 以前上面3个问题都是我们在 ssr 渲染过程中需要考虑的问题,而 react 18 给 ssr 提供的新特性可以帮助我们解决。
-
支持服务端流式输出 HTML(
renderToPipeableStream
)。 -
支持客户端选择性吸水。使用
Suspense
包裹对应的组件。
接下来开始进行代码改造:
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}>这是相关评论</div>
}export default Comment
2、在Home
组件中使用 Suspense
包裹Comment
组件。Suspense
组件必须结合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、服务端将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>)
}
服务端改造: 这种方式没法直接传递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 = trueconst 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() {// 错误发生时替换外壳res.statusCode = 500res.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、结合数据和组件生成HTMLPromise.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 = trueconst injectTemplateTransform = new Transform({transform(chunk, _encoding, callback) {if (isShellStream) {this.push(`${headTpl}${chunk.toString()}`)isShellStream = false} else {this.push(chunk)}callback()},flush(callback) {// end触发前执行this.push(tailTpl)callback()},})pipe(injectTemplateTransform).pipe(res)},onErrorShell() {// 错误发生时替换外壳res.statusCode = 500res.send('<!doctype><p>Error</p>')},})setTimeout(abort, 10_000)})
})
两种方式都可以,这里需要注意 js 的处理:
-
bootstrapScripts
:一个 URL 字符串数组,它们将被转化为
当看到评论组件能异步加载出来,并且模版文件中出现占位符即成功。
简单介绍下 ssr 流式替换的流程:先使用占位符,再替换为真实的内容。
第一次访问页面:ssr 第 1 段数据传输,Suspense
组件包裹的部分先是使用<templte id="B:0"></template
>标签占位children
,注释 <!—$?—> 和 <!—/$—>
中间的内容表示异步渲染出来的,并展示fallback
中的内容。
<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>
传输的第 2 段数据,经过格式化后,如下:
<div hidden id="S:0"><div>这是相关评论</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;elsee--;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
的渲染结果,不过这个div
设置了hidden
属性。接下来的$RC
函数,会负责将这个div
插入到第 1 段数据中template
标签所在的位置,同时删除template
标签。
第二次访问页面:html
的内容不会分段传输,评论组件也不会异步加载,而是一次性返回。这是因为Comment
组件对应的 js 模块已经被加入到服务端的缓存模块中了,再一次请求时,加载Comment
组件是一个同步的过程,所以整个渲染就是同步的。即只有当 Suspense
中包裹的组件需要异步渲染时,ssr
返回的HTML
内容才会分段传输。
四、小结
本文讲述了关于如何实现一个基本的 React SSR 应用,希望能帮助大家更好的理解服务端渲染。