react 的核心模块是可以做到平台无关的,在做 fiber 树渲染的时候,再根据需求选择在浏览器上渲染 DOM,还是在服务端渲染生成 html 字符,或者是在其它实现 HostConfig 协议的场景实现任意端的渲染。

react 的服务端渲染(ssr)和 ejs 的区别在于,除了在服务端渲染出 html 外,还支持服务端的渲染产物在客户端无缝绑定 react 在客户端的能力,即同构。

ssr 能在 SEO 和 首屏速度方面带来一定收益,但是同时也会给代码的复杂性和维护成本上带来负面影响,需要根据项目实际情况做好权衡。这里简单总结一下 ssr 过程中可能会遇到的问题和处理方案,内容结构见下图。

image.png

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
    19
    import 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.tsx

1
2
3
4
5
6
7
8
9
10
11
12
import 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.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import 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.tsx

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
26
27
28
29
30
import 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} ...`);
})

这样就能实现首屏内容快速加载,耗时内容流式下发的效果。

image.png

慢组件加载完成后

image.png

renderToReadableStream

同 reanderToPipeableStream,只是返回的是 Web 可读流,而不是 nodejs 的可读流,一般在 Deno 或支持 Web Streams 的运行时中使用。

renderToStaticMarkup

和 renderToString 类似,只是其生成的内容无法在客户端水合(后面会提到),适合纯展示类型静态页。

renderToStaticNodeStream

和 renderToStaticMarkup 类似,其生成的内容是一个 node 可读流,支持 Suspend,但是无法再客户端水合。

2. 同构与水合

在上面示例的基础上,添加一个点击交互组件,客户端渲染,组件正常工作。服务端渲染,点击后没有反应。

image.png

查看服务端渲染返回的内容,也确实没有任何脚本内容。
修改 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.use(KoaStatic(path.resolve(__dirname, '../client/')));

router.get('/ssr', async (ctx, next) =>{
// const content = renderToString(<App/>);
// ctx.body=content;
await new Promise<void>((resolve, reject) => {
const readableSteam = renderToPipeableStream(<App />, {
bootstrapScripts: ['client.js'],
onShellReady() {
ctx.respond = false;
ctx.res.statusCode = 200;
ctx.response.set('content-type', 'text/html');
readableSteam.pipe(ctx.res);
},
onError(err) {
reject(err);
},
});
})
})

image.png

可以看到 ssr 返回的代码中附加了客户端的脚本。此时如果服务端渲染的 dom 结构和客户端渲染的结构不同,会导致 hydrate 时无法找到目标 dom,无法正确接管,无法达到正确效果。

注意:实际生产环境比这种情况要复杂,可能存在 hash 命名或者代码/公共库/运行时拆分,需要结合打包工具插件生成 assetsMap 来获取打包后的资源。

3. 路由处理

ssr路由支持

客户端渲染时,一般可以通过 hash 或者 pushState 来实现单页应用路由,而这俩在服务端都无法使用。
react-router-dom 提供了 StaticRouter 来实现服务端渲染的路由。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
router.get(/^\/ssr.*/gim, async (ctx, next) =>{
const data = <><html><head><title>react ssr</title></head><body><div id="root">
<StaticRouter location={ctx.request.path}>
<App />
{ routes }
</StaticRouter>
</div></body></html></>;
await new Promise<void>((resolve, reject) => {
const readableSteam = renderToPipeableStream(data, {
// bootstrapScripts: ['/client.js'],
onShellReady() {
ctx.respond = false;
ctx.res.statusCode = 200;
ctx.response.set('content-type', 'text/html');
readableSteam.pipe(ctx.res);
},
onError(err) {
reject(err);
},
});
})
})

注意点:

  • 必须使用 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
16
import 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
13
import 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')
)

最终生成的 html

1
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.tsx

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
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
81
import { 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
16
import 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.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 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.tsx

1
2
3
4
5
6
7
8
9
10
import { 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 状态管理。

image.png

然而我们观察页面展示效果和源码,可以发现 ssr 返回的数据其实是 redux 中的初始数据,真正的数据加载还是在客户端水合后,组件渲染后触发的。

想要 ssr 直出异步数据加载后的 html,思路也比较简单,就是找到当前路由,然后执行它的数据加载逻辑,加载完毕后再渲染返回就行了。

可以使用 react-router-config 模块提供的 matchRoutes 获取当前的路由。为此,我们需要将路由改造成 matchRoutes 需要的 RouteConfig 格式。

1
2
3
4
5
6
7
8
9
10
11
export interface RouteConfig {
key?: React.Key | undefined;
location?: Location | undefined;
component?: React.ComponentType<RouteConfigComponentProps<any>> | React.ComponentType | undefined;
path?: string | string[] | undefined;
exact?: boolean | undefined;
strict?: boolean | undefined;
routes?: RouteConfig[] | undefined;
render?: ((props: RouteConfigComponentProps<any>) => React.ReactNode) | undefined;
[propName: string]: any;
}

路由定义

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// Router.tsx
...
const routes = [
{
path: '/ssr/route1',
component: <RoutePage1 />,
exact: true,
// 供服务端调用
loadData: RoutePage1.loadData,
key: 'route1'
},
{
path: '/ssr/route2',
component: <RoutePage2 />,
exact: true,
loadData: RoutePage2.loadData,
key: 'route2'
}
];
// server.tsx 服务端路由
...
<Provider store={store}>
<StaticRouter location={ctx.request.path}>
<App />
<Routes>
{routes.map((route) => {
return <Route path={route.path as string} key={route.key} element={route.component as ReactNode}/>;
})}
</Routes>
</StaticRouter>
</Provider>
...
// client.tsx 客户端水合
...
hydrateRoot(rootContainer, <StrictMode>
<Provider store={store}>
<BrowserRouter>
<App />
<Routes>
{routes.map((route) => {
return <Route path={route.path as string} key={route.key} element={route.component as ReactNode}/>;
})}
</Routes>
</BrowserRouter>
</Provider>
</StrictMode>)
...

服务端判断路由并加载数据
(在我使用的 react-router@6.xreact-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);

image.png

此时可以观察到几个现象

  • 服务端返回的 html 已经是符合预期的加载好数据之后的内容了
  • 客户端在展示服务端加载结果后一瞬间又会切换到初始值,并重新走一遍加载逻辑
  • 控制台中有 hydrate 过程的异常报错

注水脱水

上面的现象其实比较好理解,客户端在水合结束后,会立即开始运行客户端代码,即渲染 store 中的初始值,并在渲染完毕后去拉取远程数据,导致一次无意义的数据请求。

有一种比较简单的思路,在服务端渲染时,把加载好的数据通过 script 标签写入到window中,客户端从 window 中拿到初始数据后用于初始化 store。这个过程被称为服务端注水、客户端脱水。

服务端注水

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// server.tsx
await new Promise<void>((resolve, reject) => {
const readableSteam = renderToPipeableStream(data, {
bootstrapScripts: ['/client.js'],
bootstrapScriptContent: `window.context = {state: ${JSON.stringify (store.getState())}}`,
onShellReady() {
ctx.respond = false;
ctx.res.statusCode = 200;
ctx.response.set('content-type', 'text/html');
readableSteam.pipe(ctx.res);
},
onError(err) {
reject(err);
},
});
})

客户端脱水

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 同构应用。

image.png

6. 基于 next.js 的 ssr

前面提到的内容会给代码带来不小的复杂度和维护成本,生产环境下也可以选择一些比较成熟的开源库来实现,如 next.js。

next.js 封装了基本的 ssr 实现、css支持、服务端数据加载等(可以让组件通过提供 getServerSideProps 方法来实现服务端的数据加载)。

1
2
3
4
5
6
7
8
9
10
11
12
13
export default function Page({ data }) {
// Render data...
}

// This gets called on every request
export async function getServerSideProps() {
// Fetch data from external API
const res = await fetch(`https://.../data`)
const data = await res.json()

// Pass data to the page via props
return { props: { data } }
}

其它细节这里不再赘述,可参考文档 https://nextjs.org/docs/pages/building-your-application/data-fetching/get-server-side-props。

☞ 参与评论