react 的核心模块是可以做到平台无关的,在做 fiber 树渲染的时候,再根据需求选择在浏览器上渲染 DOM,还是在服务端渲染生成 html 字符,或者是在其它实现 HostConfig 协议的场景实现任意端的渲染。
react 的服务端渲染(ssr)和 ejs 的区别在于,除了在服务端渲染出 html 外,还支持服务端的渲染产物在客户端无缝绑定 react 在客户端的能力,即同构。
ssr 能在 SEO 和 首屏速度方面带来一定收益,但是同时也会给代码的复杂性和维护成本上带来负面影响,需要根据项目实际情况做好权衡。这里简单总结一下 ssr 过程中可能会遇到的问题和处理方案,内容结构见下图。
1. 基础的 ssr 实现
react 官方 API 中关于 ssr 的部分提供了很简单的几个 API https://react.dev/reference/react-dom/server
实际项目中,ssr 还有很多问题要处理,但是 react 只关注组件渲染本身,这里首先看一下几个官方 API 的简单使用。
- renderToString 将 React 树渲染为一个 HTML 字符串
- renderToPipeableStream 将 react 组件渲染为 nodejs 的可读流
- renderToReadableStream 将 react 组件渲染为 Web 可读流
- renderToStaticMarkup 将非交互的 React 组件树渲染成 HTML 字符串
- renderToStaticNodeStream 将非交互的 React 组件树渲染成 nodejs 的可读流
renderToString
看方法名称就比较好理解了,在渲染步骤中,react 不再是根据 fiber 树创建 dom 节点,而是生成 html 字符串。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import Koa from 'koa';
import Router from '@koa/router';
import React from 'react';
import { renderToString }from"react-dom/server";
import App from './App';
const SERVER_PORT = 8002;
const app = new Koa();
const router = new Router();
router.get('/', (ctx, next) =>{
const content = renderToString(<App/>);
ctx.body=content;
})
app.use(router.routes());
app.listen(SERVER_PORT, () => {
console.log(`server is started on port ${SERVER_PORT} ...`);
})
renderToPipeableStream
react18 中提供了 lazy、Suspense,配合 renderToPipeableStream 方法,实现流式内容下发的效果,加快首屏展示速度。
使用 renderToString 生成 html 时,可能有部分组件依赖其它资源加载生成。如果等待所有组件渲染完成再输出内容,速度会变得很慢。如果使用上面的 lazy + Suspense 方案,renderToString 甚至只能输出 fallback 的内容,不支持加载后的结果渲染。
SlowComponent.tsx1
2
3
4
5
6
7
8
9
10
11
12import React, { lazy } from 'react';
const SlowComponent = lazy(async () => {
await new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 2000)
})
return { default: () => <p>a slow component</p> };
})
export default SlowComponent;
App.tsx1
2
3
4
5
6
7
8
9
10
11
12
13
14import React, { Suspense } from 'react';
import SlowComponent from './SlowComponent';
import './App.css';
const App = function() {
return <>
<p className='reactApp'>react app</p>
<Suspense fallback={<p>loading...</p>}>
<SlowComponent />
</Suspense>
</>
}
export default App;
server.tsx1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30import Koa from 'koa';
import Router from '@koa/router';
import React from 'react';
import { renderToString, renderToPipeableStream }from"react-dom/server";
import App from './App';
const SERVER_PORT = 8002;
const app = new Koa();
const router = new Router();
router.get('/', async (ctx, next) =>{
await new Promise<void>((resolve, reject) => {
const readableSteam = renderToPipeableStream(<App />, {
onShellReady() {
ctx.respond = false;
ctx.res.statusCode = 200;
ctx.response.set('content-type', 'text/html');
readableSteam.pipe(ctx.res);
},
onError(err) {
reject(err);
},
});
})
})
app.use(router.routes());
app.listen(SERVER_PORT, () => {
console.log(`server is started on port ${SERVER_PORT} ...`);
})
这样就能实现首屏内容快速加载,耗时内容流式下发的效果。
慢组件加载完成后
renderToReadableStream
同 reanderToPipeableStream,只是返回的是 Web 可读流,而不是 nodejs 的可读流,一般在 Deno 或支持 Web Streams 的运行时中使用。
renderToStaticMarkup
和 renderToString 类似,只是其生成的内容无法在客户端水合(后面会提到),适合纯展示类型静态页。
renderToStaticNodeStream
和 renderToStaticMarkup 类似,其生成的内容是一个 node 可读流,支持 Suspend,但是无法再客户端水合。
2. 同构与水合
在上面示例的基础上,添加一个点击交互组件,客户端渲染,组件正常工作。服务端渲染,点击后没有反应。
查看服务端渲染返回的内容,也确实没有任何脚本内容。
修改 client 渲染的入口,client 端的 react 将会连接到内部有 domNode 的 HTML 上,然后接管其中的 domNode,这个操作称为“水合”。
1 | const root = hydrateRoot(rootContainer, <App />); |
修改 client 打包配置,让 client 的 output 输出为一个 client.js。
此时可以将 client.js 的 url 直接插入服务端渲染输出的 html 中,或者通过 renderToPipeableStream 提供的 bootstrapScripts 选项来设置,下面是一个通过 bootstrapScripts 来设置的示例:
1 | app.use(KoaStatic(path.resolve(__dirname, '../client/'))); |
可以看到 ssr 返回的代码中附加了客户端的脚本。此时如果服务端渲染的 dom 结构和客户端渲染的结构不同,会导致 hydrate 时无法找到目标 dom,无法正确接管,无法达到正确效果。
注意:实际生产环境比这种情况要复杂,可能存在 hash 命名或者代码/公共库/运行时拆分,需要结合打包工具插件生成 assetsMap 来获取打包后的资源。
3. 路由处理
ssr路由支持
客户端渲染时,一般可以通过 hash 或者 pushState 来实现单页应用路由,而这俩在服务端都无法使用。
react-router-dom 提供了 StaticRouter 来实现服务端渲染的路由。
1 | router.get(/^\/ssr.*/gim, async (ctx, next) =>{ |
注意点:
- 必须使用 StaticRouter
- 后端需要自己处理 router 请求路径的问题
路由同构
此时,ssr 应用已经支持了后端路由,在页面上通过 Link 切换路由时,可以看到应用内容随路由变化。但是路由每次切换时,都是一次刷新页面。
此时在客户端使用 BrowserRouter 实现同样的路由逻辑,使用 bootstrapScripts 开启客户端水合,即可对路由逻辑进行同构。
服务端:1
2
3
4
5
6...
const readableSteam = renderToPipeableStream(data, {
bootstrapScripts: ['/client.js'],
...
});
...
客户端:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import React, { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom';
import routes from './Router';
import App from './App'
import './index.css'
const rootContainer = document.querySelector('#root');
if (rootContainer) {
hydrateRoot(rootContainer, <StrictMode>
<BrowserRouter>
<App />
{ routes }
</BrowserRouter>
</StrictMode>)
}
注意点:
- 因为server端对路由增加了支持,要小心 bootstrapScripts 解析路径不受影响,否则会导致拉取脚本时拿到 ssr 返回的 html 结果而导致 hydrate 失败
- 客户端需要使用 BrowserRouter 而不是 HashRouter,否则会导致从子路由进入应用时 hydrate 失败
4. 样式处理
在“基础的 ssr 实现”示例中, App 组件引入了样式,在客户端渲染时,通过 style-loader 或者 MiniCssExtractPlugin.loader 可以让样式生效,但是使用服务端渲染时,样式无效了。
对于不同方式的样式,ssr 时也需要做不同的处理。
同构时由客户端设置
在“同构与水合”、“路由处理” 的示例中,客户端打包时选择用 style-loader 将样式附加到页面中,所以一旦水合成功,页面样式就设置成功了。
这种方式会导致 ssr 直出的时候实际上是丢失样式的,直到客户端第二次渲染完成后,样式才设置上。对于比较复杂的页面,会有样式闪烁的问题。
使用 isomorphic-style-loader
isomorphic-style-loader 可以像客户端的 style-loader 一样,在 ssr 时向 html 中插入样式。
项目地址:https://github.com/kriasoft/isomorphic-style-loader
组件中1
2
3
4
5
6
7
8
9
10
11
12
13import React from 'react'
import withStyles from 'isomorphic-style-loader/withStyles'
import s from './App.scss'
function App(props, context) {
return (
<div className={s.root}>
<h1 className={s.title}>Hello, world!</h1>
</div>
)
}
export default withStyles(s)(App)
服务端1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import StyleContext from 'isomorphic-style-loader/StyleContext'
import App from './App.js'
...
server.get('*', (req, res, next) => {
const css = new Set() // CSS for all rendered React components
const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))
const body = ReactDOM.renderToString(
<StyleContext.Provider value={{ insertCss }}>
<App />
</StyleContext.Provider>
)
const html = `<!doctype html>
<html>
<head>
<script src="client.js" defer></script>
<style>${[...css].join('')}</style>
</head>
<body>
<div id="root">${body}</div>
</body>
</html>`
res.status(200).send(html)
})
...
客户端 hydrate 时1
2
3
4
5
6
7
8
9
10
11
12
13
14
15...
import StyleContext from 'isomorphic-style-loader/StyleContext'
import App from './App.js'
const insertCss = (...styles) => {
const removeCss = styles.map(style => style._insertCss())
return () => removeCss.forEach(dispose => dispose())
}
ReactDOM.hydrate(
<StyleContext.Provider value={{ insertCss }}>
<App />
</StyleContext.Provider>,
document.getElementById('root')
)
最终生成的 html1
2
3
4
5
6
7
8
9
10<html>
<head>
...
<style type="text/css">
.App_root_Hi8 { padding: 10px }
.App_title_e9Q { color: red }
</style>
</head>
...
</html>
打包工具获取资源表
这种方式是 react 官方文档在流式 ssr 中推荐的一种方式,我感觉这是最贴近生产,侵入性也是相对较小的一种方式了。
服务端:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 你需要从你的打包构建工具中获取这个 JSON。
const assetMap = {
'styles.css': '/styles.123456.css',
'main.js': '/main.123456.js'
};
app.use('/', (request, response) => {
const { pipe } = renderToPipeableStream(<App assetMap={assetMap} />, {
// 注意: 由于这些数据并非用户生成,所以使用 stringify 是安全的。
bootstrapScriptContent: `window.assetMap = ${JSON.stringify(assetMap)};`,
bootstrapScripts: [assetMap['main.js']],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});
设置 bootstrapScriptContent 是为了让客户端也能获取到一样的数据,以支持同构。
客户端:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// index.tsx
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App assetMap={window.assetMap} />);
// App.tsx
export default function App({ assetMap }) {
return (
<html>
<head>
...
<link rel="stylesheet" href={assetMap['styles.css']}></link>
...
</head>
...
</html>
);
}
5. 状态和数据请求
redux 的 ssr 同构
在组件中使用 redux 维护全局状态,并在路由页面中异步加载数据
store.tsx1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81import { createAsyncThunk, createSlice, configureStore, combineReducers } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useSelector, useDispatch } from 'react-redux';
// 命名空间、全局状态初始值
const namespace = 'global';
const initialState = {
page1Data: 'loading...',
page2Data: 'loading...',
};
// 异步获取数据方法封装
export const fetchPage1Data = createAsyncThunk(
`${namespace}/fetchPage1Data`,
async (dataName: string) => {
const res = await new Promise((resolve, reject) => {
console.log('fetch data1...')
setTimeout(() => {
resolve(`remote page1 data of ${dataName}`)
}, 1000)
})
return { page1Data: res };
},
);
export const fetchPage2Data = createAsyncThunk(
`${namespace}/fetchPage2Data`,
async (dataName: string) => {
const res = await new Promise((resolve, reject) => {
console.log('fetch data2...')
setTimeout(() => {
resolve(`remote page2 data of ${dataName}`)
}, 1000)
})
return { page2Data: res };
},
);
// 创建带有命名空间的reducer
const globalInfoSlice = createSlice({
name: namespace,
initialState,
reducers: {
clearData: (state) => {
state.page1Data = '';
state.page2Data = '';
},
},
// 处理异步reducer
extraReducers: (builder) => {
builder
.addCase(fetchPage1Data.fulfilled, (state, { payload }) => {
if (!payload) return;
state.page1Data = payload.page1Data as string;
})
.addCase(fetchPage2Data.fulfilled, (state, { payload }) => {
if (!payload) return;
state.page2Data = payload.page2Data as string;
})
},
});
// 封装 hook
const reducer = combineReducers({
global: globalInfoSlice.reducer,
});
const store = configureStore({
reducer,
});
export const { clearData } = globalInfoSlice.actions;
export const selectGlobal = (state: RootState) => state.global;
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
export default store;
路由页面中加载数据、使用数据1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import React, { useEffect } from 'react';
import { useAppSelector, useAppDispatch, fetchPage1Data } from './store';
const RoutePage1 = function() {
const dispatch = useAppDispatch();
const page1Data = useAppSelector((state) => state.global.page1Data);
useEffect(() => {
dispatch(fetchPage1Data('data1'))
}, [])
return <>
<p>content of route 1</p>
<p>remote data of page1 from redux: {page1Data}</p>
</>
}
export default RoutePage1;
entry 入口 client.tsx1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import React, { StrictMode } from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client'
import { BrowserRouter, HashRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import store from './store';
import routes from './Router';
import App from './App'
import './index.css'
const rootContainer = document.querySelector('#root');
if (rootContainer) {
const root = createRoot(rootContainer);
root.render(
<StrictMode>
<Provider store={store}>
<HashRouter>
<App />
{ routes }
</HashRouter>
</Provider>
</StrictMode>
)
}
启动客户端应用调试,切换到对应路由后,能正常加载数据、显示数据。
修改服务端渲染入口, server.tsx1
2
3
4
5
6
7
8
9
10import { Provider } from 'react-redux';
import store from './store';
...
<Provider store={store}>
<StaticRouter location={ctx.request.path}>
<App />
{ routes }
</StaticRouter>
</Provider>
...
客户端 client.tsx 修改为水合并重新打包1
2
3
4
5
6
7
8
9
10...
hydrateRoot(rootContainer, <StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
{ routes }
</BrowserRouter>
</Provider>
</StrictMode>)
...
服务端异步请求
上一步中,已经成功实现了同构的 redux 状态管理。
然而我们观察页面展示效果和源码,可以发现 ssr 返回的数据其实是 redux 中的初始数据,真正的数据加载还是在客户端水合后,组件渲染后触发的。
想要 ssr 直出异步数据加载后的 html,思路也比较简单,就是找到当前路由,然后执行它的数据加载逻辑,加载完毕后再渲染返回就行了。
可以使用 react-router-config 模块提供的 matchRoutes 获取当前的路由。为此,我们需要将路由改造成 matchRoutes 需要的 RouteConfig 格式。
1 | export interface RouteConfig { |
路由定义
1 | // Router.tsx |
服务端判断路由并加载数据
(在我使用的 react-router@6.x 和 react-router-config@5.x 版本下,matchRoutes 有BUG会报错:TypeError: pathname.match is not a function。这里替换为自己的简单实现)1
2
3
4
5// 获取当前路由,若有匹配的路由,则先加载数据,以做到服务端 ssr 直出最终的 html
// const matchedRoutes = matchRoutes(routes, ctx.request.path);
const matchedRoutes = routes.filter(route => route.path === ctx.request.path);
const loadDataRequests = matchedRoutes.filter(item => item.loadData).map(item => item.loadData(store));
await Promise.all(loadDataRequests);
此时可以观察到几个现象
- 服务端返回的 html 已经是符合预期的加载好数据之后的内容了
- 客户端在展示服务端加载结果后一瞬间又会切换到初始值,并重新走一遍加载逻辑
- 控制台中有 hydrate 过程的异常报错
注水脱水
上面的现象其实比较好理解,客户端在水合结束后,会立即开始运行客户端代码,即渲染 store 中的初始值,并在渲染完毕后去拉取远程数据,导致一次无意义的数据请求。
有一种比较简单的思路,在服务端渲染时,把加载好的数据通过 script 标签写入到window中,客户端从 window 中拿到初始数据后用于初始化 store。这个过程被称为服务端注水、客户端脱水。
服务端注水
1 | // server.tsx |
客户端脱水1
2
3
4
5
6
7
8
9
10
11// store.tsx
// 命名空间、全局状态初始值
const namespace = 'global';
let initialState = {
page1Data: 'loading...',
page2Data: 'loading...',
};
declare const window: Window & { context: { state: any} };
if (typeof window !== 'undefined') {
initialState = window.context?.state?.global || initialState;
}
这样就能从 window 中获取服务端请求好的数据了。但是此时客户端组件渲染完成后仍然会去加载一次数据,可以在客户端增加一些判断,不要重复加载数据。1
2
3
4// RoutePage1.tsx
useEffect(() => {
if (page1Data === 'loading...') dispatch(fetchPage1Data('data1'))
}, [])
这样就完成了一个无冗余请求的 redux 同构应用。
6. 基于 next.js 的 ssr
前面提到的内容会给代码带来不小的复杂度和维护成本,生产环境下也可以选择一些比较成熟的开源库来实现,如 next.js。
next.js 封装了基本的 ssr 实现、css支持、服务端数据加载等(可以让组件通过提供 getServerSideProps 方法来实现服务端的数据加载)。
1 | export default function Page({ data }) { |
其它细节这里不再赘述,可参考文档 https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props。
本文链接:https://www.zoucz.com/blog/2024/01/18/62c58d70-b610-11ee-8eb9-6929c410dc79/