React SSR服务端渲染的实现示例
作者:crazy的蓝色梦想
前言
这篇文章和大家一起来聊一聊 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; // 也包括会自动帮助我们添加 autoprefixer postcssOptions: { 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: number name: string first_name: string last_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是必需的,它指定了应用程序的根reducer reducer: { 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、更新 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
调用 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.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, }, ]
服务端代码如下:
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、创建store const store = getStore() const promises = [] // 2、matchRoutes 分析路由组件,分析 store 中需要的数据 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、触发 Action 获取数据、更新 store 的数据 item.route.loadData(store).then(resolve).catch(resolve) }) promises.push(promise) } }) // ... 省略 // 5、结合数据和组件生成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
方法分析路由,当路由中有loadData
方法时,将store
作为参数传入,执行loadData
方法。将结果放入
promises
数组中,结合Promise.all
方法来实现等待异步数据获取之后,再将数据和组件生成 HTML
效果如下,你会发现,即使服务端已经返回了初始数据,页面还是闪烁明显,并且控制台还会出现报错。
3.4 数据的脱水和注水
由于客户端的初始store
数据还是空数组,导致服务端和客户端渲染的结果不一样,造成了闪屏。我们需要让客户端渲染时也能拿到服务端中store
的数据,可以通过在window
上挂载一个INITIAL\_STATE
,和 HTML 一起下发,这个过程也叫做“注水”。
// 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> `)
在客户端创建store
时,将它作为初始值传给state
,即可拿到数据进行渲染,这个过程也叫做”脱水”。
// store/index.ts export const getStore = () => { return configureStore({ // reducer是必需的,它指定了应用程序的根reducer reducer: { 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; // 也包括会自动帮助我们添加 autoprefixer postcssOptions: { 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 = 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() { // 错误发生时替换外壳 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、结合数据和组件生成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触发前执行 this.push(tailTpl) callback() }, }) pipe(injectTemplateTransform).pipe(res) }, onErrorShell() { // 错误发生时替换外壳 res.statusCode = 500 res.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; 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
的渲染结果,不过这个div
设置了hidden
属性。接下来的$RC
函数,会负责将这个div
插入到第 1 段数据中template
标签所在的位置,同时删除template
标签。
第二次访问页面:html
的内容不会分段传输,评论组件也不会异步加载,而是一次性返回。这是因为Comment
组件对应的 js 模块已经被加入到服务端的缓存模块中了,再一次请求时,加载Comment
组件是一个同步的过程,所以整个渲染就是同步的。即只有当 Suspense
中包裹的组件需要异步渲染时,ssr
返回的HTML
内容才会分段传输。
四、小结
本文讲述了关于如何实现一个基本的 React SSR 应用,希望能帮助大家更好的理解服务端渲染。
到此这篇关于React SSR服务端渲染的实现示例的文章就介绍到这了,更多相关React SSR服务端渲染内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!