- 发布日期
第 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()、prelude、resumeDataCache、postponedState序列化与解析。
学习目标
读完本讲,你能:
- 解释 prerender / postpone / resume 三阶段在 PPR 里的角色,以及它们和"普通 SSG"、"动态 SSR"的边界。
- 看懂
postponedState的序列化格式和它如何在 build 与 request 之间传递。 - 理解
DynamicState.HTML与DynamicState.DATA两种 postpone 类型的区别。 - 解释
prerenderResumeDataCache/renderResumeDataCache的作用,以及它为什么必须 immutable。 - 在生产排障时定位 "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 路由都启用 PPRppr: '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)。
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/static 和 react-server-dom-webpack/static。它会:
- 启动渲染(同步部分立刻渲染)
- 遇到
Suspense边界 → 渲染 fallback 到 prelude - 遇到
postpone()→ 把这一段当作 hole,等 resume 时再填 - 等所有 await 解决(不计被 postpone 的)后输出:
prelude:已经渲染的部分(HTML 或 RSC 片段)postponed:内部 React 状态,描述"哪些 hole 等着 resume"
Next.js 把这两个东西序列化成 postponedState:
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]的 JSONresumeDataCache是 build 时收集的 fetch /'use cache'命中数据
整个字符串保存到 .next/server/app/<route>.html 旁边的 .meta 文件里,runtime 收到请求时一并 load。
五、DynamicState.HTML vs DynamicState.DATA
两种 postpone:
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。
业务示例:
/dashboard用cookies()读用户 ID,这是 RSC 阶段就需要的数据 → DATA postpone,整个 RSC tree 在 build 时空着,运行时才生成。/blog/[slug]在 page 里用draftMode()切换内容,但 layout 和 metadata 都是静态的 → HTML postpone,prelude HTML 已经写好,运行时只补一段 RSC chunk。
六、DynamicHTMLPreludeState:Empty vs Full
export const enum DynamicHTMLPreludeState {
Empty = 0,
Full = 1,
}
prelude 又有两种状态:
- Empty:build 时还没产出任何 HTML(postpone 太早,比如 root layout 就是动态的)。runtime 必须从头渲染 HTML。
- Full:build 时输出了完整 HTML 框架,包括所有 Suspense fallback。runtime 只需要"在 hole 位置插入"。
Empty 与 Full 的差异决定了 runtime 走 continueDynamicHTMLResumeWeb(resume 进 prelude)还是 continueFizzStream(重新整页渲染)。在 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 怎么"恢复"
export function parsePostponedState(
state: string,
interpolatedParams: Params,
maxPostponedStateSizeBytes: number | undefined
伪代码逻辑:
- 读
<length>:拆出 postponedString - 检查 size > maxPostponedStateSizeBytes 抛错
- 从 postponedString 里读出
[preludeState, postponed] - 如果有 fallbackRouteParams replacements,替换 postponed 里的占位
- 把 stringifiedResumeDataCache 反序列化为 RenderResumeDataCache
- 返回结构化对象:
{ type: HTML/DATA, data, renderResumeDataCache }
注意 maxPostponedStateSizeBytes 的存在:postponed state 太大(比如 /dashboard 整页都是动态的)会让磁盘 / cache 压力爆炸。生产建议显式设置 experimental.maxPostponedStateSizeBytes,超过则降级为 SSR。
九、Resume Render 的入口
回到 app-render.tsx ~3158 行:
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 关掉"。常见原因:
redirect()在 build 时调用:动态跳转,prerender 没办法决定目标。- 某个 server component 抛非 postpone 错误:build 失败,无法生成 prelude。
- 使用
cookies()/headers()但又读了它们的值在 React 渲染外:比如在generateMetadata里同步读 cookie。 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 boundary | postpone 触发但没 boundary | 包一层 <Suspense fallback={<Skel />}> |
Error: maxPostponedStateSizeBytes exceeded | postponed state 序列化太大 | 减少动态分支 / 拆 page,或调高阈值 |
build 报 StaticGenBailoutError | 某段代码在 build 时使用了不允许 postpone 的 API | 用 experimental.isDebugDynamicAccesses 找具体行 |
| runtime hydration mismatch | resume 时 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 的实验:
/:纯静态,与 PPR 无关。/products/iphone:static prelude(商品基本信息)+ Suspense fallback(购物车,运行时 cookies())。/throw-bailout:故意在 server component 用redirect(),build 阶段 bailout 到 SSR。/no-suspense:使用 cookies() 但没有 Suspense 包裹,build 报 PPR 错误。
build log 里会看到每个 path 的标记:
Route (app) Size First Load JS
○ / … …
◐ /products/[slug] … … (PPR)
λ /throw-bailout … … (SSR)
◐ 标记表示 PPR、λ 表示 SSR、○ 表示 SSG。
十四、本讲小结
- PPR 是 prerender(build)+ postpone(暂停)+ resume(运行时续上) 的三步舞,把 SSG 速度与 SSR 灵活性合并。
postpone()抛出特殊信号,标记 hole;不能 postpone 的 API 会触发 bailout。postponedState序列化[preludeState, postponed] + resumeDataCache,build 时写盘、runtime 解析。DynamicState.HTML/DATA区分 hole 在 SSR 阶段还是 RSC 阶段;PreludeState.Empty/Full决定 runtime 是从头渲染还是 patch 前缀。resumeDataCache让 build 时的 fetch 结果在 runtime 复用,避免双渲染不一致。
下讲预告
第 20 讲《动态路由与 generateStaticParams》。我们会回到 build pipeline 视角:generateStaticParams 是怎么被收集、并行执行的?fallback 模式(true / 'blocking' / false)在 App Router 下的对应是什么?interception/parallel 路由参数怎么处理?这些和 PPR 又怎么互动?