前言
不久前React 18推出了第一个发布候选版本(18.0.0-rc.0),意味着React 18的所有特性已经趋于稳定,可以投入到生产测试中。其中一个重要特性就是本文要介绍的Streaming SSR with Suspense。
服务端渲染(SSR)
首先,React的服务端渲染(Server side rendering)是怎么做的?
在用户访问时,React SSR(下图中的SSR with hydration一类)将React组件提前在服务器渲染成HTML发送给客户端,这样客户端能够在JavaScript渲染完成前展示基本的静态HTML内容,减少白屏等待的时间。
然后在JavaScript加载完成后对已有的HTML组件进行React事件逻辑绑定(也就是Hydration过程),Hydration完成后才是一个正常的React应用。
但是这类SSR同样存在弊端:
- 服务端需要准备好所有组件的HTML才能返回。如果某个组件需要的数据耗时较久,就会阻塞整个HTML的生成。
- Hydration是一次性的,用户需要等待客户端加载所有组件的JavaScript并Hydrated完成后才能和任一组件交互。(渲染逻辑复杂时,页面首次渲染到可交互之间可能存在较长的不可交互时间)
- 在React SSR中不支持客户端渲染常用的代码分割组合
React.lazy
和Suspense
。
而在React 18中新的SSR架构React Fizz带来了两个主要新特性来解决上述的缺陷:Streaming HTML(流式渲染)和Selective Hydration(选择性注水)
流式渲染(Streaming HTML)
一般来说,流式渲染就是把HTML分块通过网络传输,然后客户端收到分块后逐步渲染,提升页面打开时的用户体验。通常是利用HTTP/1.1
中的分块传输编码(Chunked transfer encoding)机制。
renderToNodeStream
早在React 16中同样有一个用于流式传输的APIrenderToNodeStream
来返回一个可读的流(然后就可以将这个流pipe给node.js的response流)给客户端渲染,比原始的renderToString
有着更短的TFFB时间。
TFFB:Time To First Byte,发出页面请求到接收到应答数据第一个字节所花费的毫秒数
app.get("/", (req, res) => {
res.write("<!DOCTYPE html><html><head><title>Hello World</title></head><body>");
res.write("<div id='root'>");
const stream = renderToNodeStream(<App/>);
stream.pipe(res, { end: false });
stream.on('end', () => {
// 流结束后再写入剩余的HTML部分
res.write("</div></body></html>");
res.end();
});
});
但是renderToNodeStream
需要从DOM树自顶向下开始渲染,并不能等待某个组件的数据然后渲染其他部分的HTML(如下图的效果)。该API会在React 18中正式废弃。
renderToPipeableStream
而新推出renderToPipeableStream
API则同时具备Streaming和Suspense的特性,不过在用法上更复杂。
// react-dom/src/server/ReactDOMFizzServerNode.js
// 类型定义
type Options = {
identifierPrefix?: string,
namespaceURI?: string,
nonce?: string,
bootstrapScriptContent?: string,
bootstrapScripts?: Array<string>,
bootstrapModules?: Array<string>,
progressiveChunkSize?: number,
// 在至少有一个root fallback(Suspense中的)可以显示时被调用
onCompleteShell?: () => void,
// 在shell完成前报错时调用,可以用于返回别的结果
onErrorShell?: () => void,
// 在完成所有等待任务后调用,但可能还没有flushed。
onCompleteAll?: () => void,
onError?: (error: mixed) => void,
};
type Controls = {
// 取消等待中的I/O,切换到客户端渲染
abort(): void,
pipe<T: Writable>(destination: T): T,
};
function renderToPipeableStream(
children: ReactNodeList,
options?: Options,
): Controls
这里以React官方给出的第一个Demo作为例子。
import { renderToPipeableStream } from "react-dom/server";
import App from "../src/App";
import { DataProvider } from "../src/data";
function render(url, res) {
// res为writable response流
res.socket.on("error", (error) => {
console.error("Fatal", error);
});
let didError = false;
const data = createServerData();
// 返回一个Writable Stream
const { pipe, abort } = renderToPipeableStream(
<DataProvider data={data}>
<App assets={assets} />
</DataProvider>,
{
bootstrapScripts: [assets["main.js"]],
onCompleteShell() {
// Stream传输之前设置正确的状态码
res.statusCode = didError ? 500 : 200;
res.setHeader("Content-type", "text/html");
pipe(res);
},
onErrorShell(x) {
// 错误发生时替换外壳
res.statusCode = 500;
res.send('<!doctype><p>Error</p>');
},
onError(x) {
didError = true;
console.error(x);
}
}
);
// 放弃服务端渲染,切换到客户端渲染.
setTimeout(abort, ABORT_DELAY);
};
以下截取自实际传输的HTML,最初被Suspense的Comment组件还没准备好,返回的只有占位的Spinner。每个被Suspense的组件都有一个对用户不可见的带注释和id的template
占位符用来记录已传输状态的块(Chunks),这些占位符后续会被有效的组件填充。
可以用于任意标签类型组件的子组件,因此被用作占位符。
数据结构
一次带有Suspense的渲染可以划分为以下数据结构,最底层的Chunk就是字符串或者基本的HTML片段。
- Request
- SuspenseBoundary
- Segment
- Chunk
// 状态 const PENDING = 0; const COMPLETED = 1; const FLUSHED = 2; const ABORTED = 3; const ERRORED = 4; type PrecomputedChunk = Uint8Array; type Chunk = string; type Segment = { status: 0 | 1 | 2 | 3 | 4, // typically a segment will be flushed by its parent, except if its parent was already flushed parentFlushed: boolean, // starts as 0 and is lazily assigned if the parent flushes early id: number, // the index within the parent's chunks or 0 at the root +index: number, +chunks: Array<Chunk | PrecomputedChunk>, +children: Array<Segment>, // The context that this segment was created in. formatContext: FormatContext, // If this segment represents a fallback, this is the content that will replace that fallback. +boundary: null | SuspenseBoundary, }; type SuspenseBoundary = { id: SuspenseBoundaryID, rootSegmentID: number, // if it errors or infinitely suspends forceClientRender: boolean, parentFlushed: boolean, // when it reaches zero we can show this boundary's content pendingTasks: number, // completed but not yet flushed segments. completedSegments: Array<Segment>, // used to determine whether to inline children boundaries. byteSize: number, // used to cancel task on the fallback if the boundary completes or gets canceled. fallbackAbortableTasks: Set<Task>, }; type Request = { destination: null | Destination, +responseState: ResponseState, +progressiveChunkSize: number, status: 0 | 1 | 2, fatalError: mixed, nextSegmentId: number, // when it reaches zero, we can close the connection. allPendingTasks: number, // when this reaches zero, we've finished at least the root boundary. pendingRootTasks: number, // Completed but not yet flushed root segments. completedRootSegment: null | Segment, abortableTasks: Set<Task>, // Queues to flush in order of priority pingedTasks: Array<Task>, // Errored or client rendered but not yet flushed. clientRenderedBoundaries: Array<SuspenseBoundary>, // Completed but not yet fully flushed boundaries to show. completedBoundaries: Array<SuspenseBoundary>, // Partially completed boundaries that can flush its segments early. partialBoundaries: Array<SuspenseBoundary>, onError: (error: mixed) => void, onCompleteAll: () => void, onCompleteShell: () => void, onErrorShell: (error: mixed) => void, }
占位符格式
不同的ID前缀尾代表不同作用的元素:
- Placeholder(占位块):
P:
- Segment (要插入的有效片段):
S:
,一般是div
,表格,数学公式,SVG会用对应的元素- Boundary (Suspense边界):
B:
- Id:
R:
不同的注释标注开头代表不同Suspense边界 (Suspense boundaries)状态的块:
为了不影响CSS选择器和展示效果,Suspense Boundary不是一个具体片段或者自定义标签
- Completed (已完成):
<!--$-->
- Pending (等待中):
<!--$?-->
- ClientRendered (客户端已渲染):
<!--$!-->
渲染流程
主体内容部分除了最上层的main是Completed,其他的sidebar,post和comments组件都是Pending中:
<body> <noscript><b>Enable JavaScript to run this app.</b></noscript> <!--$--> <main> <nav><a href="/">Home</a></nav> <aside class="sidebar"> <!--$?--> <template id="B:0"></template> <div class="spinner spinner--active" role="progressbar" aria-busy="true"></div> <!--/$--> </aside> <article class="post"> <!--$?--> <template id="B:1"></template> <div class="spinner spinner--active" role="progressbar" aria-busy="true"></div> <!--/$--> <section class="comments"> <h2>Comments</h2> <!--$?--> <template id="B:2"></template> <div class="spinner spinner--active" role="progressbar" aria-busy="true"></div> <!--/$--> </section> <h2>Thanks for reading!</h2> </article> </main> <!--/$--> </body>
HTML中还带有用于替换占位符为实际组件的脚本:
<script> // function completeSegment(containerID, placeholderID) function $RS(a, b) { // ... } </script> <script> // function completeBoundary(suspenseBoundaryID, contentID) function $RC(a, b) { // ... } </script> <script> // function clientRenderBoundary(suspenseBoundaryID) function $Rx(a, b) { // ... } </script>
下面用
replaceChildren
来简单展示replaceChildren是2020年推出的试验性DOM API,目前主流浏览器都已经提供支持。
<div hidden id="comments"> <!-- Comments --> <p>foo</p> <p>bar</p> </div> <script> // 新的替换子元素API document.getElementById('sections-spinner').replaceChildren( document.getElementById('comments') ); </script>
组件Suspense结束后继续传输准备好的评论组件和用于替换占位符的脚本,浏览器解析后就能实现“增量渲染”。
<div hidden id="S:2"><template id="P:5"></template></div> <div hidden id="S:5"> <p class="comment">Wait, it doesn't wait for React to load?</p> <p class="comment">How does this even work?</p> <p class="comment">I like marshmallows</p> </div> <script> $RS("S:5", "P:5"); </script> <script> $RC("B:2", "S:2"); </script>
这样就完成了一次HTML的服务器流式渲染,在这个阶段客户端JavaScript可能尚未加载。
最终生成的HTML会保留要插入的内容片段(有隐藏的标记,用户不可见),虽然因为
Suspense
和Streaming的关系不能保证顺序和DOM顺序一致,但应该不影响SEO的效果。React 18的另一个新特性React Server Component同样用到了服务器的流式传输,这里不做展开,感兴趣的可以查阅Plasmic的这篇React Server Component深度解析。
选择性注水 (Selective Hydration)
有了
lazy
和Suspense
的支持,另一个特性就是React SSR能够尽早对已经就绪的页面部分注水,而不会被其他部分阻塞。从另一个角度看,在React 18中注水本身也是lazy的。这样就可以将不需要同步加载的组件选择性地用
lazy
和Suspense
包起来(和客户端渲染时一样)。而React注水的粒度取决于Suspense
包含的范围,每一层Suspense
就是一次注水的“层级”(要么组件都完成注水要么都没完成)。import { lazy } from 'react'; const Comments = lazy(() => import('./Comments.js')); // ... <Suspense fallback={<Spinner />}> <Comments /> </Suspense>
同样的,流式传输的HTML也不会阻塞注水过程。如果JavaScript早于HTML加载完成,React就会开始对已完成的HTML部分注水。
React通过维护几个优先队列,能够记录用户的交互点击来优先给对应组件注水,在注水完成后组件就会响应这次交互,即事件重放(event replay)。
总结
出于篇幅等原因,本文并没有对React Fizz架构作详细的解读,只是简要介绍了Streaming SSR的流式渲染和选择性注水的特性。在实际使用中用户只需要选择性地引入
<Suspense>
就能享受到Streaming SSR带来的巨大提升。值得一提的是React 18.0中的SSR<Suspense>
还不支持在请求数据时Suspense,该特性或在18.x中和react-fetch
和Server Component一起推出。参考资料
Rendering on the Web
New Suspense SSR Architecture in React 18
Keeping browser interactive during hydration
Upgrading to React 18 on the server
What changes are planned for Suspense in 18
Library Upgrade Guide: (e.g. react-helmet)
Basic Fizz Architecture