Skip to content

对应源码:ReactFizzServer.js, ReactDOMFizzStaticBrowser.js

一个被浪费的渲染

你的电商产品页面有导航栏、产品标题、产品描述、评论列表、实时价格。问题:每来一个用户请求,你的服务器都把全部内容重新渲染一遍——包括导航栏和产品描述这种对每个用户都一样的内容

用户 1 请求 /products/42:
  服务器渲染 <html><nav>...</nav><h1>Product 42</h1><p>...</p>...</html>
  → 导航栏 HTML 完全一样,但每次都重渲染
  → 产品描述 HTML 完全一样,但每次都重渲染
  → 只有实时价格是用户专属的

用户 2 请求 /products/42:
  又来了 → 又全渲染一遍

这就是 React SSR 的核心矛盾:SSR 必须在服务器渲染才能返回完整 HTML,但大量内容是静态的。SSG 可以缓存静态 HTML,但无法处理动态内容。Suspense 的流式输出缓解了"全部等待"的问题,但仍然每次都要从零渲染。

React 19.2 给出了答案:把整页拆成静态部分和动态部分,静态部分提前渲染存 CDN,动态部分在请求时"续载"填充React 19.2 官方博客 称之为 Partial Pre-rendering。

API 演进:从 unstable_prerender 到稳定 API

prerender / resume API 并非 React 19.2 首创,它有一个明确的实验-稳定演进过程:

版本API 名称PR状态
React 19.1 (2025-03)unstable_prerender#31724实验性
React 19.1 (同上)resume / resumeToPipeableStream同上实验性
React 19.2 (2025-10)prerender / prerenderToNodeStreamReact 19.2 Blog稳定
React 19.2 (同上)resumeAndPrerender / resumeAndPrerenderToNodeStream同上稳定

unstable_ 前缀移除意味着 API 签名和返回值结构已稳定。当前源码中不再存在 unstable_prerender,已全部替换为 prerender

javascript
// packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js:68
function prerender(children, options) { ... }  // ← 不再是 unstable_prerender

// packages/react-dom/src/server/ReactDOMFizzServerBrowser.js:169
function resume(children, postponedState, options) { ... }

// packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js:158
function resumeAndPrerender(children, postponedState, options) { ... }

React 19.1 Changelog 中同时记录的还有流式传输出 Environments 优化(#31852),为 PPR 的 Edge 部署奠定了基础。

核心思路:prerender + resume

传统 SSR:
  每个用户请求 → 完整渲染 → 输出
  → 服务器负载高、TTFB 慢
  → 静态内容每次都重新渲染

Partial Pre-rendering:
  构建时/定时:prerender → 生成静态 shell → 存 CDN
  用户请求时:resume → 只渲染动态部分 → 流式填充
  → CDN 静态:TTFB 几毫秒
  → 服务器只需补动态部分

预渲染的输出有两部分:

  • prelude:静态 HTML shell(含导航栏、产品描述等所有不会变的内容)
  • postponed:一个序列化的状态对象(记录了哪些 Suspense 边界需要续载、渲染上下文在哪中断)
javascript
const { prelude, postponed } = await prerender(<App />, { signal: controller.signal });
await savePostponedState(postponed);
await savePreludeToCDN(prelude);

// 后续用户请求时:
const resumeStream = await resume(<App />, postponed);
resumeStream.pipe(res);  // 动态部分流式填充

与传统 SSR / SSG / RSC 的关系

方式何时生成内容个性化
CSR客户端运行 JS空 HTML + JS支持
SSR每次请求完整动态 HTML完全支持
SSG构建时完整静态 HTML不支持
ISR定时完整静态 + 重新生成部分
PPR预渲染 + 请求时续静态 shell + 动态补充支持
RSC请求时服务端组件树 + Flight 格式完全支持

PPR 的精妙之处:静态部分 CDN 加速(毫秒 TTFB),动态部分仅在请求时填充,和 React 流式渲染无缝对接

API:prerender + resume

3.1 prerender

javascript
// packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js:68-97

function prerender(children, options) {
  return new Promise((resolve, reject) => {
    function onAllReady() {
      const stream = new ReadableStream({
        type: 'bytes',
        pull: (controller) => startFlowing(request, controller),
        cancel: (reason) => {
          stopFlowing(request);
          abort(request, reason);
        },
      });
      
      const result = {
        postponed: getPostponedState(request),  // ['★ 核心:postponed state']
        prelude: stream,                         // ['★ 静态 shell 流']
      };
      resolve(result);
    }
    // ...创建 request,处理 signal...
  });
}

返回值:

  • prelude:HTML 流(含静态部分的内容)
  • postponed:可序列化的 Postponed State(用于后续 resume)

3.2 resume / resumeAndPrerender

javascript
// packages/react-dom/src/server/ReactDOMFizzStaticBrowser.js:158-216

function resumeAndPrerender(children, postponedState, options) {
  return new Promise((resolve, reject) => {
    function onAllReady() {
      const stream = new ReadableStream({...});
      const result = {
        postponed: getPostponedState(request),  // ['★ resume 后可能还有再 postpone']
        prelude: stream,                         // ['★ 完整 HTML(静态+动态)']
      };
      resolve(result);
    }
    
    const request = resumeAndPrerenderRequest(
      children,
      postponedState,           // ['★ 接收之前保存的 postponed state']
      resumeRenderState(postponedState.resumableState, undefined),
      ...
    );
    // ...处理 AbortSignal、开始 work...
  });
}

3.3 使用方式

javascript
// 步骤 1:预生成(部署前定时运行)
const { prelude, postponed } = await prerender(<App/>, {
  signal: controller.signal  // 总超时信号
});

// prelude → 保存到 CDN 或文件系统(HTML shell,含所有静态部分)
// postponed → 保存到 KV store(序列化的 PostponedState)

await savePostponedState(postponed);
await savePreludeToCDN(prelude);

// 步骤 2:用户请求时 resume
const postponedState = await getPostponedState(request);
const resumeStream = await resume(<App/>, postponed);
// 直接管道流给客户端的 HTTP Response:
resumeStream.pipe(res);

// 或者通过 resumeAndPrerender 得到完整 HTML(用于 SSG):
const { prelude } = await resumeAndPrerender(<App/>, postponed);
// 发送完整 HTML(原生 SSG)

内部机制

4.1 Postponed State 包含什么

javascript
// packages/react-server/src/ReactFizzServer.js
// Request 对象的 postponedState 字段

postponedState: null | PostponedState,
// PostponedState 包含:
//   - resumableState(可恢复状态:已写入的指令集、bootstrap、segment ID 等)
//   - rootFormatContext(根格式上下文:HTML format、是否 in select、是否 text 等)
//   - progressiveChunkSize(渐进 chunk 大小)
//   - nextSegmentId(下一个可用的 segment ID)

设计原则:postponed state 包含了从暂停位置恢复渲染所需的全部上下文。保存成本低(可序列化),恢复速度快。

4.2 什么是"postponed"部分

在 prerender 时,如果遇到需要动态数据的部分(通常是包裹在 <Suspense> 中的异步操作),React 会:

  1. 暂停那个区域的渲染(类似 Suspense 的 throw promise)
  2. 在 HTML shell 中放入一个占位符(如 <div id="B:1"></div>),但不立即流式输出 fallback
  3. 记录"这里需要 resume"。这是 PPR 与普通流式 SSR 的关键差异
javascript
// ReactFizzServer.js 内部逻辑简化
// 当 render 中遇到 Suspense + 异步数据 →
 
// 1. 普通 SSR:立即流式输出 fallback → wait → 流式替换为真实内容
//    问题:fallback 提前发送后就无法 revoke

// 2. PPR/prerender:
//    → 暂停到这里 → 不输出 fallback → 记录 postponed → 返回到 onAllReady
//    → 应用层决定何时 resume(在真正用户请求时)

// resume 时:
//    → 从 postponed 点继续 → 执行异步获取数据 → 流式填充内容

4.3 客户端水合与 PPR

PPR 输出的 HTML 包含一个特殊的 bootstrap 脚本——客户端水合时知道哪些是 prelude(预渲染部分)、哪些是 dynamic(动态部分),hydration 机制会 accordingly 处理:

服务端 prerender 输出的 HTML:
  <html>
    <head>static_meta...</head>
    <body>
      <nav>static navigation</nav>           ← prelude(静态,CDN cache)
      <main>
        <div id="P:1"><!-- PPR postpone marker --></div>  ← 动态区域占位符
      </main>
    </body>
    <script>R4>${PPR_INFO}</script>          ← PPR 恢复信息
  </html>

客户端接收到完整 HTML(prelude + resume 的动态填充):
  <html>...
    <main>
      <div id="P:1">动态内容</div>   ← resume 阶段填充
    </main>
  </html>

水合过程:
  hydrateRoot 对完整 HTML 水合
  → 注意:之前接收到的 prelude 可能是个"半产物"
  → 但 hydrateRoot 水合的是完整 HTML
  → 因此水合逻辑和普通 SSR 一致

与 Next.js Cache Components 的关系

Next.js 16 的 Cache Components 基于 React 的 prerender/resume 底层 API 构建了完整的框架级 PPR 体验。启用方式:

javascript
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,  // ★ 启用 Cache Components,PPR 成为默认行为
}

export default nextConfig

'use cache' 指令

cacheComponents: true 后获得的核心工具,作用于两个层级:

数据级缓存——缓存异步函数的返回值:

javascript
import { cacheLife, cacheTag } from 'next/cache'

export async function getProducts() {
  'use cache'
  cacheLife('hours')        // ← 缓存有效期
  cacheTag('products')      // ← 缓存标签(用于失效)
  return db.query('SELECT * FROM users')
}

UI 级缓存——缓存整页或整个组件:

javascript
export default async function Page() {
  'use cache'
  cacheLife('hours')
  const users = await db.query('SELECT * FROM users')
  return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

函数参数和闭包变量自动成为缓存键——不同参数产生不同的缓存条目,实现个性化缓存。

渲染模型:Static Shell + Dynamic Holes

组件类型行为处理方式
纯同步组件自动进入 static shell构建时完成
'use cache' 组件缓存后进入 static shell预渲染 + CDN
<Suspense> 包裹的动态组件请求时流式填充resume
未包裹 <Suspense> 的动态组件构建错误必须显式处理

Next.js 16 严格要求:访问运行时 API(如 cookies())、执行非确定性操作(如 crypto.randomUUID())的组件必须<Suspense> 包裹或标记 'use cache',否则构建时报 "Uncached data was accessed outside of <Suspense>" 错误。

缓存生命周期与失效

函数用途示例
cacheLife('hours')设置缓存有效期来自 next/cache
cacheTag('products')打标签用于批量失效来自 next/cache
updateTag('products')手动批量失效标签来自 next/cache
connection()显式标记请求时机来自 next/server
javascript
// 管理 Server Action 中手动失效缓存
async function createPost(formData) {
  'use server'
  await db.post.create({ data: { title: formData.get('title') } })
  updateTag('posts')  // ★ 失效所有带 'posts' 标签的缓存
}

React prerender/resume vs Next.js Cache Components

维度React (底层 API)Next.js (框架级)
缓存策略手动管理 prelude + postponed'use cache' 声明式
缓存失效无内建机制cacheTag + updateTag
缓存存储手动持久化默认内存 + 可扩展远端
构建校验构建时报错防遗漏
PPR手动编排默认行为
路由级拆分手动自动(每个 page.tsx 独立包)

React 的 prerender/resume 是底层 primitive,Next.js 在其上构建了框架级体验:自动 Suspense boundary(loading.js)、声明式缓存指令、构建时校验、CDN 集成。

注意事项

6.1 何时使用 prerender vs renderToPipeableStream

prerender + resume:
  → 适合:有显著静态部分的页面(blog、文档站、商品页)
  → 适合:CDN 缓存静态 shell 提升首屏
  → 不适合:全动态页面(如用户 dashboard)

renderToPipeableStream:
  → 适合:完全动态页(每个请求都不同)
  → 简单:一次调用,流式输出到 response
  → 没有静态缓存点

6.2 Node.js vs Web Streams

React 19.2 引入了 Node Streams 支持。源码中有多个入口:

javascript
// 多个入口根据环境选择
// - react-dom/src/server/react-dom-server.node.js → Node Streams 环境
// - react-dom/src/server/react-dom-server.browser.js → 浏览器/Web Stream
// - react-dom/src/server/react-dom-server.edge.js → Edge 环境(Cloudflare Workers 等)
// - react-dom/src/server/react-dom-server.bun.js → Bun 环境

React 19.2 Blog 的 Pitfall 警告:"在 Node.js 环境仍推荐 Node Streams(renderToPipeableStreamresumeToPipeableStream),因为 Node Streams 更快,且 Web Streams 不默认压缩"。

API 速查表

API用途返回
prerender (Web Stream)生成静态 shell + postponed state{ prelude, postponed }
prerenderToNodeStream (Node Streams)同上,Node 环境同上
resume (Web Stream)从 postponed state 续载到流ReadableStream
resumeToPipeableStream (Node Streams)同上,Node 环境PipeableStream
resumeAndPrerender (Web Stream)续载到完整静态 HTML{ prelude }
resumeAndPrerenderToNodeStream (Node Streams)同上 Node同上

下一步

参考资料

Released under the MIT License.