发布日期

第 17 讲:SSR 与 Dual Rendering:Fizz Stream 与 RSC Stream 的合奏

SSR 双流渲染:Fizz HTML 流与 RSC Stream 如何合奏成完整响应

上一讲我们看了 RSC 流是怎么生成的。但浏览器在第一次访问页面时,先要拿到 HTML 才能启动 React——RSC 流是被"内联"在 HTML 里发过去的。本讲就来拆这个合奏:服务端是怎么把"HTML 流"和"RSC 流"组合在一起,实现 Streaming SSR 的?

学习目标

读完本讲,你能:

  1. 区分 Fizz stream(react-dom/server 输出的 HTML 流)和 Flight stream(react-server-dom-webpack 输出的 RSC 流)。
  2. 看懂 dual rendering 的"合奏结构":RSC 流 → ReactClient.use() → React Element → Fizz stream → <App> 组件 → bootstrap script。
  3. 理解 onShellReady vs onAllReadyshouldWaitOnAllReady 的关系,以及它们对 SEO bot、TTFB、流式有什么影响。
  4. 解释 <App> 组件、ReactServerResult.tee()continueFizzStreamcreateNodeInlinedDataStream 等关键拼装函数的角色。
  5. 在生产排障时定位 hydration mismatch、shell 卡住、bot 看不到内容这类问题。

本讲对应代码:packages/next/src/server/app-render/app-render.tsx(约 4080~4350 行)、stream-ops.web.ts/stream-ops.node.tsstream-utils/node-web-streams-helper.tsuse-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 这里的拼装策略是:

  1. 先生成 RSC 流。
  2. tee 一路给 <App> 在 SSR 期间用 ReactClient.use() 消费,得到 React tree,喂给 react-dom 渲染 HTML。
  3. 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> 组件

2467:packages/next/src/server/app-render/app-render.tsx
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.useHeadManagerContext.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

637:packages/next/src/server/app-render/stream-ops.node.ts
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 = truewaitForAllReady,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 模式的代码:

2325:packages/next/src/server/base-server.ts
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-rendershouldWaitOnAllReady = 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

16:packages/next/src/server/lib/streaming-metadata.ts
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,
});

它要做的"插入"分四类:

  1. inlinedDataStream:把 RSC chunks 写成 <script>self.__next_f.push([1,"..."])</script>,插入 </body> 前。
  2. getServerInsertedHTML:执行 React useServerInsertedHTML() 的回调,把 styled-jsx、emotion 等的关键 CSS 注入到 <head>
  3. getServerInsertedMetadata:metadata streaming 模式下把 metadata 块注入到 <head>
  4. 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>

bootstrapScriptgetRequiredScripts(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 内):

  1. 监听 </body> 标签出现的位置(用 indexOfUint8Array(ENCODED_TAGS.CLOSED.BODY, ...))。
  2. </body> 之前插入 inline RSC chunks。
  3. 如果到流结束都没看到 </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 HTMLres
   (浏览器看到 shell + fallback)
          ▼ 第 1Suspense 完成
   Fizz 注入 <template> + <script>
   浏览器替换 fallback 为内容
          ▼ 第 23...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 含有:

  1. 完整的 DOM(包括 fallback 占位 + 已替换的内容)
  2. <script>self.__next_f.push(...)</script> inline RSC chunks
  3. <script src="...main-app.js"> bootstrap

客户端启动顺序:

  1. 浏览器解析 HTML,DOM ready,所有 <script> 按顺序执行(包括 inline RSC pushes 和 main-app)。
  2. main-app 加载 React 客户端 runtime,调用 hydrateRoot(document, <App />)
  3. <App /> 在客户端 React 里再跑一遍:通过 ReactClient.use(createFromReadableStream(__next_f)) 反序列化 inline RSC,得到 <AppRouter> 树。
  4. React 把这棵树和 DOM 对齐——这就是 hydrate。
  5. 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) { ... }

4347:packages/next/src/server/app-render/app-render.tsx
      // MARK: renderToStream errorRecovery
    } catch (err) {

如果 React 渲染失败:

  1. 检查是否是 prerender bailout(PPR 里特有)
  2. 否则用 ErrorApp 重新渲染一份 error 页面(app/error.tsxapp/global-error.tsx 对应的 RSC payload)
  3. 同样走一遍 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> 里没有预期的 preloadpreinit 没跑检查 buildManifest 是否包含该 page entry
Fizz 报 MISSING_ROOT_TAGS_ERRORdev 检查 root layoutapp/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

关键观察点:

  1. shell vs all-ready:用 curl -A "Mozilla/5.0"curl -A "Googlebot/2.1" 分别访问 /streaming,对比响应到达时间和分块。
  2. inline RSC 位置:访问 /inspect,view-source 看 <script>self.__next_f.push( 在 HTML 中的位置(应该都在 </body> 前一段)。
  3. <App> 的 hydrate 行为:在 client-only 组件里打 console.log('hydrated', Date.now()),对比 server timestamp。

十六、本讲小结

  1. Dual rendering = RSC 流(react-server-dom-webpack)+ Fizz 流(react-dom/server),通过 tee() 一份 RSC 流被 <App> 在服务端 SSR 期间消费、另一份被 inlined 到 HTML 里给客户端 hydrate 用。
  2. <App> 是 client component,借 ReactClient.use(getFlightStream(...)) 把 RSC 流转成 React tree 再被 react-dom 渲染。
  3. onShellReady / onAllReady / shouldWaitOnAllReady 决定是流式还是"等全部";bot 默认走 wait-for-all。
  4. continueFizzStream 在 HTML 流里精确插入 inlinedDataStream / serverInsertedHTML / metadata / closing tags。
  5. Hydration 时客户端 React 通过 inline RSC 反序列化 + DOM 对齐完成接管。

下讲预告

第 18 讲《Suspense 边界与 loading.tsx:流式渲染的关键武器》。我们会拆 <Suspense> 在 RSC + SSR 双渲染下的实际行为,讲清楚 loading.tsx 如何被自动包裹、并发请求时它怎么"提升"到合适的层级、以及 useFormStatus/useTransition 等 React 19 新 hook 在 Suspense 下的工作机制。