- 发布日期
第 17 讲:SSR 与 Dual Rendering:Fizz Stream 与 RSC Stream 的合奏
SSR 双流渲染:Fizz HTML 流与 RSC Stream 如何合奏成完整响应
上一讲我们看了 RSC 流是怎么生成的。但浏览器在第一次访问页面时,先要拿到 HTML 才能启动 React——RSC 流是被"内联"在 HTML 里发过去的。本讲就来拆这个合奏:服务端是怎么把"HTML 流"和"RSC 流"组合在一起,实现 Streaming SSR 的?
学习目标
读完本讲,你能:
- 区分 Fizz stream(react-dom/server 输出的 HTML 流)和 Flight stream(react-server-dom-webpack 输出的 RSC 流)。
- 看懂 dual rendering 的"合奏结构":RSC 流 →
ReactClient.use()→ React Element → Fizz stream →<App>组件 → bootstrap script。 - 理解
onShellReadyvsonAllReady与shouldWaitOnAllReady的关系,以及它们对 SEO bot、TTFB、流式有什么影响。 - 解释
<App>组件、ReactServerResult.tee()、continueFizzStream、createNodeInlinedDataStream等关键拼装函数的角色。 - 在生产排障时定位 hydration mismatch、shell 卡住、bot 看不到内容这类问题。
本讲对应代码:
packages/next/src/server/app-render/app-render.tsx(约 4080~4350 行)、stream-ops.web.ts/stream-ops.node.ts、stream-utils/node-web-streams-helper.ts、use-flight-response.ts。
一、为什么不能"先生成完 HTML 再发"
经典 SSR 时代(Pages Router 老 API):服务端调用 renderToString(<App />),得到完整 HTML 字符串,一次性 res.end(html)。
这种方案的问题:
- TTFB 高:浏览器要等服务端把整页所有 await 完成才看到第一个字节。
- 没有 Suspense streaming:所有 fallback 必须在最终 HTML 里就替换为内容,无法"等到内容 ready 再插入"。
- 大量 server work 阻塞:单个慢 API 拖累整页。
React 18 引入 renderToReadableStream(web)/renderToPipeableStream(node),它们是流式的:
onShellReady一触发,就能开始往浏览器写"shell HTML"(<html><head><body><div id="root">+ 第一层渲染好的 fallback)。onAllReady触发时,所有 Suspense 子树都 ready 了。- 中间过程:每个 Suspense 子树 ready 后,React 用
<script>注入 inline data,让浏览器 DOM 上的 fallback 被替换。
这就是 streaming SSR。Next.js 在它之上又加了一层 RSC,于是变成了 dual rendering。
二、Dual Rendering 的整体结构
打开 app-render.tsx,找到 dynamic 渲染分支(约 4140 行起):
// 1. 已经有了 RSC 流 reactServerResult
const appElement = (
<App
reactServerStream={reactServerResult.tee()}
reactDebugStream={reactDebugStream}
debugEndTime={undefined}
preinitScripts={preinitScripts}
ServerInsertedHTMLProvider={ServerInsertedHTMLProvider}
nonce={nonce}
images={ctx.renderOpts.images}
/>
)
// 2. 把 <App /> 喂给 Fizz stream
const { stream: htmlStream, allReady } = await workUnitAsyncStorage.run(
requestStore,
renderToNodeFizzStream,
appElement,
fizzOptions,
{ waitForAllReady: generateStaticHTML }
)
// 3. 拼装最终输出(HTML 主流 + inline RSC + 其它注入)
return await continueFizzStream(htmlStream, {
inlinedDataStream: createNodeInlinedDataStream(
reactServerResult.consume(),
nonce,
formState
),
isStaticGeneration: generateStaticHTML,
allReady,
// ...
})
整个数据流可以画成:
generateDynamicRSCPayload
│
▼
renderToReadableStream
(react-server-dom-webpack)
│
▼
Flight Stream
│
reactServerResult
──────────┴──────────
│ │
.tee() .consume()
│ │
▼ ▼
<App reactServerStream={...}/> inlinedDataStream
│ (将 RSC chunks
▼ 转成 <script>self.__next_f.push>)
renderToReadableStream/PipeableStream
(react-dom/server)
│
▼
Fizz HTML Stream ◀──────┘
│ continueFizzStream 会把
│ inlinedDataStream 在 HTML 流里
▼ 的合适位置插入
最终 text/html 响应
注意几个关键点:
- RSC 流被 tee 成两路:一路给
<App>在服务端 React DOM 渲染期间消费(用来生成 HTML),另一路给inlinedDataStream直接转<script>内联回响应。两路内容是 完全相同 的——服务端不重复渲染,只是把同一段流复制了一份。 <App>组件本身是 client component:它跑在 react-dom/server 的"客户端 react"环境(不是 react-server condition),通过ReactClient.use(getFlightStream(...))解析 RSC 流为 React Element 树,再让 react-dom 把它渲染成 HTML。continueFizzStream:把 React 输出的 HTML 流和 inline RSC、metadata、polyfill、错误信息等"片段"做正确的位置注入。
三、关键角色 1:ReactServerResult 与 tee
ReactServerResult 是一个对 ReadableStream<Uint8Array> 的薄包装:
.tee()— 复制一路新流(用于 React 服务端渲染消费 RSC payload).consume()— 把流"读完",转成Uint8Array[]后再变回 stream(用于 inlinedDataStream)
为什么需要 tee?因为 ReadableStream 是 一次性消费 的——一旦被一个 reader 读了,就不能再被另一个 reader 读。Next.js 这里的拼装策略是:
- 先生成 RSC 流。
- tee 一路给
<App>在 SSR 期间用ReactClient.use()消费,得到 React tree,喂给 react-dom 渲染 HTML。 - consume 另一路,编码成
<script>self.__next_f.push([1, "..."])</script>,让浏览器在拿到 HTML 后能立刻拼出客户端的 RSC tree。
业务示例:你访问
/products/iphone-15,server 一次渲染出 RSC payload。同样这段 payload 既被 SSR 用来产生 HTML(<h1>iPhone 15</h1>),也被序列化成 inline<script>让客户端 React 拿到 RSC 树。客户端 hydrate 时,先把 inline 的 RSC chunks 还原成 React tree,再和 SSR DOM 对齐——这就是 hydration 不报错的关键。
四、关键角色 2:<App> 组件
function App<T>({
reactServerStream,
reactDebugStream,
debugEndTime,
preinitScripts,
ServerInsertedHTMLProvider,
nonce,
images,
}: { /* ... */ }): JSX.Element {
preinitScripts()
const response = ReactClient.use(
getFlightStream<InitialRSCPayload>(
reactServerStream,
reactDebugStream,
debugEndTime,
nonce
)
)
const initialState = createInitialRouterState({
navigatedAt: -1,
initialRSCPayload: response,
location: null,
})
const actionQueue = createMutableActionQueue(initialState, null)
return (
<HeadManagerContext.Provider value={{ appDir: true, nonce }}>
<ImageConfigContext.Provider value={images ?? imageConfigDefault}>
<ServerInsertedHTMLProvider>
<AppRouter actionQueue={actionQueue} globalErrorState={response.G} />
</ServerInsertedHTMLProvider>
</ImageConfigContext.Provider>
</HeadManagerContext.Provider>
)
}
要点:
ReactClient.use(getFlightStream<InitialRSCPayload>(...)):把 RSC 流转换成完整的 React tree。use()会 suspend,等 RSC 流的 root 行 ready 后再 unblock。createInitialRouterState:用 RSC payload 里的f(FlightDataPath)/c(canonicalUrl)等字段构造 client router 初始 state。<AppRouter>:客户端 App Router 组件,第 9 讲拆过它的 reducer。HeadManagerContext.Provider:给客户端 React Head Manager 提供 nonce、appDir 标志(对应 metadata 注入)。
注意它运行在 client React runtime:使用 ReactClient.use、HeadManagerContext.Provider、<AppRouter>(client component)等。它不是 server component。所以这里能访问浏览器侧 React 的全部能力,但不能 await fetch、不能调用 server-only 的 cookies()。
关键认知:服务端 SSR 阶段,react-dom/server 在跑
<App>;<App>在 react-dom 渲染过程中通过ReactClient.use(rsc-stream)反序列化 RSC payload,得到<AppRouter>+<RootLayout>+ ... 这棵树;然后 react-dom 继续把它渲染成 HTML。
五、关键角色 3:renderToNodeFizzStream / renderToWebFizzStream
export async function renderToNodeFizzStream(
element: React.ReactElement,
streamOptions: any,
options?: { waitForAllReady?: boolean }
): Promise<FizzStreamResult> {
const pt = new PassThrough()
const shellReady = new DetachedPromise<void>()
const allReady = new DetachedPromise<void>()
const deferPipe = options?.waitForAllReady === true
const pipeable = getTracer().trace(AppRenderSpan.renderToReadableStream, () =>
renderToPipeableStream(element, {
...streamOptions,
onHeaders: streamOptions?.onHeaders,
onShellReady() {
streamOptions?.onShellReady?.()
if (!deferPipe) {
pipeable.pipe(pt)
}
shellReady.resolve()
},
onShellError(error: unknown) {
streamOptions?.onShellError?.(error)
shellReady.reject(error)
},
onAllReady() {
streamOptions?.onAllReady?.()
if (deferPipe) {
pipeable.pipe(pt)
}
allReady.resolve()
},
onError: streamOptions?.onError,
})
)
await shellReady.promise
注意 deferPipe 决定的是:
deferPipe = false(默认,dynamic 请求)→onShellReady触发后立刻 pipe,开始往浏览器写 shell HTML。后续 Suspense ready 时 React 持续写入 stream。deferPipe = true(waitForAllReady,bot/SSG/SEO 优化)→ 等到onAllReady才 pipe,相当于"等所有内容 ready 再发"。
业务示例:搜索引擎 crawler 抓取页面,SEO 内容必须完整。这时 base-server 把
supportsDynamicResponse = false(看 user-agent 决定),app-render 就走waitForAllReady路径,确保 Googlebot 拿到的是完整 HTML 而不是只有 fallback 的 shell。
六、关键角色 4:bot 检测与 supportsDynamicResponse
回看 base-server 决定 dual rendering 模式的代码:
if (opts.supportsDynamicResponse === true) {
const ua = req.headers['user-agent'] || ''
const isBotRequest = isBot(ua)
const isSupportedDocument =
typeof components.Document?.getInitialProps !== 'function' ||
NEXT_BUILTIN_DOCUMENT in components.Document
// Disable dynamic HTML in cases that we know it won't be generated,
// so that we can continue generating a cache key when possible.
opts.supportsDynamicResponse =
!isSSG && !isBotRequest && isSupportedDocument
}
// In development, we always want to generate dynamic HTML.
if (!isNextDataRequest && isAppPath && this.dev) {
opts.supportsDynamicResponse = true
}
isBot(ua) 来自 shared/lib/router/utils/is-bot.ts,识别 Googlebot / Bingbot / Twitterbot / Facebot 等。识别到 bot 时:
supportsDynamicResponse = false- 进入
app-render后shouldWaitOnAllReady = true(实际上由supportsDynamicResponse反推得到,见下) - Fizz stream
deferPipe = true,等所有 Suspense 内容 ready 才输出
源码里这段:
const generateStaticHTML =
supportsDynamicResponse !== true || !!shouldWaitOnAllReady;
可以理解为 "只要不是 streaming 友好的客户端,就走 wait-for-all 路径"。
htmlLimitedBots:第二档 bot 处理
不是所有 bot 都需要"等全部 ready"。有些 bot(如 Slackbot 抓 OG image 时只看 head metadata)不在乎完整 body。Next.js 14 引入了 htmlLimitedBots:
const pattern = htmlLimitedBots || HTML_LIMITED_BOT_UA_RE_STRING
匹配到这一档 bot 时,metadata streaming 会被禁用(等所有 metadata ready 再写 head),但 body 部分仍然 stream。这样既保证 Slack 拿到完整 OG,又不会让普通 bot 等更慢。
业务示例:营销活动落地页里 metadata 用了
generateMetadata异步获取 A/B 实验信息。Slackbot 抓页面时:如果 metadata 是 streaming 的,Slack 可能在 metadata 没 ready 时就关闭连接,OG 卡片显示空。Next.js 把这类 bot 加到 htmlLimitedBots 里,专门给它走"先等 metadata 完整再发"的路径。
七、关键角色 5:continueFizzStream
continueFizzStream 在 stream-utils 里:把 React Fizz 输出的 HTML 流和"额外注入"组合成最终响应。
return await continueFizzStream(htmlStream, {
inlinedDataStream: createNodeInlinedDataStream(
reactServerResult.consume(),
nonce,
formState,
),
isStaticGeneration: generateStaticHTML,
allReady,
deploymentId: ctx.sharedContext.deploymentId,
getServerInsertedHTML,
getServerInsertedMetadata,
validateRootLayout: !!process.env.__NEXT_DEV_SERVER,
});
它要做的"插入"分四类:
- inlinedDataStream:把 RSC chunks 写成
<script>self.__next_f.push([1,"..."])</script>,插入</body>前。 - getServerInsertedHTML:执行 React
useServerInsertedHTML()的回调,把 styled-jsx、emotion 等的关键 CSS 注入到<head>。 - getServerInsertedMetadata:metadata streaming 模式下把 metadata 块注入到
<head>。 - closing tags:补全
</body></html>。
如果 validateRootLayout = true(dev),还会检查输出里是否有完整的 <html>...</html>,没有就提示用户在 app/layout.tsx 里写正确的 root layout。
八、关键角色 6:bootstrap script
打开 view-source 任意 App Router 页面,找到 </body> 前的最后几行:
<script src="/_next/static/chunks/main-app.abc.js" async></script>
<script>
self.__next_f=self.__next_f||[];
self.__next_f.push([0]);
self.__next_f.push([1,"...RSC chunk 1..."]);
self.__next_f.push([1,"...RSC chunk 2..."]);
...
</script>
bootstrapScript 由 getRequiredScripts(buildManifest, ...) 生成,里面是这一页所需要的所有 _next/static/chunks/* 入口(main-app、page entry、shared)。
bootstrapScriptContent 是 dev only 的,注入了 self.__next_r = "<requestId>",让 dev overlay 能关联到 server 的请求 ID。
self.__next_f.push([0]) 是"启动信号",告诉 client React 监听器开始累积 RSC chunks。后续 [1, "..."] 是数据 chunks,[2, "..."] 是 form state hint,[3, "..."] 是 lazy import 等。
九、Inline Data Stream 的位置魔法
createNodeInlinedDataStream / createWebInlinedDataStream 的关键是 位置插入:要把 RSC <script> 插入到 HTML 流的"合适位置"。
实现思路(stream-utils/node-web-streams-helper.ts 内):
- 监听
</body>标签出现的位置(用indexOfUint8Array(ENCODED_TAGS.CLOSED.BODY, ...))。 - 在
</body>之前插入 inline RSC chunks。 - 如果到流结束都没看到
</body>(dev 模式 root layout 异常),回退到末尾插入。
这就是为什么 React Fizz 的输出和 RSC inline 能配合得"看起来天然"——其实是 Next.js 在 stream transformer 里做了精细调度。
十、Fizz 内部的事件序列
react-dom 的 renderToPipeableStream 触发的回调按时间序:
请求开始
│
▼
┌──── React 渲染 ────┐
│ <App> 解析 RSC tree │
│ ├ 同步分支立即渲染 │
│ ├ Suspense fallback│
│ └ 注册 await 等待 │
└─────────┬──────────┘
│
▼ onShellReady
立刻 pipe HTML 到 res
(浏览器看到 shell + fallback)
│
▼ 第 1 个 Suspense 完成
Fizz 注入 <template> + <script>
浏览器替换 fallback 为内容
│
▼ 第 2、3、...个 Suspense 完成
持续注入
│
▼ onAllReady
res.end()
这里有几个关键事实:
onShellReady≠ DOMContentLoaded:shell 输出后浏览器 DOM 已经 ready,但 React hydrate 还没完成。- Fizz 自动维护
<template>+ script 注入:你不用自己写"等 fetch 完成再切换 fallback"——React 内置了<template id="..."><div>新内容</div></template><script>$RC("placeholder", "template")</script>这套魔法。 onAllReady才结束响应流:除非显式res.end()或客户端 abort,连接会保持到所有 Suspense ready。
十一、Hydration:如何让客户端"接管"
服务端发出去的 HTML 含有:
- 完整的 DOM(包括 fallback 占位 + 已替换的内容)
<script>self.__next_f.push(...)</script>inline RSC chunks<script src="...main-app.js">bootstrap
客户端启动顺序:
- 浏览器解析 HTML,DOM ready,所有
<script>按顺序执行(包括 inline RSC pushes 和 main-app)。 - main-app 加载 React 客户端 runtime,调用
hydrateRoot(document, <App />)。 <App />在客户端 React 里再跑一遍:通过ReactClient.use(createFromReadableStream(__next_f))反序列化 inline RSC,得到<AppRouter>树。- React 把这棵树和 DOM 对齐——这就是 hydrate。
- Hydrate 完成后,client component 接管事件、state;server component 渲染结果作为
<AppRouter>树的"已渲染叶子"保留。
业务排障:报错
Hydration failed: Text content does not match,几乎总是因为:
- 服务端 prop 和客户端 prop 不同(比如
Date.now()、Math.random()、window.matchMedia)- 浏览器扩展(Grammarly、LastPass 等)在 hydrate 前修改了 DOM
- 父组件传给 client component 的对象在每次渲染创建新引用(虽然不会破坏 hydration,但会触发不必要的 re-render)
十二、关键角色 7:preinit scripts
<App> 里的第一行:
preinitScripts();
它执行的是 React preinit 调用——服务端通过 getRequiredScripts 拿到这一页要用的 chunks,然后用 ReactDOM.preinit(src, { as: 'script', ... }) 让 React 在 HTML 里插入 <link rel="modulepreload"> 或 <script async>。
这是为什么页面 <head> 里能看到一系列 chunk preload。它早于 <body> 渲染,浏览器并行下载资源,缩短 hydrate 等待时间。
十三、错误兜底:renderToStream errorRecovery
app-render.tsx 在 dual render 主分支外面包了一层 try { ... } catch (err) { ... }:
// MARK: renderToStream errorRecovery
} catch (err) {
如果 React 渲染失败:
- 检查是否是 prerender bailout(PPR 里特有)
- 否则用
ErrorApp重新渲染一份 error 页面(app/error.tsx或app/global-error.tsx对应的 RSC payload) - 同样走一遍
renderToFizzStream+continueFizzStream,输出错误 HTML 流
这就是为什么生产用户能看到合理的 error 页面而不是白屏——只要 server 还能跑 React,错误页面也能流式输出。
十四、生产排障实战清单
| 现象 | 在 dual rendering 中的位置 | 排查方向 |
|---|---|---|
| TTFB 偏高 | shell 没有 ready | 检查 root layout / generateMetadata 是否有阻塞 await |
| 浏览器只看到 fallback,不替换 | inline RSC 没插入 | 检查 nonce 是否阻断了 script、CSP 是否限制了 inline script |
| Googlebot 看到空 body | 没正确进入 waitForAllReady | 检查 isBot 识别、user-agent 是否被中间网关篡改 |
| Hydration mismatch | 服务端 / 客户端 prop 不一致 | 用 React DevTools Components 对比 server snapshot |
<head> 里没有预期的 preload | preinit 没跑 | 检查 buildManifest 是否包含该 page entry |
Fizz 报 MISSING_ROOT_TAGS_ERROR | dev 检查 root layout | 在 app/layout.tsx 加上 <html> <body> |
| 流响应中断 | 某个 server component 抛错且没 boundary | 在 page 外面加 error.tsx,并检查 onError 是否记录 digest |
十五、配套 fixture:直接观察 dual rendering
fixtures/lecture-17/ 设计了三种场景:
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-17
pnpm install
pnpm dev
# 浏览 http://localhost:3017
关键观察点:
- shell vs all-ready:用
curl -A "Mozilla/5.0"和curl -A "Googlebot/2.1"分别访问/streaming,对比响应到达时间和分块。 - inline RSC 位置:访问
/inspect,view-source 看<script>self.__next_f.push(在 HTML 中的位置(应该都在</body>前一段)。 <App>的 hydrate 行为:在 client-only 组件里打console.log('hydrated', Date.now()),对比 server timestamp。
十六、本讲小结
- Dual rendering = RSC 流(react-server-dom-webpack)+ Fizz 流(react-dom/server),通过
tee()一份 RSC 流被<App>在服务端 SSR 期间消费、另一份被 inlined 到 HTML 里给客户端 hydrate 用。 <App>是 client component,借ReactClient.use(getFlightStream(...))把 RSC 流转成 React tree 再被 react-dom 渲染。onShellReady/onAllReady/shouldWaitOnAllReady决定是流式还是"等全部";bot 默认走 wait-for-all。continueFizzStream在 HTML 流里精确插入 inlinedDataStream / serverInsertedHTML / metadata / closing tags。- Hydration 时客户端 React 通过 inline RSC 反序列化 + DOM 对齐完成接管。
下讲预告
第 18 讲《Suspense 边界与 loading.tsx:流式渲染的关键武器》。我们会拆 <Suspense> 在 RSC + SSR 双渲染下的实际行为,讲清楚 loading.tsx 如何被自动包裹、并发请求时它怎么"提升"到合适的层级、以及 useFormStatus/useTransition 等 React 19 新 hook 在 Suspense 下的工作机制。