React 18 追踪:Fizz,流式 SSR 引擎

6 分钟阅读
·

这个系列追了三个月,视线基本都在客户端 reconciler 上。服务端渲染这边这几个月动作同样大:6 月 2 日 76f85b3e 把 Fizz 的 bundle 从 experimental 构建挪进 stable 构建(入口当时叫 react-dom/unstable-fizz.node),6 月 14 日 dbe3363c 把 renderToString 和 renderToNodeStream 重写到 Fizz 之上,同日 9212d994ba 把 unstable-fizz 入口撤掉,合并进 react-dom/server。到 9 月初,react-dom/server 对外暴露的所有 API 底下跑的已经是同一个引擎。这篇把 Fizz 的实现打开看。参考代码是 master 分支 1314299c7f(9 月 1 日),核心文件是 packages/react-server/src/ReactFizzServer.js,DOM 侧的输出格式在 packages/react-dom/src/server/ReactDOMServerFormatConfig.js

系列目录

日期 标题
06-08 从 Stack Reconciler 到 Fiber:追踪 React 18 开发,先看数据结构
06-10 React 18 追踪:Lane 模型(上),31 个二进制位取代 expirationTime
06-17 React 18 追踪:Lane 模型(下):调度决策与饥饿保护
06-24 React 18 追踪:Concurrent 工作循环与时间切片
07-01 React 18 追踪:createRoot 转正,ReactDOM.render 进入废弃警告
07-08 React 18 追踪:flushSync 统一同步刷新入口
07-15 React 18 追踪:自动批处理与交错更新队列
07-22 React 18 追踪:Suspense 的挂起与恢复
08-05 React 18 追踪:useTransition 与 useDeferredValue
08-12 React 18 追踪:useOpaqueIdentifier,SSR 一致的 id 怎么生成
08-19 React 18 追踪:事件系统与 Context 传播
09-02 React 18 追踪:Fizz,流式 SSR 引擎(本篇)

react-dom/server 现在的 API 面

先看入口。packages/react-dom/npm/server.node.js 现在导出五个函数:renderToString、renderToStaticMarkup、renderToNodeStream、renderToStaticNodeStream,外加新面孔 pipeToNodeWritable。前四个从 ReactDOMLegacyServerNode.js 来,最后一个从 ReactDOMFizzServerNode.js 来。Node 上流式渲染的新 API 目前就叫 pipeToNodeWritable,没有别的名字。

用法和旧 API 差别很大,调用方拿不到字符串,要把一个 Node Writable 交出去:

const {startWriting, abort} = pipeToNodeWritable(<App />, response, {
  onReadyToStream() {
    response.statusCode = 200;
    startWriting();
  },
});

返回的 controls 只有两个方法:startWriting 开始往 destination 里写,abort 中途终止并把没完成的边界降级为客户端渲染。options 里能传 onError、onReadyToStream、onCompleteAll、identifierPrefix、namespaceURI、progressiveChunkSize。三个回调的分工后面讲 flush 时会清楚:onReadyToStream 表示外壳(root segment 渲染完成)就绪、可以开始写首字节,onCompleteAll 表示所有挂起任务都完成了,想等完整 HTML 就等它。

旧实现 ReactPartialRenderer 是怎么工作的

对比之前得知道旧实现长什么样。v17 的 renderToString 走 ReactPartialRenderer.js,里面是一个 class ReactDOMServerRenderer,用 this.stack 存一组 frame(children、childIndex、context、footer 等),read(bytes) 里跑一个 while 循环:取栈顶 frame,按 childIndex 顺序渲染子节点,把产出的字符串累积到 out 数组里,凑够 bytes 就返回。renderToString 调 read(Infinity) 一次性读完,v17 的 renderToNodeStream 把流的 _read(size) 直接转给 partialRenderer.read(size)

Suspense 在这个结构里的处理靠一个 suspenseDepth 计数配分层缓冲。进边界时把后续输出写到 out 数组的新一层,边界内的内容全部渲染完、确认没人挂起,才把这一层合并回上一层。中途有人 throw thenable,已缓冲的这一层整层丢弃,栈上换成 fallback frame 重走一遍。也就是说旧实现其实已经为 Suspense 准备了缓冲机制,但缓冲的终点只有两个:内容齐了就合并,不齐就换 fallback,没有第三种结局。

这个结构有两个硬性限制。

第一,全程同步。read 循环里没有任何等待异步的通道。组件在 render 里 throw 一个 thenable,栈上对应的处理只有一条:如果 enableSuspenseServerRenderer 开着且当前在某个 Suspense 边界内,就标记 suspended,回溯时把这个边界已经缓冲的输出丢掉,换成 fallback frame 重新渲染,输出里写一个 <!--$!-->。数据后来到了也没用,这次渲染已经结束了,fallback 就是定稿。想在服务端等数据,只能在 renderToString 之前自己把数据全取完,整页 TTFB 被最慢的数据源拖住。

第二,分段只是字节意义上的。renderToNodeStream 确实比 renderToString 早发首块,read(size) 凑够 size 就返回。但它按渲染顺序线性推进,没有「先把外壳发出去、某块数据到了再补」的能力,Suspense 边界在这套结构里只能输出 fallback。

ReactPartialRenderer 在 master 上还留着,不过引用面已经收缩到只剩 ReactDOMLegacyServerBrowser.classic.fb.js,也就是 FB 内部 classic 构建的 fork,npm 上的 react-dom 不再经过它。

Fizz 的数据结构

Fizz 的核心代码放在独立的 react-server 包里,和 react-reconciler 一个思路:引擎本身不碰 DOM,流控制和输出格式通过注入的 host config(ReactServerStreamConfig、ReactServerFormatConfig)提供,Node 的实现在 ReactServerStreamConfigNode.js,scheduleWork 就是 setImmediate。ReactFizzServer.js 文件开头定义了四类对象。

Request 代表一次渲染请求,字段分三组。状态组:destination(往哪写)、status(BUFFERING / FLOWING / CLOSED)、responseState(格式相关的预计算产物)。任务组:allPendingTasks、pendingRootTasks 两个计数器,pingedTasks 就绪队列,abortableTasks 可终止集合。输出组:completedRootSegment,外加三个按优先级排列的队列,clientRenderedBoundaries(出错降级、要尽快通知客户端)、completedBoundaries(完整就绪的边界)、partialBoundaries(部分就绪、可以先把完成段发出去的边界)。

Task 是渲染工作的最小单元。字段里最关键的是 node(待渲染的 React 节点)、ping(数据到了之后的回调)、blockedBoundary 和 blockedSegment(这个任务往哪个边界的哪个 segment 写)、context 和 legacyContext(挂起时的上下文快照,恢复时用)。

Segment 是输出缓冲的一段。chunks 数组存已经渲染好的字符串和预计算 chunk,children 存子 segment,status 在 PENDING / COMPLETED / FLUSHED / ABORTED / ERRORED 之间流转,boundary 字段反向指向它所属的 Suspense 边界。id 初始是 -1,父 segment 先 flush 了、需要留占位时才分配。

SuspenseBoundary 记录一个边界的聚合状态:pendingTasks 计数(归零说明内容齐了)、completedSegments(完成但还没 flush 的段)、forceClientRender(出错或无限挂起时置位)、fallbackAbortableTasks(内容就绪后要取消掉的 fallback 任务)、byteSize(决定内容要不要内联)。

createRequest 完成初始化:建一个 root segment(parentFlushed 直接置 true,它没有父亲要等),建一个包住整棵树的 root task 塞进 pingedTasks,返回 Request。之后 startWork(request) 用 scheduleWork 把 performWork(request) 排进下一轮。

挂起、分段与补发

performWork 的工作循环很短:遍历 pingedTasks,对每个 task 调 retryTask,清空队列,如果 request 处于 FLOWING 状态就接着 flush。所有真实渲染都走 retryTask 这一条路,首个 task 也不例外。retryTask 先恢复任务挂起时保存的 context,然后调 renderNodeDestructive 往下渲染。

渲染中 throw 一个 thenable 时,renderNode 这层 catch 住,调 spawnNewSuspendedTask:在当前 segment 的 chunks 末尾记下插入位置,新建一个子 segment,新建一个 task(带着当前的 context 快照),x.then(ping, ping) 挂到 promise 上。原 task 继续渲染兄弟节点。promise 兑现后 ping 把这个新 task 推进 pingedTasks,首个 ping 触发 scheduleWork,下一轮 performWork 从挂起点重渲染。整套机制和客户端 Suspense 的 ping 模型(第 8 篇)同构,区别只是这里没有 fiber 树,断点信息存在 task 和 segment 上。

重试还有一个细节。retryTask 调的是破坏式的 renderNodeDestructive,刻意让它直接改当前 task 的 node 字段。这样如果渲染途中再次挂起,同一个 task 原地复用,挂起多少次都只占一个任务名额,不会每挂一次就多生一个 task。

渲染抛真错误走另一条路。erroredTask 先把错误报给 request.onError,然后看 blockedBoundary:挂在 root 上说明外壳本身坏了,fatalError 直接毁掉 destination;挂在某个 Suspense 边界上,就把这个边界置成 forceClientRender,父 segment 已经 flush 的话顺手推进 clientRenderedBoundaries 队列,等 flush 时用 $RX 脚本通知客户端把这块标成 <!--$!-->,hydration 时直接客户端重渲染。服务端一个边界出错不再拖垮整页,这是旧实现给不了的降级粒度。

Suspense 边界本身由 renderSuspenseBoundary 处理,做法是先尝试再兜底。进边界时建一个 SuspenseBoundary 对象和两个 segment:boundarySegment(写 fallback)和 contentRootSegment(写正式内容)。当前 task 直接换到 contentRootSegment 上渲染 children,代码注释说这里本可以走 pingedTasks 排队,但同步先渲染省一次 context 切换。全程不挂起的话这个边界就地完成,fallback 根本不会创建。一旦挂起,挂起点以下变成一个新 task 等数据,同时给 fallback 建一个延迟任务,注释里写了原因:先不渲染 fallback,万一内容先完成就不用白费工,fallback 任务排在 pingedTasks 尾部,真轮到它说明内容还没好。边界完成时 finishedTask 递减 pendingTasks,归零后把 boundary 推进 completedBoundaries,顺手把 fallbackAbortableTasks 里还没跑的 fallback 任务全部取消(abortTaskSoft,只取消任务、不波及边界本身)。

边界没完成但已经完成了几段的情况也有出路。finishedTask 里另一个分支在边界 pendingTasks 未归零、但父 segment 已 flush 时,把完成的段先记进 boundary.completedSegments,首个完成段会把边界推进 partialBoundaries 队列,flush 时可以先把这些段发出去,不用等整个边界齐活。

renderNodeDestructive 这一层还有个细节:数组和迭代器的子节点用非破坏式的 renderNode 包一层,保证某个子节点挂起时兄弟节点还能继续渲染,断点粒度到子节点级。

HTML 上怎么落:注释节点、隐藏容器和内联脚本

流式补发要解决的实际问题是:fallback 已经发给浏览器了,后来的内容怎么替换上去。Fizz 用的载体全是标准 HTML,不依赖任何客户端运行时先就位。

边界标记用注释节点。<!--$--> 是已完成边界,<!--$?--> 是挂起中的边界,<!--$!--> 是降级为客户端渲染的边界,<!--/$--> 收尾。挂起边界的 fallback 直接包在 <!--$?--> ... <!--/$--> 里发给浏览器,立即可见。

内容补发分两步。后到的 segment 内容写在一个隐藏容器里,普通场景是 <div hidden id="...">,SVG、table 这些对合法子元素有要求的场景换成对应标签(format config 里有一整组变体)。容器后面紧跟一个内联脚本。脚本逻辑在 format config 里以注释形式留了可读版,实际输出的是压缩后的单行函数,一共三个:$RC 处理整个边界完成,把隐藏容器里的节点搬到 <!--$?--> 的位置、删掉 fallback、把起始注释改成 <!--$-->$RS 处理单个 segment 完成,搬到占位注释前面再删占位;$RX 处理边界降级,把标记改成 <!--$!--> 让 hydration 时直接客户端渲染。$RC 的压缩实现末尾还有一行 a._reactRetry&&a._reactRetry(),给 hydration 侧留的钩子,边界补发完成时唤醒等待中的注水。

分段大小有个默认约束。DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800,上面挂着一段估算注释:按低端 3G 约 500kbps 算,带宽先打八折、每 500ms 只能收到一半、再对半估一次,得出每 500ms 大约能显示 12.5KB 新内容,分段比这更细没意义,客户端也消化不了。可以用 progressiveChunkSize 选项覆盖。

flush 的优先级与背压

写盘这一侧由 startFlowing 和 flushCompletedQueues 负责。pipeToNodeWritable 的 startWriting 调 startFlowing,把 status 置 FLOWING 并立刻 flush 一轮,同时给 destination 挂上 drain 监听。flushCompletedQueues 按固定优先级清四个队列:先 completedRootSegment(外壳),再 clientRenderedBoundaries(出错降级的要尽快让客户端知道),再 completedBoundaries(完整边界优先,能真正显示内容),completeWriting 让积压数据先落盘,然后 partialBoundaries(部分完成的边界,先把完成的段发出去),最后再扫一遍 completedBoundaries 接住新完成的。

背压处理得很直接:writeChunk 就是 destination.write,返回 false 说明内部缓冲满了,flush 立刻停,status 退回 BUFFERING,等 drain 事件触发 startFlowing 再续。Node 侧还做了两个小优化,beginWriting 调 destination.cork()、completeWriting 调 uncork(),flushBuffered 时如果 destination 有 flush 方法(compression 中间件会加)就调一次,让压缩器别攒着。所有队列清空、任务归零后 close(destination),也就是 response.end()。

flush 到一个带边界的 segment 时,flushSegment 按边界状态分四种写法。forceClientRender 的包一层 <!--$!--> 写 fallback;还在等数据的包一层 <!--$?--> 写 fallback,同时给 boundary.rootSegmentID 分配一个 id,如果手头已有完成的段就把边界推进 partialBoundaries;内容齐了但 byteSize 超过 progressiveChunkSize 的,按挂起态写 fallback,把边界推进 completedBoundaries 稍后单独补发,注释解释了大块内容单独发的动机,别的小块可以先显示;内容齐且体积没超标的直接内联,写 <!--$--> 加正式内容。第四种路径下输出里完全没有 fallback 的痕迹,和不加 Suspense 的静态渲染结果一致,Suspense 的运行时代价只落在真正挂起的边界上。

同一页面,两种 API 的输出时序

拿一个具体页面过两遍:外壳(头部、导航)加两个 Suspense 边界,A 的数据 200ms 到,B 的 800ms 到。

renderToString 与 pipeToNodeWritable 输出时序对比

renderToString 路径上,不管底下是旧实现还是 Fizz 版,首字节都要等最慢的 B。顺便看一眼 Fizz 版 renderToString 的实现(ReactDOMLegacyServerBrowser.js 的 renderToStringImpl):它 createRequest 后 startWork,紧接着主动 abort(request) 再 startFlowing,意图写在注释里,挂起没完成的边界直接按客户端渲染写出去,因为字符串场景没有补发的机会。Fizz 版 renderToNodeStream(ReactDOMLegacyServerNode.js)则是等 onCompleteAll 才开始 startFlowing,注释写明「等一切就绪再开始写,这样即使挂起也能输出完整 HTML」。也就是说 legacy API 换了引擎之后输出语义刻意保持原样,但 renderToNodeStream 提前发首块的时序特性在 Fizz 版里没有了,真正能流式输出的是 pipeToNodeWritable。

pipeToNodeWritable 路径上,首字节只等外壳渲染完。浏览器拿到外壳和两个 fallback 就能首屏,A 就绪时服务器补发隐藏容器加 $RC 脚本,边界 A 原地替换;B 同理。客户端配合 hydrateRoot(7 月 createRoot 那篇讲过入口)时,hydration 也是分边界推进的,外壳和早到的边界先可交互。TTFB 从「最慢数据源的时间」变成「外壳渲染时间」,大页面里这两者的差距就是流式的收益。

结尾

这一篇把 Fizz 的机制层面看完了:四类数据结构、挂起后的分段补发、注释节点加内联脚本的替换协议、flush 的优先级和背压。从使用侧看还有两个空白。一个是数据获取,Fizz 只认 throw thenable 这个约定,什么时候取、怎么缓存,目前完全由使用方自己组织,官方方案还没影。另一个是客户端这一半只提了 hydrateRoot 入口,hydration 怎么按边界推进、挂起边界上的事件怎么处理,这块逻辑在 reconciler 的 hydration 路径里,下一篇顺着 _reactRetry 那个钩子往客户端看。


1067 字 · 45 段落
xi ming

Written by xi ming You should follow him on Github