在 React 18 中进行流式 SSR

从本质上讲,使用 SSR 最重要的原因是:

  • 性能
  • 用户体验 UX
  • SEO

使用 SSR 的 React 应用存在特定的渲染流程。首先,服务器接管客户端的责任,获取所有数据并渲染整个 React 应用程序。执行此操作后,生成的 HTML 和 JavaScript 会从服务器发送到客户端。最后,客户端将 HTML 放在屏幕上,并将其与适当的 JavaScript 连接起来,这也称为水合过程。现在,客户端收到整个 HTML 结构,而不是渲染自身所需的一大堆 JavaScript。

上面描述的整个流程的好处包括网络爬虫更容易访问以索引这些页面,从而改善 SEO,并且客户端可以快速向用户显示生成的 HTML 而不是空白屏幕,从而改善用户体验。由于所有渲染都发生在服务器上,因此客户端免除了这一职责,并且不会冒成为低端设备场景中的瓶颈的风险,从而提高性能。

React 18 之前的版本的流式传输 SSR

在 React 18、Suspense 存在之前,React 中的典型 SSR 设置如下所示:

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
// server/index.ts
import path from 'path';
import fs from 'fs';

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';

import { App } from '../client/App';

const app = express();

app.get('/', (req, res) => {
const appContent = ReactDOMServer.renderToString(<App />);
const indexFile = path.resolve('./build/index.html');


fs.readFile(indexFile, 'utf8', (err, data) => {
if (err) {
console.error('Something went wrong:', err);
return res.status(500).send('Failed to load the app.');
}

return res.send(
data.replace('<div id="root"></div>', `<div id="root">${app}</div>`)
);
});
});

app.use(express.static('./build'));

app.listen(8080, () => {
console.log(`Server is listening on port ${PORT}`);
});
1
2
3
4
5
6
7
8
9
10
11
// build/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>React App</title>
<script src="main.js" async defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

SSR 设置的最大部分是服务器,所以先从服务器开始。在此示例中,使用 Express 启动服务器来提供端口 8080 上的构建文件夹中的文件。当服务器在根 URL 处收到请求时,它将使用 renderToString 将 React 应用程序呈现为 HTML 字符串 ReactDOMServer 包中的函数。

然后需要将结果发送回客户端。但在此之前,服务器需要用适当的 HTML 结构包围渲染的应用程序。为此,此示例在 build 文件夹中查找 index.html 文件,导入该文件,并将渲染的应用程序注入到根元素中:

1
2
3
4
5
6
7
// client/index.ts
import React from "react";
import ReactDOM from 'react-dom';
import { App } from './App';

// Instead of `ReactDOM.render(...)`
ReactDOM.hydrate(<App />, document.getElementById('root'));

然后,客户端需要进行的主要更改是不再需要渲染应用程序。
上一步中看到应用已经由服务器渲染。现在客户端只负责为应用程序提供水份。它是通过使用 ReactDOM.Hydrate 函数而不是 ReactDOM.render 来实现的。

存在的问题

虽然这是 React SSR 的工作设置,但它在性能和用户体验方面仍然存在一些主要缺点:

  • 虽然服务器现在负责渲染 React 应用程序,但服务器端渲染的内容仍然是一大块 HTML,需要在渲染之前传输到客户端
  • 由于React组件的相互依赖性质,服务器必须等待所有数据被获取后才能开始渲染组件、生成HTML响应并将其发送到客户端
  • 客户端仍然需要加载整个应用程序的 JavaScript,然后才能开始吸收服务器的 HTML 响应
  • 水合过程需要同时发生,但组件只有在水合后才可以交互,这意味着用户在水合完成之前无法与页面交互

最后,所有这些缺点都归结为当前的设置,它仍然是从服务器到客户端的瀑布式方法。这会创建从一端到另一端的全有或全无流程:要么将整个 HTML 响应发送到客户端,要么所有数据都已完成获取以便服务器可以开始渲染,要么整个应用程序都已完成水合与否,整个页面要么有响应,要么没有。

在 React 16 中,在现有 renderToString 之上引入了 renderToNodeStream 服务器渲染函数。就设置和结果而言,除了该函数返回 Node.js ReadableStream 之外,没有太大变化。这允许服务器将 HTML 流式传输到客户端。

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
app.get('/', (req, res) => {
const endHTML = "</div></body></html>";
const indexFile = path.resolve('./build/index.html');

fs.readFile(indexFile, 'utf8', (err, data) => {
if (err) {
console.error('Something went wrong:', err);
return res.status(500).send('Failed to load the app.');
}

// 分割应注入 React 应用程序的 HTML,并将第一部分发送给客户端
const beginHTML = data.replace('</div></body></html>', '');
res.write(beginHTML);


// 使用 `renderToNodeStream` 将应用程序渲染为数据流,并将其导入到响应中
const appStream = ReactDOMServer.renderToNodeStream(<App />);
appStream.pipe(res, { end: 'false' });


// 服务器完成渲染后,发送剩余的 HTML 代码
appStream.on('end', () => {
response.end(endHTML);
)};
});
});

这个新功能部分解决了上面描述的缺点之一;也就是说,它必须将 HTML 响应作为一大块从服务器传输到客户端。然而,服务器仍然需要等待整个 HTML 结构生成,然后才能开始向客户端传输任何内容。因此,它并没有真正解决上面描述的任何其他缺点。

现在看看 React 18 新引入的功能之后的情况以及它们如何解决这些缺点。

React 18 的流式 SSR

React 18 之后的 SSR 架构涉及几个不同的部分。这些都不能单独解决上面描述的任何缺点,但它们的组合可以产生神奇的效果。因此,要充分了解整个设置,有必要研究所有这些设置

Suspense 组件

这一切的核心就是著名的 Suspense 组件。它是实现将要介绍的所有功能的主要通道

1
2
3
4
5
6
7
8
9
10
11
12
13
// client/src/SomeComponent.js
import { lazy, Suspense } from 'react';

const SomeInnerComponent = lazy(() => import('./SomeInnerComponent.js' /* webpackPrefetch: true */));

export default function SomeComponent() {
// ...
return (
<Suspense fallback={<Spinner />}>
<SomeInnerComponent />
</Suspense>
);
}

Suspense 是开发人员告诉 React 应用程序的某个部分正在等待数据的机制。同时 React 将在其位置显示一个后备 UI,并在数据准备好时更新它。

这听起来与以前的方法并没有太大不同,但从根本上来说,它以一种更优雅和集成的方式同步 React 的渲染过程和数据获取过程。

Suspense 边界可根据数据获取要求将应用程序分成若干块,然后服务器可利用这些块延迟渲染待处理的数据。与此同时,服务器可以预先渲染数据可用的数据块,并将其流式传输给客户端。当先前待处理的数据块的数据准备就绪时,服务器将对其进行渲染,并再次使用开放流将其发送给客户端。

与用于将 JavaScript 包代码分割成更小的部分的 React.lazy 一起,它提供了修复剩余瀑布缺陷的第一部分拼图。

然而,问题是使用 React.lazy 的 Suspense 和代码分割与 SSR 还不兼容,直到 React 18。

renderToPipeableStream API

为了理解剩下的连接难题,看一下 React 团队在 React 18 后的架构工作组讨论中提供的 Suspense SSR 示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import ReactDOMServer from "react-dom/server";
import { App } from "../client/App";

app.get('/', (req, res) => {
res.socket.on('error', (error) => console.log('Fatal', error));

let didError = false;
const stream = ReactDOMServer.renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/main.js'],
onShellReady: () => {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
onError: (error) => {
didError = true;
console.log(error);
}
}
);
});

与之前的设置相比,最显着的变化是在服务器端使用 renderToPipeableStream API。这是 React 18 中新引入的服务器渲染函数,它返回一个可管道化的 Node.js 流。虽然之前的 renderToNodeStream 无法等待数据,并且会缓冲整个 HTML 内容直到流结束,但 renderToPipeableStream 函数不会受到这些限制。

当 Suspense 边界里的内容准备就绪时,将调用 onShellReady 回调。如果同时发生任何错误,则会反映在对客户端的响应中。然后,我们将通过管道将 HTML 传输到响应中,开始将其传输到客户端。

然后流将保持打开状态并将任何后续渲染的 HTML 块传输到客户端。这是与之前版本相比最大的变化。

该渲染功能与 Suspense 功能以及服务器端通过 React.lazy 进行代码分割完全集成,从而实现了 SSR 的流式 HTML 功能。这解决了前面描述的 HTML 和数据获取的瀑布问题,因为应用程序可以根据数据需求增量呈现和传输。

1
2
3
4
5
6
7
// client/index.ts
import React from "react";
import ReactDOMClient from 'react-dom/client';
import { App } from './App';

// Instead of `ReactDOM.hydrate(...)`
ReactDOMClient.hydrateRoot(document.getElementById('root'), <App />);

引入 ReactDOMClient.hydrateRoot,实现选择性水合

在客户端,唯一需要更改的是应用程序在屏幕上的显示方式。作为之前的 ReactDOM. Hydro 的替代品,React 团队在 React 18 中引入了新的 ReactDOMClient. HydroRoot。虽然变化很小,但它实现了很多改进和功能。最重要的是选择性补水。

如前所述,Suspense 根据数据需求将应用程序拆分为 HTML 块,而代码拆分将应用程序拆分为 JavaScript 块。选择性水合允许 React 将这些东西放在客户端上,并在不同的时间和优先级开始水合块。一旦收到 HTML 和 JS 块,它就可以开始水合,并优先考虑与用户交互的部分的水合队列。

这解决了剩下的两个瀑布问题:必须等待所有 JavaScript 加载才能开始水合,以及要么水合整个应用程序,要么不水合。

选择性水合和其他提到的功能的组合允许 React 在加载必要的 JavaScript 代码后立即开始水合,同时还能够根据优先级单独水合应用程序的不同部分。

React 18 是其 SSR 架构经过几个主要版本和多年微调的长期发展变化成果。Suspense 和代码分割是这个难题的早期部分,但直到 React 18 中引入流式 HTML 和选择性水合之前,它们无法在服务器上充分发挥其潜力。