Next.js: Partial Prerendering (PPR)
最后更新:2026-08-26
PPR 是 Next.js 16 最具突破性的渲染模式——让同一个页面同时拥有静态外壳的极速响应和动态区域的实时内容。
1. 你将学到
- PPR 的概念:一个页面 = 静态外壳 + 动态流式内容
- 开启 PPR 的配置方法
- Suspense 边界即动态边界的核心原则
- PPR vs ISR vs SSR 的性能对比
- Dashboard 场景的 PPR 实战设计
2. 一个架构师的真实故事
(1) 痛点:Dashboard 要么全静态(过时数据),要么全动态(慢)
Diana 是 TaskFlow 团队的架构师。公司的 SaaS Dashboard 面临两难:
- SSG 方案:整个页面在构建时生成——导航栏、侧边栏、用户信息全部是过时的。"Welcome back, Alice!" 显示的是 3 天前的数据
- SSR 方案:每次请求都重新渲染——页面要 4 秒才能加载,因为数据库查询 + API 调用串行执行
她想要的是:导航栏/侧边栏/布局用静态(构建时生成,CDN 缓存),用户数据/通知用动态(实时获取)。但传统的 SSG 或 SSR 只能二选一——一个页面只能有一种渲染模式。
(2) PPR 的解法
用 PPR 将 Dashboard 拆成静态外壳(Layout + Navigation)和动态区域(Suspense 边界)。
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { NavBar } from '@/components/NavBar'
import { UserGreeting } from '@/components/UserGreeting'
import { NotificationList } from '@/components/NotificationList'
import { Skeleton } from '@/components/Skeleton'
export default function DashboardPage() {
return (
<div>
<NavBar /> {/* 静态外壳:构建时生成 */}
<Suspense fallback={<Skeleton />}> {/* 动态边界 */}
<UserGreeting />
</Suspense>
<Suspense fallback={<Skeleton />}>
<NotificationList />
</Suspense>
</div>
)
}
(3) 收益
| 维度 | 纯 SSR | 纯 SSG | PPR |
|---|---|---|---|
| 首屏时间(TTFB) | 4 秒 | 50 ms | 50 ms |
| 数据实时性 | ✅ 最新 | ❌ 构建时快照 | ✅ 动态区域实时 |
| 服务器 CPU | 85% | < 5% | < 15% |
| CDN 缓存 | ❌ 不可 | ✅ 全页面 | ✅ 静态外壳 |
| 实现复杂度 | 低 | 低 | 低(仅加 Suspense) |
3. PPR 概念与开启配置
PPR(Partial Prerendering)的核心思想是:一个页面可以同时包含预渲染的静态外壳和动态流式区域。静态部分在构建时生成,动态部分在请求时流式渲染。
(1) 静态外壳 + 动态边界
graph TB
subgraph "PPR 页面"
A[静态外壳<br/>Layout + Navigation]
B[Suspense 边界 1<br/>用户信息 - 动态]
C[Suspense 边界 2<br/>通知列表 - 动态]
D[Suspense 边界 3<br/>实时图表 - 动态]
end
A --> B
A --> C
A --> D
style A fill:#d4edda
style B fill:#cce5ff
style C fill:#cce5ff
style D fill:#cce5ff
| 组件类型 | 渲染时机 | 缓存策略 | 典型组件 |
|---|---|---|---|
| 静态外壳(Static Shell) | 构建时 | CDN 缓存 | Layout、NavBar、Footer、Sidebar、Logo |
| 动态边界(Dynamic Boundary) | 请求时 | 不缓存(或短缓存) | UserGreeting、NotificationList、实时图表、搜索 |
(2) 开启 PPR
PPR 在 Next.js 16 中默认关闭,需要在 next.config.ts 中开启:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true // 开启 PPR
}
}
export default nextConfig
# 安装后启动开发服务器
npm run dev
dynamic = 'force-dynamic' 或 cache: 'no-store' 的页面默认享受 PPR 优化。构建时会看到日志:✓ PPR enabled for /dashboard
▶ 示例:PPR vs 非 PPR 对比(难度⭐)
// app/ppr-compare/page.tsx — 开启 PPR 后自动生效
import { Suspense } from 'react'
// 静态外壳部分(构建时生成)
export default function PprComparePage() {
return (
<div>
<header style={{ background: '#f0f0f0', padding: 16 }}>
<h1>PPR Demo</h1>
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
</header>
{/* 动态边界:每次请求都重新获取 */}
<Suspense fallback={<div style={{ padding: 16 }}>Loading user...</div>}>
<RealtimeUser />
</Suspense>
{/* 静态边界:构建时已渲染 */}
<footer style={{ borderTop: '1px solid #ddd', padding: 16 }}>
<p>Built at: {new Date().toISOString()}</p>
</footer>
</div>
)
}
async function RealtimeUser() {
const user = await fetch('https://api.example.com/me', { cache: 'no-store' })
.then(r => r.json())
return <div style={{ padding: 16 }}>Welcome, {user.name}</div>
}
4. Suspense 边界即动态边界
PPR 的核心原则:每个 <Suspense> 包裹的内容都是一个动态渲染边界。没有 Suspense 包裹的部分是静态外壳,构建时预渲染。
(1) 边界规则
graph LR
A[Page 组件] --> B[静态内容<br/>无 Suspense]
A --> C[Suspense boundary]
C --> D[动态子组件<br/>每次请求重新渲染]
A --> E[另一个 Suspense]
E --> F[独立的动态区域]
| 包裹状态 | PPR 行为 | 示例 |
|---|---|---|
✅ 包裹在 <Suspense> 中 |
动态 — 请求时渲染,内容实时 | 用户信息、库存数据 |
❌ 没有 <Suspense> |
静态 — 构建时渲染,CDN 缓存 | 导航栏、页脚、Logo |
(2) 避免不必要的 Suspense
如果某个组件不需要实时数据,不要用 Suspense 包裹它——这样它就会成为静态外壳的一部分。
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
{/* ✅ 静态:侧边栏一直在,不需要实时更新 */}
<Sidebar />
{/* ✅ 动态:需要实时获取 */}
<Suspense fallback={<LoadingSpinner />}>
<RealtimeData />
</Suspense>
{/* ❌ 不必要的 Suspense:这个组件没有动态数据 */}
<Suspense fallback={<LoadingSpinner />}>
<StaticAboutSection /> {/* 不需要包裹 */}
</Suspense>
</div>
)
}
▶ 示例:多个 Suspense 边界的加载时序(难度⭐⭐)
// app/ppr-timing/page.tsx
import { Suspense } from 'react'
export default function PprTimingPage() {
return (
<div>
<h1>PPR Timing Demo</h1>
{/* 静态外壳:立即显示 */}
<p>This appears instantly (static shell)</p>
{/* 动态边界 1:2 秒后显示 */}
<Suspense fallback={<div>⏳ Loading section 1...</div>}>
<DelayedSection label="Section 1" delay={2000} />
</Suspense>
{/* 动态边界 2:4 秒后显示,独立于边界 1 */}
<Suspense fallback={<div>⏳ Loading section 2...</div>}>
<DelayedSection label="Section 2" delay={4000} />
</Suspense>
</div>
)
}
async function DelayedSection({ label, delay }: { label: string; delay: number }) {
await new Promise(resolve => setTimeout(resolve, delay))
return <div>✅ {label} loaded after {delay}ms</div>
}
5. PPR vs ISR vs SSR 性能对比
三种渲染模式各有适用场景,PPR 填补了"部分静态 + 部分动态"的空白。
| 维度 | SSR | ISR | PPR |
|---|---|---|---|
| 渲染时机 | 每次请求 | 构建 + 后台按需 | 构建(静态壳)+ 请求(动态内容) |
| 缓存策略 | 不可 CDN 缓存 | 全页面 CDN 缓存 | 静态壳 CDN + 动态区域不缓存 |
| 实时性 | ✅ 最新 | ⚠️ 最大延迟 = revalidate | ✅ 动态区域实时 |
| 服务器负载 | 高 | 低 | 低(仅渲染动态部分) |
| 适用场景 | 个性化、认证页面 | 内容站、博客 | Dashboard、混合页面 |
| 首屏时间 | 慢(等待服务端渲染) | 快(缓存) | 快(静态壳即时) |
(1) 选型建议
graph TB
A[这个页面需要?] --> B{实时数据?}
B -->|整个页面都是实时| C[SSR]
B -->|部分区域实时| D[PPR]
B -->|没有实时数据| E{更新频率?}
E -->|频繁| F[ISR]
E -->|几乎不变| G[SSG]
style D fill:#d4edda
▶ 示例:PPR Dashboard 组件设计(难度⭐⭐⭐)
// app/dashboard-ppr/page.tsx — PPR 实战设计
import { Suspense } from 'react'
// ======== 静态外壳组件 ========
function DashboardHeader() {
return (
<header style={{ background: '#1a1a2e', color: 'white', padding: '16px 24px' }}>
<h1 style={{ margin: 0 }}>TaskFlow Dashboard</h1>
</header>
)
}
function Sidebar() {
return (
<nav style={{ width: 240, background: '#f5f5f5', padding: 16, minHeight: 'calc(100vh - 64px)' }}>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><a href="/">🏠 Home</a></li>
<li><a href="/projects">📁 Projects</a></li>
<li><a href="/tasks">✅ Tasks</a></li>
<li><a href="/analytics">📊 Analytics</a></li>
<li><a href="/settings">⚙️ Settings</a></li>
</ul>
</nav>
)
}
// ======== 动态边界组件 ========
async function UserProfile() {
const user = await fetch('https://api.example.com/me', { cache: 'no-store' }).then(r => r.json())
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img src={user.avatar} alt="" style={{ borderRadius: '50%', width: 40, height: 40 }} />
<div>
<strong>{user.name}</strong>
<p style={{ margin: 0, fontSize: 12, color: '#666' }}>{user.role}</p>
</div>
</div>
)
}
async function ProjectStats() {
const stats = await fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json())
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
<StatCard label="Active Projects" value={stats.activeProjects} color="#4caf50" />
<StatCard label="Pending Tasks" value={stats.pendingTasks} color="#ff9800" />
<StatCard label="Completed" value={stats.completed} color="#2196f3" />
</div>
)
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: 16, textAlign: 'center' }}>
<p style={{ fontSize: 28, fontWeight: 'bold', color, margin: 0 }}>{value}</p>
<p style={{ margin: 0, color: '#666', fontSize: 14 }}>{label}</p>
</div>
)
}
export default function DashboardPprPage() {
return (
<div style={{ display: 'flex' }}>
<Sidebar />
<main style={{ flex: 1, padding: 24 }}>
<DashboardHeader />
<Suspense fallback={<div>Loading profile...</div>}>
<UserProfile />
</Suspense>
<div style={{ height: 24 }} />
<Suspense fallback={<div>Loading stats...</div>}>
<ProjectStats />
</Suspense>
</main>
</div>
)
}
6. 完整示例:PPR 电商 Dashboard
// app/ppr-ecommerce/page.tsx — PPR 电商仪表盘
import { Suspense } from 'react'
import { cookies } from 'next/headers'
// ======== 静态外壳 ========
function StoreHeader() {
return (
<header style={{ background: '#2c3e50', color: 'white', padding: '12px 24px', display: 'flex', justifyContent: 'space-between' }}>
<strong>Store Dashboard</strong>
<span>Built: {new Date().toISOString().split('T')[0]}</span>
</header>
)
}
function Navigation() {
return (
<nav style={{ background: '#34495e', padding: '8px 24px', display: 'flex', gap: 24, color: 'white' }}>
<a href="/ppr-ecommerce" style={{ color: 'white' }}>Overview</a>
<a href="/ppr-ecommerce/orders" style={{ color: 'white' }}>Orders</a>
<a href="/ppr-ecommerce/products" style={{ color: 'white' }}>Products</a>
</nav>
)
}
// ======== 动态组件 ========
async function LiveOrderFeed() {
const orders = await fetch('https://api.example.com/orders/recent', {
cache: 'no-store'
}).then(r => r.json())
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>Live Orders ({orders.length})</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th>Order</th><th>Customer</th><th>Status</th><th>Total</th></tr></thead>
<tbody>
{orders.map((o: any) => (
<tr key={o.id}>
<td>#{o.id}</td>
<td>{o.customer}</td>
<td><StatusBadge status={o.status} /></td>
<td>${o.total}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
pending: '#ff9800', shipped: '#2196f3', delivered: '#4caf50', cancelled: '#f44336'
}
return <span style={{ background: colors[status] ?? '#ccc', color: 'white', padding: '2px 8px', borderRadius: 12, fontSize: 12 }}>{status}</span>
}
async function RevenueWidget() {
const revenue = await fetch('https://api.example.com/revenue/today', {
next: { revalidate: 300 }
}).then(r => r.json())
return (
<div style={{ background: 'linear-gradient(135deg, #667eea, #764ba2)', color: 'white', borderRadius: 8, padding: 24 }}>
<h2 style={{ margin: 0, fontSize: 14, opacity: 0.8 }}>Today's Revenue</h2>
<p style={{ fontSize: 36, fontWeight: 'bold', margin: '8px 0' }}>${revenue.total}</p>
<p style={{ margin: 0, fontSize: 12, opacity: 0.8 }}>↑ {revenue.growth}% vs yesterday</p>
</div>
)
}
async function TopProducts() {
const products = await fetch('https://api.example.com/products/top', {
next: { tags: ['top-products'] }
}).then(r => r.json())
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>Top Products</h2>
<ol>{products.slice(0, 5).map((p: any) => (
<li key={p.id}>{p.name} — {p.sold} sold</li>
))}</ol>
</div>
)
}
// ======== 页面(PPR 混合) ========
export default function PprEcommercePage() {
return (
<div>
<StoreHeader />
<Navigation />
<main style={{ padding: 24, display: 'grid', gap: 24, gridTemplateColumns: '2fr 1fr' }}>
<div>
<Suspense fallback={<div>Loading orders...</div>}>
<LiveOrderFeed />
</Suspense>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Suspense fallback={<div>Loading revenue...</div>}>
<RevenueWidget />
</Suspense>
<Suspense fallback={<div>Loading products...</div>}>
<TopProducts />
</Suspense>
</div>
</main>
</div>
)
}
▶ 示例:构建产物验证(难度⭐)
# 构建后查看静态外壳产物
npm run build
# 在 .next/server/app/ppr-ecommerce 下查看
ls .next/server/app/ppr-ecommerce/
输出:
page.html ← 静态外壳 HTML(导航栏、布局)
page.rsc ← RSC Payload(静态部分)
page_stream.html ← 流式部分注入点
▶ 示例:PPR 静态外壳 + 动态数据验证(难度⭐⭐)
// app/ppr-verify/page.tsx
import { Suspense } from 'react'
function StaticHeader() {
return (
<header style={{ borderBottom: '2px solid #333', padding: 16, marginBottom: 16 }}>
<h1>PPR Verification Page</h1>
<p>Build timestamp: {new Date().toISOString()}</p>
<nav><a href="/">Home</a> | <a href="/ppr-verify">Refresh</a></nav>
</header>
)
}
async function DynamicContent() {
const res = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'no-store'
}).then(r => r.json())
return (
<div style={{ background: '#e3f2fd', padding: 16, borderRadius: 8 }}>
<h2>Live Server Time</h2>
<p style={{ fontSize: 24 }}>{res.datetime}</p>
</div>
)
}
export default function PprVerifyPage() {
return (
<div>
<StaticHeader />
<Suspense fallback={<div style={{ padding: 16 }}>⏳ Loading live time...</div>}>
<DynamicContent />
</Suspense>
<footer style={{ marginTop: 32, color: '#666' }}>
<p>Header is static shell (cached). Dynamic content updates per request.</p>
</footer>
</div>
)
}
❓ 常见问题
dynamic = 'force-dynamic' 的路由都会使用 PPR。但只有那些包含 <Suspense> 边界的页面才会有"静态外壳 + 动态区域"的效果。没有 Suspense 的页面仍然是纯静态(SSG 行为)。npm run build && npm run start 测试生产模式。📖 小节
- PPR 将一个页面拆分为静态外壳(构建时生成)和动态边界(请求时渲染)
<Suspense>边界即动态边界——没有 Suspense 的内容是静态外壳- 在
next.config.ts中设置experimental.ppr = true启用 PPR - PPR 比 SSR 首屏快 10-100 倍(静态外壳 CDN 缓存),动态区域保持实时
- 静态外壳适合:导航栏、侧边栏、页脚、Logo、静态文本
- 动态边界适合:用户信息、通知、实时图表、搜索、个性化内容
- PPR 是 SSG 和 SSR 的最佳中间方案,适合大部分混合内容页面
📝 作业
-
基础题(⭐):在
next.config.ts中开启 PPR,创建一个app/ppr-basic/page.tsx,包含一个静态<header>和一个动态<Suspense>区域(调用 API 获取当前时间),验证构建产物中包含静态 HTML。 -
进阶题(⭐⭐):创建一个
app/ppr-dashboard/page.tsx,包含至少 3 个 Suspense 边界(用户信息、通知列表、实时统计),每个边界有独立 fallback。确保导航栏和侧边栏是静态外壳。构建后验证page.html包含导航栏但不包含动态内容。 -
挑战题(⭐⭐⭐):构建一个 PPR 多页面仪表盘——
app/ppr-portal/下有 Overview、Orders、Analytics 三个子页面,共享一个静态 Layout(顶部导航 + 侧边栏),每个页面内有 2-4 个动态 Suspense 边界。添加 Server Actions 允许用户在动态区域内提交数据,触发revalidateTag()刷新对应的动态区域。