深入 App Router 渲染引擎:RSC、流式传输与 PPR
前置知识
- ›第 1-2 篇:架构概览与服务器启动/请求生命周期
- ›React Server Components 基础概念——服务端/客户端边界、'use client' 与 'use server' 指令
- ›Node.js Streams——ReadableStream、TransformStream 及管道操作
- ›Node.js AsyncLocalStorage——用于请求级别的上下文传播
深入 App Router 渲染引擎:RSC、流式传输与 PPR
Next.js App Router 的核心是 app-render.tsx——这个约 7,350 行的文件统筹协调了从 React Server Components 渲染到 HTML 流式传输,再到部分预渲染(PPR)的全部流程。框架最核心的特性都在这里汇聚:用于客户端导航的 Flight payload、用于首次 HTML 交付的 Fizz 流,以及实现"静态外壳 + 动态空洞"的 postpone 机制。
本文将沿着渲染管道的脉络,从路由模块入口一路追踪到流式输出,揭示这一切背后的架构设计模式。
路由模块系统:包装用户代码
正如第 2 篇所介绍的,路由匹配完成后,请求会被分发至对应的路由模块。对于 App Router 页面,入口是 AppPageRouteModule:
classDiagram
class RouteModule {
<<abstract>>
+definition: RouteDefinition
+userland: UserlandModule
+setup(): Promise
+handle(req, res, context)
#manifests: Manifests
#incrementalCache: IncrementalCache
}
class AppPageRouteModule {
+render(req, res, context)
-vendoredReactRSC
-vendoredReactSSR
+loaderTree: LoaderTree
}
RouteModule <|-- AppPageRouteModule
路由模块充当了服务器通用请求管道与渲染引擎之间的桥梁。有一个细节值得关注——React 本身的加载方式。在 module.ts#L40-L53 中,模块加载了两个独立的 React 实例:vendoredReactRSC 用于服务端组件渲染,vendoredReactSSR 用于 HTML 渲染。这两个都是 Next.js 内部维护的 vendored 副本,确保版本一致性,同时避免与用户自行安装的 React 产生冲突。
第 59 行定义的 AppPageModule 类型描述了打包后的 app 页面模块的结构——其中包含 loaderTree,这个数据结构描述了从 app/ 目录中解析出的嵌套布局与页面层级关系。
用 AsyncLocalStorage 实现依赖注入
在深入渲染流程之前,我们需要理解 Next.js 如何在深层嵌套的组件树中传递上下文,同时避免 prop drilling。答案是 AsyncLocalStorage——Node.js 提供的跨异步边界传播上下文的机制。
其中有两个核心 store:
WorkStore — 渲染级别的上下文,贯穿整个页面渲染过程:
export interface WorkStore {
readonly isStaticGeneration: boolean
readonly page: string
readonly route: string
readonly incrementalCache?: IncrementalCache
readonly cacheLifeProfiles?: { [profile: string]: CacheLife }
forceDynamic?: boolean
forceStatic?: boolean
}
RequestStore — 请求级别的上下文,包含实际的 HTTP 数据:
export interface RequestStore extends CommonWorkUnitStore {
readonly type: 'request'
readonly url: { readonly pathname: string; readonly search: string }
readonly headers: ReadonlyHeaders
cookies: ReadonlyRequestCookies
readonly mutableCookies: ResponseCookies
}
flowchart TD
Render["renderToHTMLOrFlight()"] --> WS["WorkStore\n(AsyncLocalStorage)"]
Render --> RS["RequestStore\n(AsyncLocalStorage)"]
WS --> HeadersAPI["headers()"]
WS --> CookiesAPI["cookies()"]
WS --> CacheDecisions["Cache/Static decisions"]
RS --> HeadersAPI
RS --> CookiesAPI
RS --> DraftMode["draftMode()"]
subgraph "User Code (Server Components)"
HeadersAPI
CookiesAPI
DraftMode
end
当你在 Server Component 中调用 headers() 时,其底层实现会通过 workUnitAsyncStorage.getStore() 从 RequestStore 中读取数据。这正是 Next.js 的各类 API 不需要显式接收参数的原因——它们直接从运行时的异步上下文中获取所需数据。这是一种基于运行时的依赖注入模式,也决定了这些 API 只能在渲染过程中调用(若找不到对应的 store,它们会直接抛出错误)。
提示: 这些文件名中的
.external.ts后缀有其特殊含义。它表示这些模块需要跨 bundler 边界共享——它们使用with { 'turbopack-transition': 'next-shared' }这样的 import 属性,确保无论模块如何被解析,始终使用同一个实例。
renderToHTMLOrFlight:核心入口
公开的入口函数是第 2691 行的 renderToHTMLOrFlight。它解析请求头以确定渲染模式,然后将实际工作委托给从第 2205 行开始、约 500 行的 renderToHTMLOrFlightImpl 函数。
最关键的分支判断是:输出 HTML 还是 Flight payload?
flowchart TD
Entry["renderToHTMLOrFlight()"] --> Parse["Parse Request Headers"]
Parse --> RSCCheck{"RSC_HEADER\npresent?"}
RSCCheck -->|Yes| Prefetch{"Prefetch\nrequest?"}
RSCCheck -->|No| HTML["Full HTML Render\n(Fizz streaming)"]
Prefetch -->|Yes| PrefetchFlight["Prefetch Flight Payload\n(static shell only)"]
Prefetch -->|No| NavigationFlight["Navigation Flight Payload\n(full RSC data)"]
HTML --> Postponed{"Postponed\nstate?"}
Postponed -->|Yes| Resume["Resume PPR\n(fill dynamic holes)"]
Postponed -->|No| FullRender["Full Server Render"]
FullRender --> StaticCheck{"Static\ngeneration?"}
StaticCheck -->|Yes| StaticPrerender["Prerender\n(cache result)"]
StaticCheck -->|No| DynamicRender["Dynamic Render\n(stream to client)"]
浏览器发起首次页面加载时,请求中不携带 RSC_HEADER——服务器会使用 React 的 Fizz 流式渲染器生成完整的 HTML。当客户端路由器发起导航时(详见第 4 篇),请求会带上 RSC_HEADER,服务器便转而返回 Flight payload——一种描述 React 树的二进制格式,不包含 HTML。
renderToHTMLOrFlightImpl 函数通过 createWorkStore() 和 createRequestStoreForRender() 初始化 WorkStore 与 RequestStore,随后构建 React 元素树并执行渲染。
构建组件树
create-component-tree.tsx 模块负责将 loader tree(由 next-app-loader 根据 app/ 目录结构生成)转换为 React 元素树:
graph TD
LoaderTree["Loader Tree\n(from next-app-loader)"] --> RootLayout["Root Layout\n(app/layout.tsx)"]
RootLayout --> ErrorBoundary1["ErrorBoundary"]
ErrorBoundary1 --> Template1["Template"]
Template1 --> Suspense1["Suspense\n(loading.tsx)"]
Suspense1 --> NestedLayout["Nested Layout\n(app/dashboard/layout.tsx)"]
NestedLayout --> ErrorBoundary2["ErrorBoundary"]
ErrorBoundary2 --> Template2["Template"]
Template2 --> Suspense2["Suspense"]
Suspense2 --> Page["Page\n(app/dashboard/page.tsx)"]
对于 loader tree 中的每个路由段,create-component-tree 会用错误边界(来自 error.tsx)、加载状态(来自 loading.tsx)和模板(来自 template.tsx)对组件进行层层包裹。正是这种嵌套结构,赋予了 App Router 细粒度的错误处理与加载 UI 能力——每个路由段都可以独立展示加载动画或捕获错误,互不干扰。
该函数还负责处理 'use client' 边界。当遇到客户端引用时,它不会在服务端渲染该组件,而是生成一个客户端引用标记,Flight 协议会通过这个标记告诉浏览器:"请加载此模块并在客户端渲染它。"
流式传输管道:Fizz 与 Flight
渲染输出始终是流,而非完整的字符串。Next.js 使用两种 React 渲染器:
- Fizz(
react-dom/server)——将 React 元素渲染为支持 Suspense 边界的 HTML 流 - Flight(
react-server-dom-webpack/server)——将 React 树序列化为二进制 payload,供客户端重建使用
流操作被抽象在 stream-ops.ts 中,该文件再导出自 stream-ops.web.ts。核心操作包括:
renderToFizzStream— 将组件树渲染为 HTMLrenderToFlightStream— 将 RSC 数据序列化,用于客户端导航createInlinedDataStream— 将 Flight 数据以<script>标签形式注入 HTML 流continueFizzStream— 处理 Suspense 边界的解析与流式传输
sequenceDiagram
participant App as React Tree
participant Flight as Flight Renderer
participant Fizz as Fizz Renderer
participant Transform as Stream Transform
participant Client as Browser
App->>Flight: Serialize RSC tree
Flight-->>Fizz: React elements (with client refs)
Fizz-->>Transform: HTML chunks
Flight-->>Transform: Flight data chunks
Transform->>Transform: Interleave HTML + <script> tags
Transform->>Client: Streaming response
Note over Client: Browser renders HTML immediately
Note over Client: Hydration uses inlined Flight data
在首次页面加载时,HTML 流与 Flight 数据会被合并输出。Flight 数据通过 createInlinedDataStream 以内联 <script> 标签的形式注入 HTML,浏览器无需发起第二次请求即可完成 hydration。这就是"自包含 HTML"方案——hydration 所需的 RSC 数据与 HTML 一起,在同一个响应中传输给浏览器。
客户端导航时,则只发送 Flight 流,不包含 HTML——因为客户端路由器可以直接将 RSC 数据应用到组件树上完成更新。
部分预渲染(PPR)
PPR 是渲染引擎中架构上最具雄心的特性。它将渲染拆分为两个阶段:
- 静态外壳(Static shell) — 在构建时预渲染,包含所有静态内容,以及动态区域的 Suspense fallback
- 动态空洞(Dynamic holes) — 在请求时填充,将动态内容流式传输以替换 fallback
dynamic-rendering.ts 模块(约 1,395 行)负责追踪动态访问行为。当 Server Component 在静态预渲染期间调用 headers()、cookies() 或其他动态 API 时,动态渲染系统会调用 React 的 postpone() API,将对应的 Suspense 边界标记为动态。
stateDiagram-v2
[*] --> StaticPrerender: Build time
StaticPrerender --> FullyStatic: No dynamic access
StaticPrerender --> PPRShell: Dynamic access detected
PPRShell --> SerializePostponed: React.postpone()
state "Request Time" as RT {
[*] --> LoadShell: Serve cached static HTML
LoadShell --> ResumeRender: Fill dynamic holes
ResumeRender --> StreamDynamic: Stream dynamic content
}
SerializePostponed --> RT: On request
FullyStatic --> ServeCached: Serve from cache
postponed-state.ts 模块定义了 postponed 状态的序列化格式。它分为两种类型:
DynamicState.DATA— 动态访问发生在 RSC 渲染阶段(例如,在 Server Component 中读取cookies())。此时服务器需要重新渲染 RSC 树。DynamicState.HTML— 动态访问发生在 HTML 生成阶段(例如,一个依赖动态数据的 Suspense 边界)。此时服务器可以从 postpone 点恢复 Fizz 渲染。
每个 postponed 状态都包含一个 RenderResumeDataCache——这是静态预渲染期间已计算完毕的数据,在恢复渲染时可直接复用。resume 数据缓存存放在 server/resume-data-cache/ 目录下,同时存储了预渲染期间捕获的 RSC payload 片段与 fetch 缓存条目。
提示: PPR 由
experimental.ppr配置项控制。启用后,构建阶段的预渲染会使用 React 的prerender()API(而非renderToString()),因为它支持postpone()。运行时则通过resume()来结合请求级别的数据完成渲染。
渲染决策矩阵
综合以上内容,渲染引擎会对每个请求依次做出如下决策:
| 条件 | 渲染策略 |
|---|---|
| 首次加载,无 postponed 状态 | 完整 Fizz HTML 流 + 内联 Flight 数据 |
| 首次加载,有 postponed 状态(PPR) | 提供静态外壳,恢复动态空洞 |
| RSC 请求(客户端导航) | 仅返回 Flight payload |
| RSC 预取请求 | 部分 Flight payload(仅包含布局,不含叶子节点数据) |
| 静态生成(构建时) | 预渲染,可能触发 postpone |
| ISR 重新验证 | 后台重新渲染,返回旧版本 |
渲染引擎的复杂性,正来自于在同一条代码路径中处理上述所有场景。renderToHTMLOrFlightImpl 函数根据解析后的请求头和 work store 状态选择对应的渲染策略,而组件树构建、错误处理和流管理则在所有路径中共享。
下一篇
我们已经了解了服务器如何渲染 App Router 页面——构建组件树、在 HTML 与 Flight 输出之间做出选择、以及协调 PPR 管道。那么,这些 Flight payload 到达浏览器后又会发生什么?下一篇文章将深入探讨客户端路由器——一个类 Redux 的状态机,负责管理导航、布局保持,以及让 PPR 预取成为可能的 segment 缓存机制。