Read OSS

深入 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() 初始化 WorkStoreRequestStore,随后构建 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 渲染器:

  1. Fizzreact-dom/server)——将 React 元素渲染为支持 Suspense 边界的 HTML 流
  2. Flightreact-server-dom-webpack/server)——将 React 树序列化为二进制 payload,供客户端重建使用

流操作被抽象在 stream-ops.ts 中,该文件再导出自 stream-ops.web.ts。核心操作包括:

  • renderToFizzStream — 将组件树渲染为 HTML
  • renderToFlightStream — 将 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 是渲染引擎中架构上最具雄心的特性。它将渲染拆分为两个阶段:

  1. 静态外壳(Static shell) — 在构建时预渲染,包含所有静态内容,以及动态区域的 Suspense fallback
  2. 动态空洞(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 缓存机制。