发布日期

第 19 讲:PPR:Partial Prerendering 的 postpone 与 resume

Partial Prerendering(PPR)的 postpone/resume 原理与静动内容拼接

PPR (Partial Prerendering) 是 Next.js 在 14/15 推动的核心新能力:让一个页面可以同时拥有"静态预渲染"和"动态填充"两部分。它把 RSC + Suspense 推到极致:把整页拆成 static prelude + dynamic holes,build 时把能 prerender 的 static prelude 写到磁盘,运行时再 resume holes。

本讲拆 PPR 的 4 个关键机制:postpone()preluderesumeDataCachepostponedState 序列化与解析。

学习目标

读完本讲,你能:

  1. 解释 prerender / postpone / resume 三阶段在 PPR 里的角色,以及它们和"普通 SSG"、"动态 SSR"的边界。
  2. 看懂 postponedState 的序列化格式和它如何在 build 与 request 之间传递。
  3. 理解 DynamicState.HTMLDynamicState.DATA 两种 postpone 类型的区别。
  4. 解释 prerenderResumeDataCache / renderResumeDataCache 的作用,以及它为什么必须 immutable。
  5. 在生产排障时定位 "PPR bailout" 错误(哪一行代码导致 prerender 失败)。

本讲对应代码:

  • packages/next/src/server/app-render/postponed-state.ts(state 序列化)
  • packages/next/src/server/app-render/dynamic-rendering.ts(postpone 与 tracking)
  • packages/next/src/server/app-render/app-render.tsx(prerenderToStream + resume 路径)
  • packages/next/src/server/resume-data-cache/(resume cache 实现)
  • 第 13 讲讲过的 IncrementalCache + 第 11 讲的 'use cache' 是 PPR 的兄弟系统。

一、PPR 解决什么问题

经典的 SSG / ISR:

  • SSG:build 时全量预生成 HTML+RSC,部署上 CDN,TTFB 极低。但任何"动态部分"(如登录态、个性化内容)都不能用,否则 build 失败。
  • ISR:build 时部分预生成 + 运行时 revalidate;但还是"全或无"——一旦有 cookie/headers 这种动态读,整页就降级到 SSR。

PPR 的目标:让动态部分用 Suspense 隔离,其余仍走预生成。

// /products/[slug]/page.tsx
export default async function Page({ params }) {
  const product = await getProduct(params.slug); // 静态:build 时取
  return (
    <main>
      <h1>{product.name}</h1>
      <Suspense fallback={<Skel />}>
        <Cart /> {/* 动态:cookies(),运行时填充 */}
      </Suspense>
    </main>
  );
}

PPR 让上面这页:

  • build 时 prerender 出 <main><h1>iPhone 15</h1>...<div id="suspense-cart">...skel...</div></main> —— static prelude
  • 运行时收到请求,发现 prelude 已经准备好,立刻返回 prelude(CDN 命中级别的速度)
  • 与此同时启动一个"resume render",只渲染 <Cart> 这一段,把结果通过相同的流附加到响应

这就把"FCP 低如 SSG,但能用 cookies/headers"的最佳两面合在一起。

二、PPR 的开关

PPR 在 v15 仍是 experimental。关键 flag:

// next.config.js
module.exports = {
  experimental: {
    ppr: "incremental", // 或 true
    cacheComponents: true, // 或单独使用 'use cache'
  },
};
  • ppr: true — 全部 App Router 路由都启用 PPR
  • ppr: 'incremental' — 仅对显式 export const experimental_ppr = true 的页面启用
  • cacheComponents: true — 启用 'use cache' 与配套的 RSC 渲染管线(PPR 的主力依赖)

业务示例:迁移老仓库时常常先开 'incremental',逐路由验证;遇到 PPR bailout 修一个,覆盖一个。这种灰度方式可以避免一次开全部带来大量 build 失败。

三、postpone:让 React 把动态读"暂停"

普通 React 在渲染时遇到 await fetch(...) 会 suspend;遇到没法 suspend 的副作用就抛错。PPR 引入了一个新原语:React.unstable_postpone(reason)

399:packages/next/src/server/app-render/dynamic-rendering.ts
export function postponeWithTracking(
  route: string,
  expression: string,
  dynamicTracking: null | DynamicTrackingState
): never {
  assertPostpone()
  if (dynamicTracking) {
    dynamicTracking.dynamicAccesses.push({
      stack: dynamicTracking.isDebugDynamicAccesses
        ? new Error().stack
        : undefined,
      expression,
    })
  }

  React.unstable_postpone(createPostponeReason(route, expression))
}

postpone() 抛出一个特殊"信号对象"(不是普通错误)。React 内部在 prerender 阶段看到这个信号,就把当前位置 标记为 hole,继续渲染兄弟分支,最后在 prerender 输出里塞一个 <!-- $? --> 占位。

什么时候会调到 postpone()?看 dynamic-rendering.ts 里的入口:

// 用户调用 cookies() / headers() / draftMode()
// → 内部调 postponeWithTracking('Route ... used cookies()')
// 用户读 dynamic searchParams (params 不包含 page 内动态字段)
// → 同上
// 用户调 fetch(url, { cache: 'no-store' })
// → 同上

简单说:所有动态 API 在 prerender 阶段都会触发 postpone。它们等同于告诉 React:"这个位置等运行时再渲染,先继续别的分支"。

四、prerender:build / 运行时入参的双产出

prerender 来自 react-dom/staticreact-server-dom-webpack/static。它会:

  1. 启动渲染(同步部分立刻渲染)
  2. 遇到 Suspense 边界 → 渲染 fallback 到 prelude
  3. 遇到 postpone() → 把这一段当作 hole,等 resume 时再填
  4. 等所有 await 解决(不计被 postpone 的)后输出:
    • prelude:已经渲染的部分(HTML 或 RSC 片段)
    • postponed:内部 React 状态,描述"哪些 hole 等着 resume"

Next.js 把这两个东西序列化成 postponedState

108:packages/next/src/server/app-render/postponed-state.ts
export async function getDynamicHTMLPostponedState(
  postponed: ReactPostponed,
  preludeState: DynamicHTMLPreludeState,
  fallbackRouteParams: OpaqueFallbackRouteParams | null,
  resumeDataCache: PrerenderResumeDataCache | RenderResumeDataCache,
  isCacheComponentsEnabled: boolean
): Promise<string> {
  const data: DynamicHTMLPostponedState['data'] = [preludeState, postponed]
  const dataString = JSON.stringify(data)

  if (!fallbackRouteParams || fallbackRouteParams.size === 0) {
    return `${dataString.length}:${dataString}${await stringifyResumeDataCache(
      createRenderResumeDataCache(resumeDataCache),
      isCacheComponentsEnabled
    )}`
  }

  const replacements: OpaqueFallbackRouteParamEntries = Array.from(
    fallbackRouteParams.entries()
  )
  const replacementsString = JSON.stringify(replacements)

  const postponedString = `${replacementsString.length}${replacementsString}${dataString}`

  return `${postponedString.length}:${postponedString}${await stringifyResumeDataCache(resumeDataCache, isCacheComponentsEnabled)}`
}

序列化格式:

<postponedString.length>:<postponedString><stringifiedResumeDataCache>
  • postponedString 内部又是 [preludeState, postponed] 的 JSON
  • resumeDataCache 是 build 时收集的 fetch / 'use cache' 命中数据

整个字符串保存到 .next/server/app/<route>.html 旁边的 .meta 文件里,runtime 收到请求时一并 load。

五、DynamicState.HTML vs DynamicState.DATA

两种 postpone:

25:packages/next/src/server/app-render/postponed-state.ts
export enum DynamicState {
  /**
   * The dynamic access occurred during the RSC render phase.
   */
  DATA = 1,

  /**
   * The dynamic access occurred during the HTML shell render phase.
   */
  HTML = 2,
}
  • HTML:postpone 发生在 SSR (Fizz) 阶段(生成 HTML 时)。这意味着 RSC 流已经完成,但 HTML 里还有 hole——React 会在 prelude HTML 里留 <!-- $? --> 占位,运行时 resume 时再填。
  • DATA:postpone 发生在 RSC 阶段(生成 RSC payload 时)。这意味着连 RSC tree 都没完成。运行时需要 重新跑 RSC,再把它喂给 SSR 续上 prelude。

业务示例:/dashboardcookies() 读用户 ID,这是 RSC 阶段就需要的数据 → DATA postpone,整个 RSC tree 在 build 时空着,运行时才生成。/blog/[slug] 在 page 里用 draftMode() 切换内容,但 layout 和 metadata 都是静态的 → HTML postpone,prelude HTML 已经写好,运行时只补一段 RSC chunk。

六、DynamicHTMLPreludeState:Empty vs Full

68:packages/next/src/server/app-render/postponed-state.ts
export const enum DynamicHTMLPreludeState {
  Empty = 0,
  Full = 1,
}

prelude 又有两种状态:

  • Empty:build 时还没产出任何 HTML(postpone 太早,比如 root layout 就是动态的)。runtime 必须从头渲染 HTML。
  • Full:build 时输出了完整 HTML 框架,包括所有 Suspense fallback。runtime 只需要"在 hole 位置插入"。

EmptyFull 的差异决定了 runtime 走 continueDynamicHTMLResumeWeb(resume 进 prelude)还是 continueFizzStream(重新整页渲染)。在 app-render.tsx 里:

4278:packages/next/src/server/app-render/app-render.tsx
return await continueDynamicHTMLResumeWeb(htmlStream, {
  delayDataUntilFirstHtmlChunk:
    preludeState === DynamicHTMLPreludeState.Empty,
  inlinedDataStream: createWebInlinedDataStream(
    reactServerResult.consume(),
    nonce,
    formState
  ),
  getServerInsertedHTML,
  getServerInsertedMetadata,
  deploymentId: ctx.sharedContext.deploymentId,
})

delayDataUntilFirstHtmlChunk 是关键:Empty prelude 时不能立刻发 inline RSC 数据(因为 HTML 都还没开始),必须等第一段 HTML chunk 到达后再发。

七、resumeDataCache:让 fetch 在 build 时 hit、runtime 直接复用

PPR 必须解决一个问题:build 时取过的 fetch,在 runtime resume 时要保证一致。

否则会出现:

  • build 时 prerender 出 "

    iPhone 15

    "
  • runtime resume 时再 fetch 一次,返回 "iPhone 15 Pro"
  • 客户端 hydrate 时 server/client 不一致

解决方案就是 resumeDataCache。它是一个不可变的 Map:

type PrerenderResumeDataCache = ImmutableMap<string, ResumeDataEntry>;
  • 在 build 阶段:createPrerenderResumeDataCache() 创建一个空 cache,patchFetch 拦截到 fetch 时把 (url+options → response) 写入。
  • prerender 完成后:cache 序列化成字符串,跟 postponedState 一起写到磁盘。
  • runtime 读到 postponedState 时反序列化出 RenderResumeDataCache 注入到 requestStore,所有 fetch / 'use cache' 都先查这个 cache。

stringifyResumeDataCache 内部使用 next/dist/compiled/... 的 brotli 压缩 + base64 编码(看 resume-data-cache/cache.ts),保证 cache string 紧凑。

业务示例:商品详情页 build 时 prerender 取过价格 5999,cache 里存了 "/api/product/iphone-15 → 5999"。当 runtime 进入 resume,<Cart> 里调用同一个 fetch,命中 cache 立刻拿到 5999——客户端 hydrate 看到的是和 prelude HTML 完全一致的内容。

八、parsePostponedState:runtime 怎么"恢复"

175:packages/next/src/server/app-render/postponed-state.ts
export function parsePostponedState(
  state: string,
  interpolatedParams: Params,
  maxPostponedStateSizeBytes: number | undefined

伪代码逻辑:

  1. <length>: 拆出 postponedString
  2. 检查 size > maxPostponedStateSizeBytes 抛错
  3. 从 postponedString 里读出 [preludeState, postponed]
  4. 如果有 fallbackRouteParams replacements,替换 postponed 里的占位
  5. 把 stringifiedResumeDataCache 反序列化为 RenderResumeDataCache
  6. 返回结构化对象:{ type: HTML/DATA, data, renderResumeDataCache }

注意 maxPostponedStateSizeBytes 的存在:postponed state 太大(比如 /dashboard 整页都是动态的)会让磁盘 / cache 压力爆炸。生产建议显式设置 experimental.maxPostponedStateSizeBytes,超过则降级为 SSR。

九、Resume Render 的入口

回到 app-render.tsx ~3158 行:

3185:packages/next/src/server/app-render/app-render.tsx
if (typeof renderOpts.postponed === 'string') {
  if (fallbackRouteParams) {
    throw new InvariantError(
      'postponed state should not be provided when fallback params are provided'
    )
  }

  interpolatedParams = interpolateParallelRouteParams(
    renderOpts.ComponentMod.routeModule.userland.loaderTree,
    renderOpts.params ?? {},
    pagePath,
    fallbackRouteParams
  )

  postponedState = parsePostponedState(
    renderOpts.postponed,
    interpolatedParams,
    renderOpts.experimental.maxPostponedStateSizeBytes
  )
}

入口在 renderToHTMLOrFlight:base-server 在命中 ResponseCache 时若发现命中条目里包含 postponedState,就把它放到 renderOpts.postponed,再调 app-render.tsx

后续在 dual rendering 分支里看到:

if (postponedState?.type === DynamicState.DATA) {
  // RSC tree 整段动态 → 走 generateDynamicFlightRenderResult
} else if (postponedState) {
  // HTML hole 模式
  const { postponed, preludeState } = getPostponedFromState(postponedState);
  const { stream: htmlStream, allReady } = await resumeToFizzStream(
    resumeAppElement,
    postponed,
    { onError, nonce },
  );
  // continueDynamicHTMLResumeWeb 把 htmlStream 续到 prelude HTML 之后
}

resumeToFizzStream 是 react-dom 的 resumeToReadableStream/resumeToPipeableStream 的薄封装。它接受 prerender 阶段保存的 postponed React state,从 hole 处继续渲染,输出补丁式的 HTML 流。

十、PPR Bailout:什么会让 prerender 失败

postpone() 是 PPR 的"暂停"原语,但有些 API 不能 postpone——比如 redirect / notFound / params 解析失败。这些情况会触发 bailout

class StaticGenBailoutError extends Error {}

bailout 后整页降级为纯 SSR,等价于"PPR 关掉"。常见原因:

  1. redirect() 在 build 时调用:动态跳转,prerender 没办法决定目标。
  2. 某个 server component 抛非 postpone 错误:build 失败,无法生成 prelude。
  3. 使用 cookies()/headers() 但又读了它们的值在 React 渲染外:比如在 generateMetadata 里同步读 cookie。
  4. force-dynamic 显式声明export const dynamic = 'force-dynamic' 会让 PPR 直接放弃。

排障小贴士:开 experimental.isDebugDynamicAccesses = true 后,build 会打印每个 dynamic access 的 stack:

The following dynamic usage was detected:
  cookies() called by app/dashboard/page.tsx:12:5
  headers() called by app/dashboard/UserMenu.tsx:8:3

这就是 PPR 调试的关键日志。

十一、与 IncrementalCache 的协作

PPR 与第 13 讲讲过的 IncrementalCache 的关系:

  • IncrementalCache 存 "已渲染好的页面"(HTML + RSC payload + 原始 postponedState)。
  • runtime 命中 IncrementalCache 后:
    • 如果 cached value 没有 postponedState → 纯静态,直接发出去。
    • 如果有 postponedState → 进入 resume 流程,把 prelude 流出去 + 启动 resume render。
  • Resume render 完成后会触发 ISR revalidate(若到期),生成新 prelude。

这就是为什么 PPR 页面在生产看起来像"几乎每个请求都命中静态",但又能体现登录态——CDN 看到的是 prelude(可缓存),用户独有的部分由 resume 实时渲染。

十二、生产排障实战清单

现象位置排查方向
Error: Route used cookies()/headers() outside of a Suspense boundarypostpone 触发但没 boundary包一层 <Suspense fallback={<Skel />}>
Error: maxPostponedStateSizeBytes exceededpostponed state 序列化太大减少动态分支 / 拆 page,或调高阈值
build 报 StaticGenBailoutError某段代码在 build 时使用了不允许 postpone 的 APIexperimental.isDebugDynamicAccesses 找具体行
runtime hydration mismatchresume 时 fetch 没命中 resumeDataCache检查 fetch URL / options 是否在 build 期与 runtime 期一致;不要用 Date.now() 作为 search param
PPR 页面 first byte 反而比 SSR 慢runtime 加载 cache string 时间 / postponed state 解析慢NEXT_PRIVATE_DEBUG_CACHE=1 看耗时
useSearchParams() 总是返回空searchParams 是 dynamic API,必须包 Suspense给消费 useSearchParams 的 client tree 包 Suspense

十三、配套 fixture:观察 PPR 行为

fixtures/lecture-19/ 设计了几个对照场景,开启 experimental.cacheComponents = true(v15 推荐配置):

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-19
pnpm install
pnpm build
pnpm start
# 浏览 http://localhost:3019

注意 PPR 必须在生产构建后才能完整体验(dev 模式有简化路径)。fixture 的实验:

  1. /:纯静态,与 PPR 无关。
  2. /products/iphone:static prelude(商品基本信息)+ Suspense fallback(购物车,运行时 cookies())。
  3. /throw-bailout:故意在 server component 用 redirect(),build 阶段 bailout 到 SSR。
  4. /no-suspense:使用 cookies() 但没有 Suspense 包裹,build 报 PPR 错误。

build log 里会看到每个 path 的标记:

Route (app)                         Size     First Load JS
/                                 …         …
/products/[slug] (PPR)
λ /throw-bailout                    …          (SSR)

标记表示 PPR、λ 表示 SSR、 表示 SSG。

十四、本讲小结

  1. PPR 是 prerender(build)+ postpone(暂停)+ resume(运行时续上) 的三步舞,把 SSG 速度与 SSR 灵活性合并。
  2. postpone() 抛出特殊信号,标记 hole;不能 postpone 的 API 会触发 bailout
  3. postponedState 序列化 [preludeState, postponed] + resumeDataCache,build 时写盘、runtime 解析。
  4. DynamicState.HTML / DATA 区分 hole 在 SSR 阶段还是 RSC 阶段;PreludeState.Empty/Full 决定 runtime 是从头渲染还是 patch 前缀。
  5. resumeDataCache 让 build 时的 fetch 结果在 runtime 复用,避免双渲染不一致。

下讲预告

第 20 讲《动态路由与 generateStaticParams》。我们会回到 build pipeline 视角:generateStaticParams 是怎么被收集、并行执行的?fallback 模式(true / 'blocking' / false)在 App Router 下的对应是什么?interception/parallel 路由参数怎么处理?这些和 PPR 又怎么互动?