发布日期

第 16 讲:Server Components 渲染:renderToReadableStream 与 RSC payload

Server Components 渲染:renderToReadableStream、RSC payload 格式与 Flight 协议

上一讲我们看到请求最终落在 workAsyncStorage.run(workStore, renderToHTMLOrFlightImpl, ...)。本讲我们就从 renderToHTMLOrFlightImpl 出发,回答一个问题:

React Server Components 是怎么从一棵 React Element 树,被序列化成一段二进制流,再通过 HTTP 响应送到浏览器的?

学习目标

读完本讲,你能:

  1. 区分 RSC payload(Flight payload)和 HTML payload 的目的、生成路径、被消费的方式。
  2. 理解 renderToReadableStream(react-server-dom-webpack/server)和 renderToPipeableStream 在 Next.js 里的差异(web vs node、edge vs node runtime)。
  3. 看懂 InitialRSCPayloadNavigationFlightResponse 这两个 RSC payload 数据结构里每个字段(PcfmGShslpd)的含义。
  4. 解释 Flight protocol 的 wire format(行式记录、I/M/H 前缀、引用与延后片段)。
  5. 在生产排障时能用 Flight payload 的内容反推渲染问题(比如 Cannot find module 报错的真实位置、bundle 引用错误)。
  6. 知道为什么 React Server Components 必须跑在 react-server condition 下("server-only" 文件存在的意义)。

本讲对应代码:packages/next/src/server/app-render/app-render.tsxentry-base.tsstream-ops.web.tsstream-ops.node.tspackages/next/src/server/app-render/types.tspackages/next/src/build/webpack/loaders/next-flight-loader/

一、为什么 RSC 需要"序列化"

经典的 SSR:服务端把 React 组件渲染成 HTML 字符串,浏览器拿到 HTML 后再 hydrate。流程是 Component → HTML → Browser DOM

RSC 的额外目标:

  • 让一部分组件 完全不进客户端 JS bundle(比如直接读 DB 的 Server Component)。
  • 让客户端的"重新挂载"可以增量,而不是 reload 整个 page。
  • 让"只更新某个 layout 下某个 segment"成为可能(Next.js App Router 的 navigate 就是这样的)。

要做到这些,就需要一种"中间表示":既不是组件源码(client 看不懂),也不是 HTML(已经丢失了组件树结构),而是一种 可以被客户端 React 还原成虚拟 DOM 的、紧凑的二进制流。这就是 RSC payload,也叫 Flight payload。

业务视角:你点 <Link href="/products/iphone-15">,浏览器发出 RSC 请求,Next.js 服务端生成的就是一段 Flight payload,client 拿到后用 createFromFetch/createFromReadableStream 还原成新的 React 子树,commit 到现有 DOM 上。整个过程不会 reload <head><script>,也不会重置任何已有的 client component state。

二、两个 RSC payload:Initial vs Navigation

打开 app-render.tsx 顶部的导入:

14:packages/next/src/server/app-render/app-render.tsx
  RSCPayload,
12:packages/next/src/server/app-render/app-render.tsx
  InitialRSCPayload,

它们都是 app-render/types.ts 里定义的对象类型。区别在于使用场景:

类型何时生成配套响应
InitialRSCPayload首屏 GET(产生完整 HTML+RSC 内联)text/html,带 <script>self.__next_f.push(...)</script> 内联 RSC chunks
NavigationFlightResponse (a RSCPayload)客户端点击 Link 触发的 RSC 请求text/x-component,纯 Flight 流

InitialRSCPayload 的字段

来自 getRSCPayload 函数末尾:

2265:packages/next/src/server/app-render/app-render.tsx
  return maybeAppendBuildIdToRSCPayload(ctx, {
    P: createElement(Preloads, {
      preloadCallbacks: preloadCallbacks,
    }),
    c: prepareInitialCanonicalUrl(url),
    q: getRenderedSearch(query),
    i: !!couldBeIntercepted,
    f: [
      [
        initialTree,
        seedData,
        initialHead,
        isPossiblyPartialHead,
      ] as FlightDataPath,
    ],
    m: missingSlots,
    G: [GlobalError, globalErrorStyles],
    S: workStore.isStaticGeneration || ctx.renderOpts.cacheComponents,
    h: getMetadataVaryParamsThenable(),
    s: staleTimeIterable,
    l: staticStageByteLengthPromise,
    p: runtimePrefetchStream,
    d: !workStore.isStaticGeneration
      ? ((await getDynamicStaleTime(tree)) ?? undefined)
      : undefined,
  })

字段按"重要性"排序:

  • f (FlightData):四元组数组 [initialTree, seedData, initialHead, isPossiblyPartialHead]。其中 initialTree 就是上一讲说的 FlightRouterStateseedData 就是 CacheNodeSeedData(即客户端 LayoutRouter 树的"种子数据")。
  • c (canonical url):当前 URL,客户端 reducer 用它初始化 canonicalUrl
  • q (query):序列化的查询字符串,客户端 useSearchParams 第一次会同步取这个。
  • i (could be intercepted):拦截路由是否可能命中——影响客户端缓存键里要不要包含 Next-URL
  • G (global error)global-error.tsx 对应的组件 + 样式,让客户端在 root error 时仍然能渲染。
  • P (preloads):一个会执行 ReactDOM.preloadStyle/preloadFont 的 helper 组件——这是 React Server Components 唯一允许在 RSC payload 里"跑代码"的口子。
  • S (supports per-segment prefetch):客户端 segment cache 据此决定是否启用 per-segment 预取。
  • m (missing slots):dev 模式下追踪缺失的并行路由 slot,用于错误提示。
  • s / l / p / d / h:高级特性。s 是 PPR 的 staleTime 流,l 是 prerender 静态阶段产物的字节长度(用于 PPR runtime resume),p 是 runtime prefetch 的 stream,dunstable_dynamicStaleTimeh 是 metadata 的 vary 参数。

排障小贴士:你在客户端 window.__next_f 数组里看到的就是这个 payload 的逐 chunk 序列化结果。打开 DevTools 控制台 console.log(window.__next_f.map(x => x[1]).join('')) 可以看到完整的 Flight 流原文。

来自 generateDynamicRSCPayload

799:packages/next/src/server/app-render/app-render.tsx
  const baseResponse: NavigationFlightResponse = maybeAppendBuildIdToRSCPayload(
    ctx,
    {
      f: flightData,
      q: getRenderedSearch(query),
      i: !!couldBeIntercepted,
      S: workStore.isStaticGeneration,
      h: getMetadataVaryParamsThenable(),
    }
  )

  if (options?.staleTimeIterable !== undefined) {
    baseResponse.s = options.staleTimeIterable
  }

  if (options?.staticStageByteLengthPromise !== undefined) {
    baseResponse.l = options.staticStageByteLengthPromise
  }

  if (options?.runtimePrefetchStream !== undefined) {
    baseResponse.p = options.runtimePrefetchStream
  }

NavigationFlightResponse 的字段是 InitialRSCPayload 的 子集——客户端已经有 c(canonicalUrl 在 reducer 里)、G(global error 已经挂在 AppRouter 上)、P(preloads 只在首屏意义大),所以只需要 fqiSh 这一组就够。

业务示例:当你点 <Link href="/dashboard">,client 收到的就是 NavigationFlightResponse,里面 f 字段是 FlightDataPath[] 数组,每条 path 描述"路由树的哪条分支需要替换 + 替换后的 seedData"。这就是为什么导航是 增量 的——只发生变化的 segment 才会出现在 f 里。

三、payload → ReadableStream:renderToReadableStream

把 RSCPayload 这个 JS 对象变成字节流的核心 API 来自 react-server-dom-webpack/server

11:packages/next/src/server/app-render/entry-base.ts
// eslint-disable-next-line import/no-extraneous-dependencies
export {
  createTemporaryReferenceSet,
  renderToReadableStream,
  decodeReply,
  decodeAction,
  decodeFormState,
} from 'react-server-dom-webpack/server'

// eslint-disable-next-line import/no-extraneous-dependencies
export { prerender } from 'react-server-dom-webpack/static'

注意:

  1. 必须从 react-server-dom-webpack/server 导出,且必须放在 entry-base.ts——这个文件是整个 app-render 子图里 唯一允许从 react-server-dom-webpack/* 导入的边界。其它任何文件想用 renderToReadableStream,都必须经过 componentMod 间接拿到。这是 react-server condition 的强约束。
  2. 入口名 react-server-dom-webpack:尽管我们用 turbopack 也能跑,但模块名仍然是 webpack——这是 React 上游的命名习惯。Turbopack build 时会把它 alias 到 react-server-dom-turbopack(packages 里有 vendored 版本,第 25 讲 turbopack 篇会讲)。

node 与 web 的双实现

renderToReadableStreamWeb Streams 版本(返回 ReadableStream<Uint8Array>),适合 Edge Runtime 和现代 Node。Next.js 还兜底了 Node Streams 的 renderToPipeableStream

39:packages/next/src/server/app-render/entry-base.ts
export let renderToPipeableStream: FlightRenderToPipeableStream | undefined
export let prerenderToNodeStream: FlightPrerenderToNodeStream | undefined
if (process.env.__NEXT_USE_NODE_STREAMS) {
  renderToPipeableStream = (
    require('react-server-dom-webpack/server.node') as typeof import('react-server-dom-webpack/server.node')
  ).renderToPipeableStream
  prerenderToNodeStream = (
    require('react-server-dom-webpack/static') as typeof import('react-server-dom-webpack/static')
  ).prerenderToNodeStream
} else {
  renderToPipeableStream = undefined
  prerenderToNodeStream = undefined
}

关键点:

  • __NEXT_USE_NODE_STREAMS 是一个 构建期 feature flag,决定服务端用 Node Streams 还是 Web Streams(详见第 23 讲讲 flag plumbing)。
  • 在 Edge Runtime 里这个分支会被 DCE 完全删除,bundle 里不会包含 react-server-dom-webpack/server.node,避免引入 node:stream
  • 这就是为什么 entry-base.ts 的 require 必须严格放在 if (process.env.__NEXT_USE_NODE_STREAMS) { ... } else { ... } 的真双分支里——任何一边 break 了 DCE 模式都会让 edge bundle 多出一堆 Node 模块。

stream-ops.web.ts 的封装

248:packages/next/src/server/app-render/stream-ops.web.ts
export function renderToWebFlightStream(
  ComponentMod: FlightComponentMod,
  payload: FlightPayload,
  clientModules: FlightClientModules,
  opts: FlightRenderOptions
): AnyStream {
  return ComponentMod.renderToReadableStream(payload, clientModules, opts)
}

注意它接收的是 ComponentMod,而不是直接 import renderToReadableStream。这是 强约束stream-ops.web.ts 不在 react-server condition 下,不能直接 require react-server 包;必须由 react-server 层(entry-base.ts)通过 ComponentMod 传过来。

stream-ops.node.ts 走 Node Streams:

597:packages/next/src/server/app-render/stream-ops.node.ts
export function renderToNodeFlightStream(
  ComponentMod: FlightComponentMod,
  payload: any,
  clientModules: any,
  opts: any
): AnyStream {
  if (!ComponentMod.renderToPipeableStream) {
    throw new Error('renderToPipeableStream is not implemented')
  }

  const pt = new PassThrough()
  const pipeable = ComponentMod.renderToPipeableStream!(
    payload,
    clientModules,
    opts
  )
  pipeable.pipe(pt)
  return pt
}

这里多了一步:通过 PassThroughpipe API 变成可订阅的 Readable。返回的 pt 既能 pipe 到 HTTP response,也能被 chainStreams 拼接。

四、generateDynamicFlightRenderResult:导航请求的端到端流程

我们已经看过 generateDynamicRSCPayload(生成 payload 对象),再看 generateDynamicFlightRenderResult(把 payload 变成响应):

928:packages/next/src/server/app-render/app-render.tsx
async function generateDynamicFlightRenderResult(
  req: BaseNextRequest,
  ctx: AppRenderContext,
  requestStore: RequestStore,
  options?: { ... }
): Promise<RenderResult> {
  // ...
  if (process.env.__NEXT_USE_NODE_STREAMS) {
    // ...
    const { clientModules } = getClientReferenceManifest()

    const rscPayload = await workUnitAsyncStorage.run(
      requestStore,
      generateDynamicRSCPayload,
      ctx,
      options
    )

    const flightStream = workUnitAsyncStorage.run(
      requestStore,
      renderToNodeFlightStream,
      ctx.componentMod,
      rscPayload,
      clientModules,
      {
        onError,
        temporaryReferences: options?.temporaryReferences,
        filterStackFrame,
        debugChannel: debugChannel?.serverSide,
      }
    )

    return new FlightRenderResult(
      flightStream,
      { fetchMetrics: workStore.fetchMetrics },
      options?.waitUntil
    )
  } else {
    // 对应的 web 分支
  }
}

关键流程串起来:

  1. getClientReferenceManifest():取出本次构建产生的"客户端引用清单"。它告诉 React clientModules['/Button.js#default'] 应该指向哪个 chunk URL。
  2. workUnitAsyncStorage.run(requestStore, generateDynamicRSCPayload, ctx, options):在请求级 async storage 下生成 payload。requestStore 里挂着 cookies、headers、prerenderManifest 等运行时数据。
  3. renderToNodeFlightStream/renderToWebFlightStream:把 payload 推进 renderToReadableStream,得到一段流。
  4. new FlightRenderResult(flightStream, ...):包成 RenderResult,由 base-server 写回 HTTP response。

业务示例:你的产品页有 <AddToCartButton> 是 client component,又有一个 server component 直接渲染商品参数。React 序列化时会把 <AddToCartButton> 替换成一个 reference({ $$typeof: 'react.client.reference', id: 'src/AddToCartButton.tsx#default' }),客户端 React 看到这个 reference 就 动态加载 对应 chunk 并 hydrate。

五、Flight Wire Format:流里到底长什么样

打开浏览器 DevTools,访问任意 App Router 页面,找到 text/x-component 类型的请求,查看响应原文:

0:["$","html",null,{"lang":"zh-CN","children":["$L1","$L2"]}]
1:["$","body",null,{"className":"layout","children":["$L3"]}]
2:["$","head",null,{"children":[["$","title",null,{"children":"Lecture 16"}]]}]
3:["$Sym","react.lazy",{"$$typeof":"react.client.reference","id":"src/Button.tsx#default","chunks":["static/chunks/...","static/css/..."],"name":""}]
M1:{"id":"src/Button.tsx#default","chunks":["static/chunks/Button.abc123.js"],"name":"default"}
H:["1f3a"]

这是一种 行式(per-line) 序列化格式:

前缀含义
<id>:数据行,行号=React Flight ID,对应 payload 树里的一个节点
M<id>:Module reference(client component 引用)
H:"Hint"——Float(resource preloading)信号,对应 ReactDOM.preloadStyle/preloadFont/preconnect
I<id>:Importable references(lazy 引用)
J<id>:Server reference(server actions 的 actionId 引用)
S<id>:Symbol references
E<id>:Error rows(rendering errors with digests)
D<id>:Debug 信息(dev 模式下)
$<id>引用标记(在 JSON 字面量里出现,表示"占位,等 row id 出现时再回填")

⚠️ Flight 协议是 React 的私有协议,没有 stable spec,几乎每个 React minor 版本都会有微调。但核心概念(行式记录 + 引用 + 占位回填)一直稳定。Next.js 不解析这个流,只负责传输

为什么用行式?

行式记录有两大好处:

  1. 流式(streaming)友好:服务端可以一边渲染一边推送,客户端解析也是 line-by-line。Suspense 触发时 server 推送 null/占位,等 promise resolve 再推送真实行——客户端 Suspense 才能"懒填充"。
  2. 引用语义友好["$","Component",null,{...}] 里的 Component 可以是 $L3(懒引用),客户端拿到 M3:{...} 行时再回填——这就实现了 client component 的 延后加载

业务排障示例:客户报告说"我的 client component 在 RSC 渲染中没显示"。打开 Network → text/x-component → 搜 M<id>:,如果没找到对应行,就是 server 端没正确把它识别为 client component(多半是 'use client' 没生效,或者 manifest 没收录)。

六、ClientReferenceManifest:把组件 ID 翻译成 chunk URL

getClientReferenceManifest() 返回的 clientModules 是一个 map,key 长成这样:

'/Users/.../app/products/[slug]/Button.tsx#default'{
  id: 'src_app_products_[slug]_Button_tsx',
  chunks: ['static/chunks/main-app.js', 'static/chunks/products.js'],
  name: 'default',
  async: false,
}

这个 manifest 由 webpack 的 flight-client-entry-plugin.ts(或 turbopack 等价插件)在 build 时生成。每个 'use client' 文件都会被分配一个稳定 ID,并记录它依赖的 chunks。

排障:当你看到 Cannot find client reference for 'src/.../Button.tsx#default'

  • 大概率 manifest 里没收录这个文件('use client' 写错了、文件没被任何 entry 引用)
  • 或者 build 产物缺失(部署遗漏 .next/server/app-paths-manifest.json 等)

第 23 讲 build pipeline 里我们会专门拆 flight-client-entry-plugin

七、生成 RSC payload 期间发生了什么

renderToReadableStream(payload, clientModules, opts) 内部其实是一次 完整的 React 渲染——只是它不输出 DOM/HTML,而是输出 Flight 流。整个过程发生在 react-server runtime(react-server-dom-webpack/server 强制要求 react-server condition 下导入),其特征:

  1. 没有 useStateuseEffect:这些 hook 在 react-server entry 里压根没导出,调用就抛错。
  2. 没有 useRef:同上。
  3. server hooks 可用use(promise)use(context)useId
  4. 可以 await 异步组件:服务端的 React Server Components 是 async functions
  5. client component 被替换成引用:见上节。

异步组件的"等待"

源码里有大量 await 调用,比如:

const seedData = await createComponentTree({ ... })
const initialTree = await createFlightRouterStateFromLoaderTree(...)

但这只是构建 payload 对象的过程。真正的 Suspense 懒等待 发生在 renderToReadableStream 内部:当一个 server component await fetch(...) 时,React 会暂时输出一个占位行(Suspense pending),继续渲染其它分支;fetch 完成后再回填这个占位行的内容。

这就是为什么 RSC 可以做到 "第一个商品的描述早于第二个商品的库存"——而不是非要全部等完才输出。

业务示例:商品列表页 5 个商品,每个商品都是独立的 server component 子树,每个组件内部都 await fetch。RSC 流会把这 5 个 fetch 并发发出(React 在渲染期建立 batch),并按完成顺序写入流。客户端在第一个商品 ready 时就能看到它的内容,体感比"等 5 个全完"快很多。

八、Suspense 与流式的边界

打开任意 App Router 页面 console 看:

0: ["$","$L1",null,{}]                  # AppRouter 占位
1: ["$","body",null,{"children":"$L2"}]  # body 占位
2: ["$","main",null,{"children":["$L3","$L4"]}]
3: <suspense pending>                    # 商品 A
4: <suspense pending>                    # 商品 B
3: ["$","article",null,{"children":"iPhone"}]   # 商品 A 完成
4: ["$","article",null,{"children":"MacBook"}]  # 商品 B 完成

看 server 把每个 <Suspense> 块都建模成"独立的 chunk":先输出占位,等 children 完成再续上。

Suspense 的边界是 流式的物理边界

  • 没有 <Suspense> 包裹 → 整棵树要等所有 await 完成才能开始输出 → 体感慢
  • <Suspense fallback={<Skeleton />}> 包裹 → 父级先输出 + fallback,子树独立流出 → 体感快

第 18 讲讲 Suspense 时我们会专门拆 fallback boundary 的实现。

九、错误处理:digest 与 server-side error

renderToReadableStream 接收一个 onError 回调:

const onError = createReactServerErrorHandler(
  process.env.NODE_ENV === "development",
  isBuildTimePrerendering,
  workStore.reactServerErrorsByDigest,
  onFlightDataRenderError,
);

它的工作:

  1. 生成 digest:对错误信息做哈希,得到一个稳定的 8 字节 digest。
  2. 把 digest 写入 RSC 流E<id>:{"digest": "abc123..."})。
  3. 把 (digest → message) 映射存到 workStore.reactServerErrorsByDigest
  4. 调用 onInstrumentationRequestError(...),把错误冒泡到 instrumentation.tsonRequestError hook,便于上报到 Sentry/Datadog。

客户端 RSC 解析器看到 E<id>: 时,会抛错,触发最近的 error.tsx boundary。error.tsxdigest prop 就是来自这里——你可以把 digest 上报后端去查具体错误。

业务示例:用户报错 "Application error" 但页面上看不到具体信息。打开 server 日志:搜对应 digest,可以快速定位是哪个 server component 抛的错。

十、debug channel:dev 模式的 server stack 回传

注意源码里的:

const debugChannel = setReactDebugChannel && createNodeDebugChannel();

这是 dev only 的"反向通道":当客户端 React 在 hydrate 期间 catch 到错误,通过这个 channel 把客户端 stack 推回 server,server 再把对应 server-side stack 配套打印。这是 React 19+ 的特性,叫 React Owner Stacks。

排障小贴士:你在 dev 模式 server console 看到 "originated at <Component>" 这样的信息,就是 debug channel 的产物。生产模式没这通道,所以错误信息会简单很多。

第 35 讲讲生产排障时我们会再讲它的局限。

十一、payload 怎么从服务端送到客户端

11.1 RSC-only 请求(导航)

base-server 把 FlightRenderResult 包成响应:

Content-Type: text/x-component
Cache-Control: ...
Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch

0:["$","html",null,{...}]
1:...
M2:{"id":"...","chunks":[...]}

客户端 fetch 这个响应,喂给 createFromReadableStream(在 react-server-dom-webpack/client 里),得到 React tree,然后送给 navigateReducer(第 9 讲)。

11.2 首屏 GET(HTML + 内联 RSC)

首屏不一样:客户端没有 React 根节点,需要先渲染 HTML 让浏览器启动。所以 server 同时跑 两种渲染

  1. RSC 流renderToReadableStream from react-server-dom-webpack/server)→ 内联到 HTML 里的 <script>self.__next_f.push([1,"..."])</script>
  2. HTML 流renderToReadableStream from react-dom/server.edge 或 renderToPipeableStream from react-dom/server.node)→ 作为 HTML 主体输出

这就是 SSR + RSC dual rendering,第 17 讲讲 SSR 时会展开。这里你只要知道:

  • RSC payload 在首屏会通过 <script>self.__next_f.push(...)</script> 内联,先于 HTML 内容到达(因为 React 用 inline data 通道)
  • 客户端 React 在 hydrate 时,先消费完 __next_f 里的 chunks,重建 RSC tree,再把它和 SSR 出来的 DOM 对齐

排障小贴士:window.__next_f 是 dev 工具最直观的"实时观察 RSC payload"窗口。你可以在 console 里改它的内容(追加自定义 chunks)来调试客户端解析逻辑——但这种 hack 仅供本地排障。

十二、prerender 与 dynamic:两种渲染入口

renderToHTMLOrFlightImpl 里其实分了两条主要路径:

2833:packages/next/src/server/app-render/app-render.tsx
  if (isStaticGeneration) {
    // We're either building or revalidating. In either case we need to
    // prerender our page rather than render it.
    const prerenderToStreamWithTracing = getTracer().wrap(
      AppRenderSpan.getBodyResult,
      {
        spanName: `prerender route (app) ${pagePath}`,
        attributes: {
          'next.route': pagePath,
        },
      },
      prerenderToStream
    )

    const response = await prerenderToStreamWithTracing(
      req,
      res,
      ctx,
      metadata,
      loaderTree,
      fallbackRouteParams
    )
  • isStaticGeneration = true:构建期 prerender 或 ISR revalidate。走 prerenderToStream(背后是 react-dom/static.prerenderreact-server-dom-webpack/static.prerender,配合 PPR 的 postpone() 机制)。
  • isStaticGeneration = false:动态请求。走 generateDynamicFlightRenderResult 或者完整的 SSR + RSC dual render。

我们这里只关心 RSC 部分。prerenderToStream 内部仍然会调用 prerender(payload, clientModules, opts),得到一个 prelude(先生成的部分),再配合 postponed state 在请求时恢复(PPR)。第 19 讲讲 PPR 时展开。

十三、生产排障实战清单

现象在 RSC 渲染层的位置排查方向
客户端 console: Cannot find module 'src/Button.tsx'clientModules 缺失检查 flight-client-entry-plugin 是否处理过该文件、'use client' 是否生效
RSC 请求 200 但页面空白payload 里 f 为空数组多半是 flightRouterState 与 server 不匹配,client 认为"没有变化";检查 router state header
Hydration error: server vs clientRSC payload 与 HTML 不一致查 client component 内 Math.random()/Date.now() 这种非确定性,或服务端 prop 与客户端 prop 不同
服务端日志 digest: '1f3a...' 但前端没显示onError 写了 digest 但客户端 error.tsx boundary 没渲染检查 error.tsx 是否在正确层级,或 server-side rerendering 失败回退到 fallback
RSC 流卡住(比如 30s 没结束)某个 server component await 永远不 resolve检查 fetch 超时、用 AbortSignal 设置上限
Edge Runtime build 报 node:stream is not allowedEdge bundle 误引入了 server.node检查 __NEXT_USE_NODE_STREAMS flag 是否在 edge 强制为 false

十四、配套 fixture:直接观察 RSC 流

fixtures/lecture-16/ 下我们准备了一组组件,并在 layout 里加入了脚本,把 window.__next_f 的内容打印出来。

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-16
pnpm install
pnpm dev
# 打开 http://localhost:3016

观察点:

  1. 首屏的 inline RSC:访问 / 后 view-source,搜 self.__next_f.push,可以直接看到行式 RSC chunks。
  2. Navigation 的 RSC 流:DevTools → Network → 点击页面里任意 Link → 找 text/x-component → Response。
  3. Suspense 流式效果:访问 /streaming,三个 server component 故意 sleep 不同时长,开页面时能看到 fallback 逐个被替换。
  4. client component reference:访问 /with-client,搜 RSC payload 里的 M0: 行,看到 chunks 引用。
  5. 错误 digest:访问 /throw,故意抛错,server console 里能看到 digest,前端 error.tsx 的 props 也带着同一个 digest。

十五、本讲小结

  1. RSC payload 是一种行式序列化协议,由 react-server-dom-webpack/server.renderToReadableStream 生成,本质上是一次"输出到 Flight 流"的 React 渲染。
  2. Next.js 把所有 react-server 边界 import 严格收敛到 entry-base.ts 一个文件,其它地方通过 ComponentMod.* 间接拿到 API。
  3. InitialRSCPayloadNavigationFlightResponseP/c/G/m,因为首屏需要"完整启动"客户端 router。
  4. __NEXT_USE_NODE_STREAMS 决定底层是 renderToReadableStream(web)还是 renderToPipeableStream(node),DCE 保证 edge bundle 不会拉进 Node API。
  5. Suspense 是流式输出的物理边界,配合 RSC 行式协议实现"先到先显示"。
  6. onError + digest 是 RSC 渲染期错误的反馈通道;dev 模式下还有 debug channel 把 client stack 反向送回 server。

下讲预告

第 17 讲《SSR 渲染:renderToReadableStream(react-dom)与 dual rendering》。我们将深入到 HTML 流的生成路径:fizz stream、shell ready、bot 检测、onShellReady vs onAllReady、以及 RSC 流和 HTML 流如何 chain 在一起最终输出给浏览器。