React Streaming SSR 原理解析
作者:徐超
功能简介
React 18 提供了一种新的 SSR 渲染模式: Streaming SSR。通过 Streaming SSR,我们可以实现以下两个功能:
- Streaming HTML:服务端可以分段传输 HTML 到浏览器,而不是像 React 18 以前一样,需要等待服务端渲染完成整个页面后才返回给浏览器。这样,浏览器可以更快的启动 HTML 的渲染,提高 FP、FCP 等性能指标。
- Selective Hydration:在浏览器端 hydration 阶段,可以只对已经完成渲染的区域做 hydration,而不需要等待整个页面渲染完成、所有组件的 JS bundle 加载完成,才能开始 hydration。这样可以更早的对已经完成渲染的区域做事件绑定,从而让页面获得更好的可交互性。
基本原理
使用示例
React 官网给出的一个简单的使用示例(以 Node.js 环境下的 API 为例)如下:
let didError = false;
const stream = renderToPipeableStream(
<App />,
{
bootstrapScripts: ["main.js"],
onShellReady() {
// The content above all Suspense boundaries is ready.
// If something errored before we started streaming,
// we set the error code appropriately.
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
stream.pipe(res);
},
onShellError(error) {
// Something errored before we could complete the shell
// so we emit an alternative shell.
res.statusCode = 500;
res.send('<!doctype html><p>Loading...</p><script src="clientrender.js"></script>');
},
onAllReady() {
// stream.pipe(res);
},
onError(err) {
didError = true;
console.error(err);
}
}
);
renderToPipeableStream 是在 Node.js 环境下实现 Streaming SSR 的 API。
Streaming HTML
HTTP 支持以 stream 格式进行数据传输。当 HTTP 的 Response header 设置 Transfer-Encoding: chunked 时,服务器端就可以将 Response 分段返回。一个简单示例:
const http = require("http");
const url = require("url");
const sleep = (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
const server = http.createServer(async (req, res) => {
const { pathname } = url.parse(req.url);
if (pathname === "/") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.setHeader("Transfer-Encoding", "chunked");
res.write("<html><body><div>First segment</div>");
// 手动设置延时,让分段显示的效果更加明显
await sleep(2000);
res.write("<div>Second segment</div></body></html>");
res.end();
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("okay");
});
server.listen(8080);
当访问 localhost:8080 时,「First segment」 和 「Second segment」会分 2 次传输到浏览器端,「First segment」先显示到页面上,2s 延迟后,「Second segment」再显示到页面上。
React 中的 Streaming HTML 要更加复杂。例如,对下面的 App 组件做 SSR:
//文件1: Content.js
export default function Content() {
return (
<div> This is content </div>
);
}
// 文件2:App.js
import { Suspense, lazy } from "react";
const Content = lazy(() => import("./Content"));
export default function App() {
return (
<html>
<head></head>
<body>
<div>App shell</div>
<Suspense>
<Content />
</Suspense>
</body>
</html>
);
}
第 1 次访问页面时,SSR 渲染的结果会分成 2 段传输,传输的第 1 段数据,经过格式化后,如下:
<!DOCTYPE html>
<html>
<head></head>
<body>
<div>App shell</div>
<!--$?-->
<template id="B:0"></template>
<!--/$-->
</body>
</html>
其中 template 标签的用途是为后续传输的 Suspense 的 children 渲染结果占位,注释 和 中间的内容,表示是异步渲染出来的。
传输的第 2 段数据,经过格式化后,如下:
<div hidden id="S:0">
<div> This is content </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 标签。
总结一下 , React Streaming SSR ,会先传输所有 以上层级的可以同步渲染得到的 html 结构,当 内的组件渲染完成后,会把这部分组件对应的渲染结果,连同一个 JS 函数再传输到浏览器端,这个 JS 函数会更新 dom ,得到最终的完整 HTML 结构。
当第 2 次访问页面时,html 结构会一次性返回,而不会分成 2 次传输。这时候 组件为什么没有将传输的数据分段呢?这是因为第 1 次请求时, Content 组件对应的 JS 模块在服务器端已经被加载到模块缓存中,再次请求时,加载 Content组件是一个同步过程,所以整个渲染过程是同步的,不存在分段传输渲染结果的情况。由此可见,只有当 的 children,需要被异步渲染时,SSR 返回的 HTML 才会被分段传输。
除了动态加载 JS 模块(code splitting)会产生分段传输数据的效果外,组件内获取异步数据则是更加常见的适用 Streaming SSR 的场景。
我们将 Content 组件做改造,通过调用异步函数 getData 获取数据:
let data;
const getData = () => {
if (!data) {
data = new Promise((resolve) => {
// 延迟 2s 返回数据
setTimeout(() => {
data = "content from remote";
resolve();
}, 2000);
});
throw data;
}
// promise-like
if (data && data.then) {
throw data;
}
const result = data;
data = undefined;
return result;
};
export default function Content() {
// 获取异步数据
const data = getData();
return <div>{data}</div>;
}
这样,Content 的内容会延迟 2s,待获取到 data 数据后传输到浏览器显示。示例代码(codesandbox 最近升级了,在 html 的 head 里注入了会阻塞 DOM 渲染的 JS,导致 Streaming SSR 效果可能失效,可以把代码复制到本地测试)。
注意:在数据未准备好前,getData 必须 throw 一个 promise,promise 会被 Suspense 组件捕获,这样才能保证 Streaming SSR 的顺利执行。
Selective Hydration
React 18 之前,SSR 实际上是不支持 code splitting 的,只能使用一些 workaround,常见的方式有:1. 对于需要 code splitting 的组件,不在服务端渲染,而是在浏览器端渲染;2. 提前将 code splitting 的 JS 写到 html script 标签中,在客户端等待所有的 JS 加载完成后再执行 hydration。
这一点 React Team 的 Dan 在 Suspense 的 RFC 中也有提及:
To the best of our knowledge, even popular workarounds forced you to choose between either opting out of SSR for code-split components or hydrating them after all their code loads, somewhat defeating the purpose of code splitting.
当前 Modern.js 对于这种情况的处理,采用的是第 2 种方式。Modern.js 利用 @loadable/component 在 SSR 阶段,收集做了 code splitting 的组件的