Next.js: 渲染策略:SSR、SSG 与 ISR

最后更新:2026-08-26

Next.js 的渲染策略让你在"构建时就绪"和"请求时最新"之间自由选择——关键是选对模式。

1. 你将学到


2. 一个技术主管的真实故事

(1) 痛点:CMS 页面加载 6 秒,运营每天更新 50 次

Charlie 是 TaskFlow 团队的技术主管。公司的电商 CMS 有 10,000 个产品页面,每个包含描述、价格、库存、图片。最糟的是:

问题 数据
单页面 SSR 响应时间 6 秒(每次请求都查数据库)
运营每日更新 50+ 次(价格/库存/活动)
服务器 CPU 持续 85%+
缓存策略 ❌ 无——每次都是实时渲染

运营团队每隔 15 分钟更新一次价格,但 SSR 每次都要重新查数据库——10,000 个页面 × 6 秒 = 60,000 秒/天的 CPU 开销。

(2) ISR 的解法

用 ISR 构建时生成静态页,每隔 N 秒重新验证,按需立即刷新。

TSX
// app/products/[id]/page.tsx — ISR 增量静态再生
export async function generateStaticParams() {
  const products = await db.product.findMany({ select: { id: true } })
  return products.map(p => ({ id: String(p.id) }))
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({ where: { id: Number(params.id) } })
  return <ProductView product={product} />
}

// 路由段配置
export const revalidate = 300  // 5 分钟重新验证一次

(3) 收益

维度 纯 SSR ISR
响应时间 6 秒 < 50 ms(静态 HTML)
服务器 CPU 85% < 10%
运营更新 ❌ 必须等 6 秒 即时(按需 revalidate)
CDN 缓存 ❌ 不可 ✅ 全页面可缓存
数据库 QPS 10,000/小时 ~50/小时

3. SSR 流式渲染

SSR(Server-Side Rendering)在每次请求时在服务端渲染 HTML。Next.js 16 使用 React 18 的流式 SSR——不需要等待整个页面渲染完成,而是将页面拆分为多个 Suspense 边界,逐个发送到客户端。

100%
sequenceDiagram
    participant Client as Browser
    participant Server as Next.js Server
    participant DB as Database

    Client->>Server: GET /dashboard
    Server->>Client: 发送静态 HTML Shell(immediate)
    Server->>DB: 查询数据(并行)
    DB-->>Server: 返回部分数据
    Server->>Client: 流式发送 <Suspense> 边界 1
    DB-->>Server: 返回更多数据
    Server->>Client: 流式发送 <Suspense> 边界 2
    Client->>Client: 逐步渲染内容
配置 写法 行为
默认(SSR 流式) 无配置 页面动态渲染,支持 Suspense 流式输出
force-dynamic export const dynamic = 'force-dynamic' 强制每次请求重新渲染,禁用缓存
force-static export const dynamic = 'force-static' 强制构建时静态化,禁用动态行为

(1) dynamic = 'force-dynamic'

确保页面在每个请求时都重新生成,适合高度个性化的数据(用户仪表盘、购物车)。

TSX
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'

export default async function DashboardPage() {
  const user = await getCurrentUser()  // 每次请求都获取最新
  return <DashboardView user={user} />
}

(2) dynamic = 'force-static'

强制页面在构建时静态化,即使它包含动态函数也会被预先计算。

TSX
// app/about/page.tsx
export const dynamic = 'force-static'

export default async function AboutPage() {
  const version = await getVersion()  // 构建时计算一次
  return <div>Version: {version}</div>
}

▶ 示例:SSR 流式加载体验(难度⭐)

TSX
// app/streaming-demo/page.tsx
import { Suspense } from 'react'

export default function StreamingDemoPage() {
  return (
    <div>
      <h1>Streaming SSR Demo</h1>
      <p>This text appears immediately (static shell).</p>
      <Suspense fallback={<div>Loading slow data...</div>}>
        <SlowComponent delay={3000} />
      </Suspense>
      <Suspense fallback={<div>Loading fast data...</div>}>
        <SlowComponent delay={1000} />
      </Suspense>
    </div>
  )
}

async function SlowComponent({ delay }: { delay: number }) {
  await new Promise(resolve => setTimeout(resolve, delay))
  return <div>Loaded after {delay}ms</div>
}

4. SSG 静态站点生成与 generateStaticParams

SSG(Static Site Generation)在构建时一次性生成所有 HTML 页面,适合内容很少变化的场景(博客、文档、营销页)。

(1) 静态页面

TSX
// app/docs/page.tsx — 构建时生成
export default async function DocsPage() {
  const docs = await fetch('https://cms.example.com/docs', {
    cache: 'force-cache'  // 构建时获取,持久缓存
  }).then(r => r.json())
  return <ArticleView content={docs} />
}

(2) generateStaticParams() 动态路由预生成

用于动态路由 [id][slug] 等页面,返回所有可能的路径参数,构建时一次性生成。

TSX
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await fetch('https://cms.example.com/posts').then(r => r.json())
  return posts.map((post: any) => ({ slug: post.slug }))  // → /blog/hello-world, /blog/nextjs-guide ...
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetch(`https://cms.example.com/posts/${params.slug}`).then(r => r.json())
  return <article><h1>{post.title}</h1><div>{post.content}</div></article>
}

▶ 示例:SSG 动态路由预生成(难度⭐⭐)

TSX
// app/products/[id]/page.tsx
type Product = { id: number; title: string; price: number }

export async function generateStaticParams() {
  const products: Product[] = await fetch('https://fakestoreapi.com/products').then(r => r.json())
  return products.slice(0, 5).map(p => ({ id: String(p.id) }))
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product: Product = await fetch(`https://fakestoreapi.com/products/${params.id}`).then(r => r.json())
  return (
    <div>
      <h1>{product.title}</h1>
      <p>Price: ${product.price}</p>
    </div>
  )
}

输出:

TEXT 📖 仅展示
构建日志:
✓ Generating static pages (5/5)  /products/1  /products/2  /products/3  /products/4  /products/5

5. ISR 增量静态再生

ISR(Incremental Static Regeneration)是 SSG 和 SSR 的中间地带:构建时生成静态页面,在 revalidate 窗口过期后后台触发重新生成,用户始终看到缓存页面。

100%
graph LR
    A[构建] --> B[生成静态 HTML]
    B --> C[CDN 缓存]
    C --> D[用户请求]
    D --> E{revalidate<br/>已过期?}
    E -->|否| F[返回缓存]
    E -->|是| G[返回缓存 + 后台重新生成]
    G --> B
模式 revalidate 行为 首次访问 更新延迟
SSG 只构建时生成 即时 需重新构建
ISR revalidate: 60 60 秒后后台刷新 即时 ≤ 60 秒
SSR dynamic: 'force-dynamic' 每次请求刷新 动态生成 实时

(1) revalidate 路由段配置

TSX
// app/posts/[id]/page.tsx
export const revalidate = 3600  // 1 小时重新验证一次

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await fetch(`https://cms.example.com/posts/${params.id}`, {
    next: { revalidate: 3600 }  // 也兼容 fetch 级别
  }).then(r => r.json())
  return <PostView post={post} />
}

(2) On-Demand ISR(按需重新验证)

使用 revalidatePath()revalidateTag() 在数据变更时立即触发重新生成。

TSX
// app/admin/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(formData: FormData) {
  const id = formData.get('id') as string
  await db.post.update({ where: { id: Number(id) }, data: { title: formData.get('title') as string } })

  revalidatePath(`/posts/${id}`)     // 精确刷新单篇文章
  revalidatePath('/posts')           // 刷新列表页
  revalidateTag('posts')             // 刷新所有带 posts 标签的缓存
}

▶ 示例:ISR 时间窗口(难度⭐⭐)

TSX
// app/isr-demo/page.tsx
export const revalidate = 30  // 30 秒重新验证

export default async function IsrDemoPage() {
  const time = await fetch('http://worldtimeapi.org/api/timezone/Asia/Shanghai', {
    next: { tags: ['time'] }
  }).then(r => r.json())

  return (
    <div>
      <h1>ISR Demo — Revalidate every 30s</h1>
      <p>Current time: {time.datetime}</p>
      <p>Generated at: {new Date().toISOString()}</p>
    </div>
  )
}

▶ 示例:按需重新验证 API Route(难度⭐⭐⭐)

TSX
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.headers.get('x-revalidate-secret')
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
  }

  const body = await request.json()
  if (body.type === 'path') {
    revalidatePath(body.path)
  } else if (body.type === 'tag') {
    revalidateTag(body.tag)
  }

  return NextResponse.json({ revalidated: true })
}
BASH
# 从 CMS Webhook 调用
curl -X POST https://example.com/api/revalidate \
  -H "x-revalidate-secret: your-secret" \
  -H "Content-Type: application/json" \
  -d '{"type": "tag", "tag": "posts"}'

▶ 示例:dynamic = 'force-static' 设置静态页(难度⭐)

TSX
// app/static-page/page.tsx — 强制静态
export const dynamic = 'force-static'

export default async function StaticPage() {
  const time = new Date().toISOString()
  return (
    <div>
      <h1>Static Page (built at build time)</h1>
      <p>This timestamp is fixed: {time}</p>
      <p>Refresh the page — the time never changes.</p>
    </div>
  )
}
TSX
// app/dynamic-page/page.tsx — 强制动态
export const dynamic = 'force-dynamic'

export default async function DynamicPage() {
  const time = new Date().toISOString()
  return (
    <div>
      <h1>Dynamic Page (rendered per request)</h1>
      <p>This timestamp updates every refresh: {time}</p>
    </div>
  )
}

输出对比:

TEXT 📖 仅展示
Static page:  Timestamp always "2026-07-06T10:00:00.000Z"  ← 不变
Dynamic page: Timestamp changes every refresh               ← 每次不同

6. 完整示例:三模式博客系统

TSX
// app/blog/layout.tsx
export default function BlogLayout({ children }: { children: React.ReactNode }) {
  return <div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>{children}</div>
}

// app/blog/page.tsx — 博客列表(SSG + ISR)
export const revalidate = 300  // 5 分钟 ISR

export default async function BlogListPage() {
  const posts = await fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { tags: ['blog-posts'] }
  }).then(r => r.json())

  return (
    <div>
      <h1>Blog</h1>
      <ul>{posts.slice(0, 10).map((p: any) => (
        <li key={p.id}><a href={`/blog/${p.id}`}>{p.title}</a></li>
      ))}</ul>
      <p>Last generated: {new Date().toISOString()}</p>
    </div>
  )
}

// app/blog/[id]/page.tsx — 文章详情(SSG + ISR)
export async function generateStaticParams() {
  const posts = await fetch('https://jsonplaceholder.typicode.com/posts').then(r => r.json())
  return posts.slice(0, 10).map((p: any) => ({ id: String(p.id) }))
}

export default async function BlogPostPage({ params }: { params: { id: string } }) {
  const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, {
    next: { tags: [`post-${params.id}`] }
  }).then(r => r.json())

  const comments = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}/comments`, {
    cache: 'no-store'
  }).then(r => r.json())

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <h2>Comments ({comments.length})</h2>
      <ul>{comments.map((c: any) => (
        <li key={c.id}><strong>{c.name}:</strong> {c.body}</li>
      ))}</ul>
    </article>
  )
}

// app/blog/actions.ts — 按需刷新
'use server'
import { revalidateTag } from 'next/cache'

export async function refreshBlog() {
  revalidateTag('blog-posts')
}

// app/blog/[id]/admin/page.tsx — 管理后台(SSR)
export const dynamic = 'force-dynamic'

export default async function AdminPage({ params }: { params: { id: string } }) {
  const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`).then(r => r.json())
  return (
    <form action={async (fd) => {
      'use server'
      await fetch(`https://jsonplaceholder.typicode.com/posts/${params.id}`, { method: 'PATCH', body: JSON.stringify({ title: fd.get('title') }) })
      revalidateTag(`post-${params.id}`)
    }}>
      <input name="title" defaultValue={post.title} />
      <button type="submit">Update</button>
    </form>
  )
}

❓ 常见问题

Q SSG 和 ISR 有什么区别?
A SSG 只在构建时生成一次,更新需要重新构建。ISR 构建时生成后,在 revalidate 窗口过期时后台自动重新生成。ISR = SSG + 自动更新。ISR 更适合内容频繁更新的场景。
Q generateStaticParams 生成大量页面会导致构建时间过长吗?
A 会。10,000 个页面 × 每个 1 秒 = 10,000 秒(~2.8 小时)。解决方案:只生成热门页面(如前 100 个),未生成的页面在首次访问时按需 SSG(称为"按需 ISR")。使用 export const dynamicParams = true(默认)允许未预生成路径动态渲染。
Q Suspense 边界在 SSR 中如何工作?
A 每个 <Suspense> 边界是一个独立的流式输出单元。服务端会先发送静态 HTML shell,然后逐步发送每个 Suspense 边界的内容。用户看到的是"渐进填充"效果——不需要等待所有数据就绪。
Q force-dynamic 和 cache: 'no-store' 的区别?
A force-dynamic 是路由段配置(整个页面动态),cache: 'no-store' 是数据获取配置(单个 fetch 不缓存)。force-dynamic 会禁用该页面所有数据缓存。推荐优先使用缓存微调(如 no-store + force-cache 混合),而不是一刀切 force-dynamic。
Q revalidatePath 和 revalidateTag 哪个性能更好?
A revalidateTag 更好——它只清除匹配该标签的缓存条目,不影响其他数据。revalidatePath 需要遍历路径的所有缓存,开销更大。推荐创建有意义的标签系统,用 tags 细粒度控制。
Q ISR 的"stale while revalidate"是什么意思?
A Stale-while-revalidate 模式:用户请求时,如果缓存过期,立即返回旧的缓存页面,同时在后台触发重新生成。下一个用户就会看到新版本。这样用户永远不会等待页面生成,但数据可能有短暂延迟。

📖 小节


📝 作业

  1. 基础题(⭐):创建一个 app/ssg-demo/[id]/page.tsx,使用 generateStaticParams 预生成 /products/1/products/5 五个静态页面(数据来自 FakeStore API),构建后验证每个页面的 HTML 是静态文件。

  2. 进阶题(⭐⭐):创建一个 ISR 时间展示页面 app/isr-clock/page.tsx,设置 revalidate = 15,使用 World Time API 显示当前时间。每 15 秒刷新验证时间是否更新。添加一个 Server Action forceRefresh() 使用 revalidatePath() 立即刷新。

  3. 挑战题(⭐⭐⭐):构建一个完整的博客系统:app/blog/page.tsx(ISR 列表,revalidate 120 秒)、app/blog/[slug]/page.tsx(SEO 元数据 + JSON-LD 结构化数据)、app/api/revalidate/route.ts(Webhook 端点接收 CMS 变更通知,按 tag 刷新缓存)。提供一个示例 Webhook 调用脚本。

Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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