PPR 与 Cache Components

本站已全站开启 cacheComponents:取数默认动态,"use cache" 显式标记静态部分;静态壳预渲染进 CDN,动态洞经 Suspense 流式补出 —— 本页即是活演示

活演示:同一页里的壳与洞

刷新本页:左边(壳)时刻不变,右边(洞)时刻与随机号每次都变 —— 壳静洞动,一眼可验

静态壳("use cache" + cacheLife("hours"))

缓存生成时刻(UTC)

2026-11-09T05:18:42.385Z

随构建期预渲染冻结,进 Full Route Cache / CDN;刷新不变。

动态洞(等待请求时渲染…)

构建期渲染到第一个 Suspense 边界为止:边界之外(页头、本说明、左侧面板)是静态壳, 预渲染后可直接由 CDN 吐出;边界之内(右侧面板)是动态洞,每个请求在服务器现渲染后流式补出。 洞的 1.2s 人为延迟让你能在慢网节流下看清 fallback 被替换的瞬间。

PPR 在 Next 16 里变成了 Cache Components

experimental.ppr 已移除,开关移到 next.config 顶层;本站已全站开启

  • PPR(Partial Prerendering)的目标:同一页面里静态壳走 CDN 预渲染,动态洞在请求时流式补出,兼得 SSG 的 TTFB 与 SSR 的实时性
  • Next 16 起它不再是一个独立实验开关,而是并入 Cache Components 模型:PPR 成为「缓存声明 + Suspense 边界」的自然结果
  • 它是项目级开关:一开全站生效,无法只对单个专题页启用 —— 这正是本站把全站迁完才把它做成活演示的原因
// next.config.ts —— 本站正在使用的真实配置
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // Next 16:experimental.ppr 已移除,
  // PPR 并入 Cache Components 模型
  cacheComponents: true,
};

export default nextConfig;

模型反转:默认动态,显式缓存

与旧模型「默认静态、force-dynamic 退出」的心智正好相反

  • 开启 cacheComponents 后,所有取数默认动态(请求时渲染),不再有「忘记加 no-store 就被缓存」的隐式行为
  • 想缓存的组件/函数用"use cache"指令显式标记,配合cacheLife声明新鲜度、cacheTag声明失效标签
  • 未缓存的请求时数据必须包在 Suspense 边界里,否则构建期直接报错 —— 边界不是可选项,而是编译契约
// topics/rendering/ppr/index.tsx —— 本页演示面板的真实写法(节选)
import { cacheLife, cacheTag } from "next/cache";

// 静态壳:"use cache" 显式标记;cacheLife/cacheTag 声明新鲜度与失效标签
async function CachedShellPanel() {
  "use cache";
  cacheLife("hours");
  cacheTag("ppr-shell");
  const cachedAt = new Date().toISOString();  // 随缓存冻结
  return <ShellPanel cachedAt={cachedAt} />;
}

// 动态洞:不带指令的 async 组件,默认动态,每次请求现渲染
async function DynamicHole() {
  await sleep(1200);                          // 未缓存的异步工作
  return <Hole at={new Date()} nonce={crypto.randomUUID()} />;
}

// 页面组装:洞必须包在 Suspense 里,否则构建报错
// (Uncached data was accessed outside of <Suspense>)
<Suspense fallback={<HoleSkeleton />}>
  <DynamicHole />
</Suspense>

从 route segment config 迁移过来

旧配置一行 vs 新模型写法的逐条对应;本站所有路由已按此迁移

// 旧模型(Next 15 及以前,route segment config)  →  本站现行(cacheComponents)
export const revalidate = 60;                 →  "use cache" + cacheLife({ revalidate: 60 })
export const dynamic = "force-dynamic";       →  什么都不写(默认动态);整页都动态可用 connection()
export const dynamic = "force-static";        →  "use cache"(显式声明可缓存)
export const fetchCache = "...";              →  删除:fetch 缓存由 "use cache" 的作用域决定
revalidateTag("posts")                        →  不变,标签改由 cacheTag("posts") 打在缓存条目上
  • 构建输出的路由表是验收工具:○ 为完全静态(含「壳 + 缓存数据」), ◐ 为 Partial Prerender(壳预渲染 + 动态洞请求时流式补出), ƒ 为每请求动态;Revalidate / Expire 两列直接展示各路由的缓存窗口
  • 对照本站 build 输出:本页、/rendering/streaming、/rendering/ssr 都是 ◐ (壳静态、内容经 Suspense 洞每请求补出); /rendering/isr 是 ○ 且 Revalidate 列为 1m(数据整份进了 "use cache"); ƒ 只剩 /api/* 的 Route Handler
  • 旧模型的 ISR 语义由 cacheLife 完整承接:60s 窗口、stale-while-revalidate、 后台重建,在 /rendering/isr 可逐点对照观察

什么时候别用 PPR

决策要点,而非 API 背诵

  • 整页都动态(个性化信息流、实时仪表盘):没有静态壳可抽,PPR 退化为普通 Streaming SSR,白添缓存声明成本
  • 整页都静态(文档、营销页):SSG/"use cache" 本来就够快,动态洞是多余的复杂度
  • 团队还没有统一的缓存失效纪律:显式缓存意味着每个"use cache"都要想清 cacheLife 与 cacheTag,粗放使用会积累 stale 事故