- 发布日期
第 16 讲:Server Components 渲染:renderToReadableStream 与 RSC payload
Server Components 渲染:renderToReadableStream、RSC payload 格式与 Flight 协议
上一讲我们看到请求最终落在
workAsyncStorage.run(workStore, renderToHTMLOrFlightImpl, ...)。本讲我们就从renderToHTMLOrFlightImpl出发,回答一个问题:React Server Components 是怎么从一棵 React Element 树,被序列化成一段二进制流,再通过 HTTP 响应送到浏览器的?
学习目标
读完本讲,你能:
- 区分 RSC payload(Flight payload)和 HTML payload 的目的、生成路径、被消费的方式。
- 理解
renderToReadableStream(react-server-dom-webpack/server)和renderToPipeableStream在 Next.js 里的差异(web vs node、edge vs node runtime)。 - 看懂
InitialRSCPayload和NavigationFlightResponse这两个 RSC payload 数据结构里每个字段(P、c、f、m、G、S、h、s、l、p、d)的含义。 - 解释 Flight protocol 的 wire format(行式记录、
I/M/H前缀、引用与延后片段)。 - 在生产排障时能用 Flight payload 的内容反推渲染问题(比如
Cannot find module报错的真实位置、bundle 引用错误)。 - 知道为什么 React Server Components 必须跑在 react-server condition 下("server-only" 文件存在的意义)。
本讲对应代码:
packages/next/src/server/app-render/app-render.tsx、entry-base.ts、stream-ops.web.ts、stream-ops.node.ts、packages/next/src/server/app-render/types.ts、packages/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 顶部的导入:
RSCPayload,
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 函数末尾:
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就是上一讲说的FlightRouterState,seedData就是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,d是unstable_dynamicStaleTime,h是 metadata 的 vary 参数。
排障小贴士:你在客户端
window.__next_f数组里看到的就是这个 payload 的逐 chunk 序列化结果。打开 DevTools 控制台console.log(window.__next_f.map(x => x[1]).join(''))可以看到完整的 Flight 流原文。
NavigationFlightResponse 的字段
来自 generateDynamicRSCPayload:
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 只在首屏意义大),所以只需要 f、q、i、S、h 这一组就够。
业务示例:当你点
<Link href="/dashboard">,client 收到的就是 NavigationFlightResponse,里面f字段是FlightDataPath[]数组,每条 path 描述"路由树的哪条分支需要替换 + 替换后的 seedData"。这就是为什么导航是 增量 的——只发生变化的 segment 才会出现在f里。
三、payload → ReadableStream:renderToReadableStream
把 RSCPayload 这个 JS 对象变成字节流的核心 API 来自 react-server-dom-webpack/server:
// 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'
注意:
- 必须从
react-server-dom-webpack/server导出,且必须放在entry-base.ts——这个文件是整个app-render子图里 唯一允许从react-server-dom-webpack/*导入的边界。其它任何文件想用renderToReadableStream,都必须经过componentMod间接拿到。这是 react-server condition 的强约束。 - 入口名
react-server-dom-webpack:尽管我们用 turbopack 也能跑,但模块名仍然是webpack——这是 React 上游的命名习惯。Turbopack build 时会把它 alias 到react-server-dom-turbopack(packages 里有 vendored 版本,第 25 讲 turbopack 篇会讲)。
node 与 web 的双实现
renderToReadableStream 是 Web Streams 版本(返回 ReadableStream<Uint8Array>),适合 Edge Runtime 和现代 Node。Next.js 还兜底了 Node Streams 的 renderToPipeableStream:
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 的封装
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:
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
}
这里多了一步:通过 PassThrough 把 pipe API 变成可订阅的 Readable。返回的 pt 既能 pipe 到 HTTP response,也能被 chainStreams 拼接。
四、generateDynamicFlightRenderResult:导航请求的端到端流程
我们已经看过 generateDynamicRSCPayload(生成 payload 对象),再看 generateDynamicFlightRenderResult(把 payload 变成响应):
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 分支
}
}
关键流程串起来:
getClientReferenceManifest():取出本次构建产生的"客户端引用清单"。它告诉 ReactclientModules['/Button.js#default']应该指向哪个 chunk URL。workUnitAsyncStorage.run(requestStore, generateDynamicRSCPayload, ctx, options):在请求级 async storage 下生成 payload。requestStore里挂着 cookies、headers、prerenderManifest 等运行时数据。renderToNodeFlightStream/renderToWebFlightStream:把 payload 推进renderToReadableStream,得到一段流。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 不解析这个流,只负责传输。
为什么用行式?
行式记录有两大好处:
- 流式(streaming)友好:服务端可以一边渲染一边推送,客户端解析也是 line-by-line。Suspense 触发时 server 推送
null/占位,等 promise resolve 再推送真实行——客户端 Suspense 才能"懒填充"。 - 引用语义友好:
["$","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 下导入),其特征:
- 没有
useState、useEffect:这些 hook 在 react-server entry 里压根没导出,调用就抛错。 - 没有
useRef:同上。 - server hooks 可用:
use(promise)、use(context)、useId。 - 可以
await异步组件:服务端的 React Server Components 是 async functions。 - 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,
);
它的工作:
- 生成 digest:对错误信息做哈希,得到一个稳定的 8 字节 digest。
- 把 digest 写入 RSC 流(
E<id>:{"digest": "abc123..."})。 - 把 (digest → message) 映射存到
workStore.reactServerErrorsByDigest。 - 调用
onInstrumentationRequestError(...),把错误冒泡到instrumentation.ts的onRequestErrorhook,便于上报到 Sentry/Datadog。
客户端 RSC 解析器看到 E<id>: 时,会抛错,触发最近的 error.tsx boundary。error.tsx 的 digest 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 同时跑 两种渲染:
- RSC 流(
renderToReadableStreamfrom react-server-dom-webpack/server)→ 内联到 HTML 里的<script>self.__next_f.push([1,"..."])</script> - HTML 流(
renderToReadableStreamfrom react-dom/server.edge 或renderToPipeableStreamfrom 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 里其实分了两条主要路径:
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.prerender和react-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 client | RSC 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 allowed | Edge 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
观察点:
- 首屏的 inline RSC:访问
/后 view-source,搜self.__next_f.push,可以直接看到行式 RSC chunks。 - Navigation 的 RSC 流:DevTools → Network → 点击页面里任意 Link → 找
text/x-component→ Response。 - Suspense 流式效果:访问
/streaming,三个 server component 故意 sleep 不同时长,开页面时能看到 fallback 逐个被替换。 - client component reference:访问
/with-client,搜 RSC payload 里的M0:行,看到 chunks 引用。 - 错误 digest:访问
/throw,故意抛错,server console 里能看到 digest,前端 error.tsx 的 props 也带着同一个 digest。
十五、本讲小结
- RSC payload 是一种行式序列化协议,由
react-server-dom-webpack/server.renderToReadableStream生成,本质上是一次"输出到 Flight 流"的 React 渲染。 - Next.js 把所有 react-server 边界 import 严格收敛到
entry-base.ts一个文件,其它地方通过ComponentMod.*间接拿到 API。 InitialRSCPayload比NavigationFlightResponse多P/c/G/m,因为首屏需要"完整启动"客户端 router。__NEXT_USE_NODE_STREAMS决定底层是renderToReadableStream(web)还是renderToPipeableStream(node),DCE 保证 edge bundle 不会拉进 Node API。Suspense是流式输出的物理边界,配合 RSC 行式协议实现"先到先显示"。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 在一起最终输出给浏览器。