Next.js: Partial Prerendering (PPR)

最后更新:2026-08-26

PPR 是 Next.js 16 最具突破性的渲染模式——让同一个页面同时拥有静态外壳的极速响应和动态区域的实时内容。

1. 你将学到


2. 一个架构师的真实故事

(1) 痛点:Dashboard 要么全静态(过时数据),要么全动态(慢)

Diana 是 TaskFlow 团队的架构师。公司的 SaaS Dashboard 面临两难:

她想要的是:导航栏/侧边栏/布局用静态(构建时生成,CDN 缓存),用户数据/通知用动态(实时获取)。但传统的 SSG 或 SSR 只能二选一——一个页面只能有一种渲染模式。

(2) PPR 的解法

用 PPR 将 Dashboard 拆成静态外壳(Layout + Navigation)和动态区域(Suspense 边界)。

TSX
// 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) 静态外壳 + 动态边界

100%
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 中开启:

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

const nextConfig: NextConfig = {
  experimental: {
    ppr: true  // 开启 PPR
  }
}

export default nextConfig
BASH
# 安装后启动开发服务器
npm run dev
⚠️ 注意: PPR 需要 Next.js 16.2+。开启后,所有没有 dynamic = 'force-dynamic'cache: 'no-store' 的页面默认享受 PPR 优化。构建时会看到日志:✓ PPR enabled for /dashboard

▶ 示例:PPR vs 非 PPR 对比(难度⭐)

TSX
// 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) 边界规则

100%
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 包裹它——这样它就会成为静态外壳的一部分。

TSX
// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <div>
      {/* ✅ 静态:侧边栏一直在,不需要实时更新 */}
      <Sidebar />

      {/* ✅ 动态:需要实时获取 */}
      <Suspense fallback={<LoadingSpinner />}>
        <RealtimeData />
      </Suspense>

      {/* ❌ 不必要的 Suspense:这个组件没有动态数据 */}
      <Suspense fallback={<LoadingSpinner />}>
        <StaticAboutSection />  {/* 不需要包裹 */}
      </Suspense>
    </div>
  )
}

▶ 示例:多个 Suspense 边界的加载时序(难度⭐⭐)

TSX
// 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) 选型建议

100%
graph TB
    A[这个页面需要?] --> B{实时数据?}
    B -->|整个页面都是实时| C[SSR]
    B -->|部分区域实时| D[PPR]
    B -->|没有实时数据| E{更新频率?}
    E -->|频繁| F[ISR]
    E -->|几乎不变| G[SSG]

    style D fill:#d4edda

▶ 示例:PPR Dashboard 组件设计(难度⭐⭐⭐)

TSX
// 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

TSX
// 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>
  )
}

▶ 示例:构建产物验证(难度⭐)

BASH
# 构建后查看静态外壳产物
npm run build

# 在 .next/server/app/ppr-ecommerce 下查看
ls .next/server/app/ppr-ecommerce/

输出:

TEXT 📖 仅展示
page.html          ← 静态外壳 HTML(导航栏、布局)
page.rsc           ← RSC Payload(静态部分)
page_stream.html   ← 流式部分注入点

▶ 示例:PPR 静态外壳 + 动态数据验证(难度⭐⭐)

TSX
// 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>
  )
}

❓ 常见问题

Q PPR 和 Streaming SSR 有什么区别?
A SSR Streaming 是请求时才渲染,每个 Suspense 边界都是"动态"。PPR 的静态外壳是构建时生成的——用户看到的是 CDN 缓存 HTML,不是服务端实时渲染的。PPR = 静态外壳 + Streaming 动态区域。
Q 开启 PPR 后所有页面都会变成 PPR 吗?
A 是的,默认所有没有 dynamic = 'force-dynamic' 的路由都会使用 PPR。但只有那些包含 <Suspense> 边界的页面才会有"静态外壳 + 动态区域"的效果。没有 Suspense 的页面仍然是纯静态(SSG 行为)。
Q PPR 的动态区域可以嵌套吗?
A 可以。Suspense 边界可以嵌套——内层 Suspense 取决于外层。但建议保持扁平结构(一个页面 2-5 个 Suspense 边界),嵌套过深会导致加载顺序复杂。
Q PPR 静态外壳中的数据如何更新?
A 静态外壳的数据在构建时锁定。如果你在 Layout 中渲染了用户名,它是构建时的快照。解决方案:将用户特定数据放到动态 Suspense 边界中,静态壳只放不变的内容(Logo、导航链接、布局结构)。
Q PPR 能与其他渲染模式共存吗?
A 可以。同一个应用中,你可以有 PPR 页面(Dashboard)、SSG 页面(Marketing)、ISR 页面(Blog)、SSR 页面(Admin)。渲染模式是路由级别的——每个路由段独立配置。
Q PPR 在开发模式下的行为?
A 开发模式下 PPR 仍然工作,但因为每次请求都重新构建,静态外壳的优势(CDN 缓存)在本地不明显。生产构建后才能真正感受到 PPR 的提速效果。可以运行 npm run build && npm run start 测试生产模式。

📖 小节


📝 作业

  1. 基础题(⭐):在 next.config.ts 中开启 PPR,创建一个 app/ppr-basic/page.tsx,包含一个静态 <header> 和一个动态 <Suspense> 区域(调用 API 获取当前时间),验证构建产物中包含静态 HTML。

  2. 进阶题(⭐⭐):创建一个 app/ppr-dashboard/page.tsx,包含至少 3 个 Suspense 边界(用户信息、通知列表、实时统计),每个边界有独立 fallback。确保导航栏和侧边栏是静态外壳。构建后验证 page.html 包含导航栏但不包含动态内容。

  3. 挑战题(⭐⭐⭐):构建一个 PPR 多页面仪表盘——app/ppr-portal/ 下有 Overview、Orders、Analytics 三个子页面,共享一个静态 Layout(顶部导航 + 侧边栏),每个页面内有 2-4 个动态 Suspense 边界。添加 Server Actions 允许用户在动态区域内提交数据,触发 revalidateTag() 刷新对应的动态区域。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏