更新于:2026 年 5 月 28 日
Partial Prerendering(PPR,部分预渲染)是 Next.js 推出的一种新型渲染策略。简单说,它允许你在同一个页面里同时输出静态预渲染的「外壳」和动态流式渲染的内容:用户首屏看到 CDN 边缘的静态 HTML,而个性化部分则通过 React Suspense 边界异步流入。我从 pages-only 时代就开始写 Next.js,老实说在很长一段时间里,我都觉得 SSG、SSR、ISR 这三套渲染心智模型已经足够复杂了。但用过 PPR 之后我意识到,它真的解决了一个我们以前只能靠客户端骨架屏来掩饰的问题。
- PPR 在构建时预渲染页面的静态外壳(shell),并在请求时通过 React Suspense 边界流式注入动态内容,单页面同时享有 SSG 的速度和 SSR 的个性化。
- 截至 Next.js 15.3(2026 年),PPR 仍标记为实验性(experimental),需要在
next.config.ts 中显式启用 experimental.ppr。
- PPR 与 ISR 不同:ISR 整页重新生成有 TTL,PPR 每次请求都流式渲染动态片段,且无 TTL 概念。
- 启用 PPR 必须使用 App Router,并将动态逻辑(cookies、headers、searchParams、未缓存 fetch)包裹在
<Suspense> 中,否则整页会回退到完全动态渲染。
- PPR 显著降低 TTFB(首字节时间),尤其适合电商商品页、博客带评论数的文章页、Dashboard 这类「静态壳 + 动态片段」场景。
什么是 Next.js Partial Prerendering?
Partial Prerendering 是一种把构建时预渲染和请求时动态渲染合并到同一个响应里的策略。它的输出可以这样理解:当用户访问 /product/123 时,Next.js 立即从 CDN 边缘返回一个已经预渲染好的 HTML 外壳(包含导航栏、商品标题、固定描述、SEO 标签),同时为页面里那些需要「实时数据」的区域(库存数量、个性化推荐、登录用户的购物车按钮)保留占位符。这个 HTML 流并不会立刻关闭,服务端会通过 React Suspense 流式渲染 把动态片段的内容追加进来,浏览器一边接收一边补全 DOM。
这个特性最早由 Vercel 在 Next.js Conf 2023 演示,2024 年作为实验性 API 进入 14.x 系列,2026 年初的 15.3 版本仍处于 experimental.ppr 标志位之下。从用户视角看,他们感受到的是静态站点的速度,加上动态站点的鲜度,这是过去 Next.js 没有任何单一渲染模式能完成的承诺。
我在迁移一个旧 pages-router 项目时,做的第一件事就是把首页改造成 PPR。原本 SSR 的 TTFB 大约 380ms,启用 PPR 后稳定在 70ms 以内,而那些个性化的「为你推荐」卡片只比之前晚 150ms 出现。但首屏 LCP 元素已经在屏幕上了,用户的感知是瞬间。
PPR 的工作原理:静态外壳与动态洞
要理解 PPR,最好的方式是把页面想成一块「瑞士奶酪」:大部分是静态可预渲染的奶酪体,而每一个 <Suspense> 边界就是一个「洞」,洞里的内容在请求时才被填充。Next.js 在 next build 阶段会做这件事:
- 遍历组件树,识别每一个动态 API 调用(
cookies()、headers()、searchParams、未缓存的 fetch())。
- 检查这些动态调用是否被包裹在某个
<Suspense fallback={...}> 内。
- 对 Suspense 之外的部分,进行静态预渲染并写入构建产物。
- 对 Suspense 之内的部分,标记为请求时流式渲染。
请求到来时,CDN 立刻返回静态外壳(带占位符),与此同时 origin 服务器开始执行动态片段并把结果流式推送给浏览器。HTTP 响应使用 Transfer-Encoding: chunked,整个过程基于 React 18+ 的 renderToReadableStream 实现,浏览器原生支持。
这里的关键点很简单:任何未被 Suspense 包裹的动态 API 调用都会让整页退化成完全动态渲染。这是我和团队踩过最多的坑。在我们一个 Dashboard 项目中,仅仅因为在 layout 里调用了 headers() 来读取 User-Agent,整个路由的所有页面都没有静态外壳了。修复方法是把 headers() 推到一个被 Suspense 包裹的子组件里。
如何在 Next.js 15 中启用 PPR?
启用 PPR 只需要两步配置加一处页面声明。前提是你已经在使用 App Router(Pages Router 不支持 PPR)。
步骤 1:升级到 Next.js 15.x 与 React 19
npm install next@latest react@latest react-dom@latest
# 或
pnpm add next@latest react@latest react-dom@latest
步骤 2:在 next.config.ts 中启用实验性标志
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
// 'incremental' 表示按页面手动启用,推荐用于渐进式迁移
// 设为 true 则全站启用(仅推荐新项目)
ppr: 'incremental',
},
}
export default nextConfig
步骤 3:在页面中声明 PPR
使用 incremental 模式时,每个页面需要显式导出 experimental_ppr = true:
// app/product/[id]/page.tsx
import { Suspense } from 'react'
import { ProductDetails } from './product-details'
import { LiveInventory } from './live-inventory'
import { InventorySkeleton } from './inventory-skeleton'
// 启用 PPR
export const experimental_ppr = true
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<main>
{/* 静态外壳:在构建时预渲染 */}
<ProductDetails id={id} />
{/* 动态洞:请求时流式注入 */}
<Suspense fallback={<InventorySkeleton />}>
<LiveInventory id={id} />
</Suspense>
</main>
)
}
构建后你会在终端看到这样的输出,其中 ◐ 标记表示该路由已成功启用 PPR:
Route (app) Size First Load JS
◐ /product/[id] 2.1 kB 102 kB
└ ◐ (PPR)
PPR 与 ISR、SSR、SSG 有什么区别?
这是我被问得最多的一个问题。下面这张表把四种渲染模式的关键差异列清楚了,尤其注意「动态片段」和「重新验证」这两列,它们是 PPR 与 ISR 最容易混淆的地方。
| 维度 |
SSG |
ISR |
SSR |
PPR |
| 渲染时机 |
构建时 |
构建 + 后台重新验证 |
每次请求 |
构建时(壳)+ 请求时(洞) |
| TTFB |
极低(CDN) |
极低(CDN) |
较高(依赖 origin) |
极低(CDN 返回壳) |
| 个性化内容 |
不支持 |
不支持 |
支持整页 |
支持片段 |
| 重新验证 |
需重建 |
TTL 或按需 |
无 |
动态片段始终最新 |
| Suspense 必需 |
否 |
否 |
否 |
是 |
| 典型场景 |
文档站 |
博客、营销页 |
Dashboard |
电商、社交、混合页 |
简而言之,PPR ≠ ISR + Suspense。ISR 的本质是「整页缓存 + TTL 后台再生」,它不能给同一份缓存里的不同用户看不同内容。PPR 的本质是「静态壳永远复用 + 每次请求流式注入个性化片段」,它根本没有 TTL 的概念,动态部分每次都是最新的。如果你做的是博客或文档站,ISR 还是更经济的选择;如果你做的是电商或带用户态的页面,那 PPR 才是你想要的。
实战示例:电商商品页的 PPR 改造
下面是一个完整的可运行示例,演示如何把一个传统 SSR 商品页改造成 PPR。我在生产环境跑过这个模式,TTFB 从 ~350ms 降到 ~80ms。
// app/product/[slug]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
export const experimental_ppr = true
// 商品基础信息:可静态预渲染(在构建时通过 generateStaticParams 预生成)
async function ProductInfo({ slug }: { slug: string }) {
// 这个 fetch 默认会被缓存
const res = await fetch(`https://api.shop.com/products/${slug}`, {
next: { revalidate: 3600 },
})
const product = await res.json()
return (
<header>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>¥{product.price}</span>
</header>
)
}
// 动态片段 1:库存(实时)
async function StockBadge({ slug }: { slug: string }) {
const res = await fetch(`https://api.shop.com/stock/${slug}`, {
cache: 'no-store', // 标记为动态
})
const { count } = await res.json()
return <p>库存:{count} 件</p>
}
// 动态片段 2:基于登录态的推荐
async function PersonalRecs() {
const cookieStore = await cookies()
const userId = cookieStore.get('user_id')?.value
if (!userId) return <GuestRecs />
const res = await fetch(`https://api.shop.com/recs?user=${userId}`, {
cache: 'no-store',
})
const items = await res.json()
return (
<ul>
{items.map((it: { id: string; name: string }) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
)
}
function GuestRecs() {
return <p>登录后查看个性化推荐。</p>
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<article>
{/* 静态壳:构建时已预渲染 */}
<ProductInfo slug={slug} />
{/* 洞 1:库存(毫秒级流式注入) */}
<Suspense fallback={<p>加载库存…</p>}>
<StockBadge slug={slug} />
</Suspense>
{/* 洞 2:个性化推荐 */}
<Suspense fallback={<p>加载推荐…</p>}>
<PersonalRecs />
</Suspense>
</article>
)
}
export async function generateStaticParams() {
const res = await fetch('https://api.shop.com/products/top')
const top = await res.json()
return top.map((p: { slug: string }) => ({ slug: p.slug }))
}
这个例子里值得注意的几点:generateStaticParams 让前 N 个热门商品在构建时预生成;cookies() 调用被严格限制在 PersonalRecs 组件内,且该组件被 Suspense 包裹,所以静态壳不受影响;cache: 'no-store' 是触发动态渲染的明确信号。把这个改成 Server Actions 驱动的「加入购物车」 也只需要在按钮上加一个 form action,不会破坏 PPR。
PPR 常见踩坑与调试
从我自己和团队成员的经验看,PPR 失效几乎都出在以下几类问题上,按出现频率排序。
1. 在 layout.tsx 顶层调用动态 API
如果你在 app/layout.tsx 或 app/(group)/layout.tsx 顶层调用了 cookies()、headers(),那么这个布局下的所有页面都会变成完全动态渲染。修复方式:把动态逻辑抽到一个客户端组件,或者一个被 Suspense 包裹的服务端组件里。
2. 第三方库在模块顶层读取环境
有些分析或 i18n 库在导入时就调用 headers(),这会污染整个路由。我曾经被一个 sentry 中间件的初始化代码坑过半天。解决办法是把这类库的调用延迟到组件渲染期。
3. 忘了 fallback 或 fallback 不稳定
Suspense 的 fallback prop 必须返回稳定的、可在静态壳里预渲染的 JSX。不要在 fallback 里再调用动态 API,那会导致死循环式的渲染降级。
4. 与中间件冲突
如果你的 middleware.ts 改写了响应,或者注入了基于用户的 header,可能会让 CDN 无法缓存静态壳。我在 Next.js 中间件完全指南 里详细讨论过中间件与 PPR 的协同模式,简单原则是:中间件只做路由判定,不做内容改写。
什么时候应该使用 PPR?
不是所有场景都适合 PPR。下面是我在做技术评审时用的决策清单。
- 适合:电商商品/分类页、博客文章页(评论数/点赞数实时)、SaaS 着陆页带个性化 CTA、社交媒体 profile 页、新闻聚合站、文档站带「我的收藏」按钮。
- 不适合:纯静态文档站(直接 SSG 就够),整页都是个性化的 Dashboard(用 SSR 更直接),SPA 风格的应用控制台(客户端渲染配合 SWR 更轻量)。
- 看情况:营销活动页。如果有 A/B 测试和地区差异化,PPR 比 ISR 更合适;纯落地页用 SSG 即可。
另一个值得考虑的维度是部署目标。PPR 在 Vercel 上零配置工作,因为 Vercel 的 Edge Network 原生理解 PPR 的混合产物。Vercel 的 PPR 工程博客 详细解释了平台层是怎么把静态壳放在边缘、把动态片段路由到 origin 的。如果你自托管,需要确保前置的 CDN(如 Cloudflare、Fastly)正确处理 Transfer-Encoding: chunked 的混合响应。大多数现代 CDN 都支持,但要小心 buffering 设置。
最后给一句来自老 Next.js 用户的建议:不要为了 PPR 而 PPR。先用 Next.js 自带的 next build 输出和 Lighthouse 报告分析现状,确认你确实有「静态壳 + 动态片段」的真实需求,再启用。盲目把所有页面打开 experimental_ppr,反而会因为某些页面的动态 API 没包裹好,导致大面积的渲染回退,这一点 Next.js 官方 PPR 文档 也在多处反复强调。
常见问题
Partial Prerendering 在 Next.js 15 中已经稳定了吗?
截至 Next.js 15.3(2026 年 5 月),PPR 仍是实验性特性,必须在 next.config.ts 中显式启用 experimental.ppr。API 名称带 experimental_ 前缀,未来稳定后会去掉。生产环境可用,但要在升级 Next.js 时仔细阅读 changelog。
PPR 和 React Server Components 是同一个东西吗?
不是。RSC 是组件模型(决定哪些组件在服务端运行),PPR 是渲染策略(决定哪部分在构建时输出、哪部分在请求时流式注入)。PPR 依赖 RSC + Suspense 才能实现,但 RSC 在没有 PPR 的项目里也能用。
启用 PPR 后我还需要使用 ISR 吗?
视场景而定。PPR 适合「静态壳 + 动态片段」的混合页面,ISR 适合「整页缓存、定期或按需重新生成」的内容站。两者可以共存于同一应用的不同路由,并不互斥。
PPR 能在 Pages Router 中使用吗?
不能。PPR 是 App Router 独占特性,它依赖 React Server Components 和 React 18+ 的流式渲染能力。如果你还在 Pages Router 上,需要先迁移到 App Router 才能使用 PPR。
自托管 Next.js 应用如何支持 PPR?
使用 next start 自托管时 PPR 默认工作。如果前置了 CDN(Cloudflare、Fastly、Nginx),需要确保它们透传 Transfer-Encoding: chunked 并禁用响应 buffering,否则用户拿不到流式片段,体验就等同于 SSR。