第 8 章

SSR、SSG 与 ISR:三种渲染策略的选型指南

第8章:SSR、SSG 与 ISR:三种渲染策略的选型指南

App Router 的渲染策略不是配置出来的,而是由组件的行为自动推导出来的。理解这一推导逻辑,是理解缓存与渲染系统的钥匙。

本章核心问题:App Router 如何自动决定渲染策略?SSG、ISR、SSR 分别适合什么场景?按需重新验证如何工作?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

渲染策略是每次请求级别,而非页面级别

App Router 的路由段(route segment)在每次请求时,会根据以下信号自动决定渲染策略:

信号 推导结果
调用 cookies()headers() 动态渲染(SSR)
使用 searchParams prop 动态渲染(SSR)
fetch 调用设置 no-store 动态渲染(SSR)
export const dynamic = 'force-dynamic' 强制动态渲染
无以上信号,且数据可缓存 静态渲染(SSG/ISR)
export const revalidate = N ISR(每 N 秒重新生成)

Next.js 在构建时静态分析每个路由段,如果能确定其输出与请求无关,就会静态生成。否则会在运行时动态渲染。

force-dynamic 与 force-static:强制覆盖

有时你需要显式告诉 Next.js 使用哪种策略,而不是依赖自动推导:

// app/dashboard/page.tsx
// 强制每次请求都重新渲染,即使 Next.js 认为可以缓存
export const dynamic = 'force-dynamic'

import { cookies } from 'next/headers'
import { db } from '@/lib/db'

export default async function DashboardPage() {
  const cookieStore = await cookies()
  const userId = cookieStore.get('user_id')?.value

  if (!userId) {
    redirect('/login')
  }

  const stats = await db.userStats.findUnique({ where: { userId } })

  return <Dashboard stats={stats} />
}
// app/about/page.tsx
// 强制静态生成,即使页面中有某些动态信号
export const dynamic = 'force-static'

export default function AboutPage() {
  // 即使在这里调用了某些通常触发动态渲染的 API
  // force-static 会让 Next.js 忽略这些信号
  return <div>关于我们页面(纯静态)</div>
}

dynamic 的可选值:

Level 2 · 它是怎么运行的(3-5年经验)

ISR:增量静态再生成

ISR 是 SSG 的进化形态:页面在构建时静态生成,但会在指定时间间隔后自动重新生成。在 App Router 中,通过 revalidate 导出实现:

// app/blog/[slug]/page.tsx
// 每 3600 秒(1小时)重新验证一次缓存
export const revalidate = 3600

import { db } from '@/lib/db'

interface Props {
  params: Promise<{ slug: string }>
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await db.post.findUnique({
    where: { slug },
    include: { author: true, tags: true },
  })

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>作者:{post.author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

ISR 的工作原理("Stale While Revalidate"语义):

  1. 首次请求时,如果没有缓存,动态渲染并缓存结果
  2. revalidate 时间内的后续请求直接返回缓存(stale)内容——毫秒级响应
  3. revalidate 时间过后,下一个请求触发后台重新生成
  4. 重新生成完成前,继续返回旧缓存;完成后,新内容生效

对用户来说,ISR 几乎总是返回缓存内容,性能接近纯静态;但内容会定期更新,不会永远过期。

generateStaticParams:动态路由的静态生成

对于 [slug][id] 这样的动态路由,Next.js 需要知道在构建时生成哪些路径。generateStaticParams 承担这个职责:

// app/products/[id]/page.tsx
import { db } from '@/lib/db'

// 构建时执行,告诉 Next.js 需要生成哪些路径
export async function generateStaticParams() {
  const products = await db.product.findMany({
    select: { id: true },
    where: { active: true },
  })

  return products.map(product => ({
    id: product.id,
  }))
}

// 每个已生成路径的 revalidate 时间
export const revalidate = 86400  // 24小时

interface Props {
  params: Promise<{ id: string }>
}

export default async function ProductPage({ params }: Props) {
  const { id } = await params
  const product = await db.product.findUnique({ where: { id } })

  if (!product) notFound()

  return (
    <div>
      <h1>{product.name}</h1>
      <p>价格:¥{product.price}</p>
      <p>库存:{product.stock}</p>
    </div>
  )
}

对于 generateStaticParams 未覆盖的路径(如新上架商品),Next.js 默认会在首次请求时动态渲染并缓存(按需 ISR)。可以通过 dynamicParams 控制这一行为:

// 对于未预生成的路径,返回 404 而不是动态渲染
export const dynamicParams = false

Level 3 · 规范怎么定义的(资深)

按需重新验证:revalidatePath 和 revalidateTag

ISR 的时间间隔有时太慢(等不了一小时内容才更新)。按需重新验证允许在数据变更时立即使缓存失效:

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

export async function POST(request: NextRequest) {
  // 验证 webhook 签名(安全性)
  const signature = request.headers.get('x-webhook-signature')
  if (!verifySignature(signature, await request.text())) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const body = await request.json()
  const { type, slug, tags } = body

  if (type === 'post.updated') {
    // 使特定路径的缓存失效,触发重新生成
    revalidatePath(`/blog/${slug}`)
    // 同时使博客列表页失效
    revalidatePath('/blog')
  }

  if (type === 'product.updated') {
    // 使所有带有 'products' 标签的缓存失效
    revalidateTag('products')
  }

  return NextResponse.json({ revalidated: true })
}

要使 revalidateTag 生效,需要在 fetch 调用时声明 tag:

// lib/api.ts
export async function getProducts() {
  const response = await fetch('https://api.example.com/products', {
    next: {
      tags: ['products'],  // 标记这个 fetch 的缓存属于 'products' 标签
      revalidate: 3600,
    },
  })
  return response.json()
}

export async function getProduct(id: string) {
  const response = await fetch(`https://api.example.com/products/${id}`, {
    next: {
      tags: ['products', `product-${id}`],  // 可以有多个标签
    },
  })
  return response.json()
}

当 CMS 触发 webhook 调用 revalidateTag('products') 时,所有使用了 products 标签的 fetch 缓存都会失效,相关页面在下次请求时重新生成。

渲染策略选型决策框架

不同业务场景应该使用不同策略。以下是实战中的选型指南:

电商场景

// 商品详情页 —— ISR,商品信息变化不频繁
// app/products/[id]/page.tsx
export const revalidate = 300  // 5分钟

// 商品库存/价格 —— SSR,实时性要求高
// app/products/[id]/stock/route.ts
export const dynamic = 'force-dynamic'

// 购物车 —— SSR,用户专属,不可缓存
// app/cart/page.tsx
export const dynamic = 'force-dynamic'
// + 从 cookie/session 读取购物车数据

// 营销首页 —— ISR,运营频繁更新
// app/(marketing)/page.tsx
export const revalidate = 60  // 1分钟,配合 CMS webhook 按需刷新

// 品类导航 —— SSG,极少变化
// app/(marketing)/categories/page.tsx
export const revalidate = false  // 永不自动重新验证,只通过 webhook 触发

博客/内容站场景

// 文章页 —— SSG + 按需 ISR
// app/posts/[slug]/page.tsx
export const revalidate = false  // 不自动过期
export async function generateStaticParams() {
  // 构建时生成所有已发布文章
  const posts = await getPosts({ published: true })
  return posts.map(p => ({ slug: p.slug }))
}
// 当 CMS 发布/更新文章时,通过 webhook 调用 revalidatePath

// 文章列表 —— ISR,1小时刷新
// app/posts/page.tsx
export const revalidate = 3600

// 作者主页 —— SSG,几乎不变
// app/authors/[id]/page.tsx
export const revalidate = 86400

SaaS 应用场景

// 仪表盘 —— SSR,用户实时数据
export const dynamic = 'force-dynamic'

// 设置页 —— SSR,读取用户配置
export const dynamic = 'force-dynamic'

// 公开文档 —— SSG
export const revalidate = false

// API 使用统计 —— SSR(实时),或 ISR(允许5分钟延迟)
export const revalidate = 300

fetch 级别的缓存控制

除了路由段级别的配置,Next.js 允许在单个 fetch 调用上精细控制缓存:

// app/page.tsx
export default async function HomePage() {
  // 强制不缓存(等同于 SSR 行为)
  const liveData = await fetch('https://api.example.com/live', {
    cache: 'no-store',
  })

  // 使用 ISR 缓存,60秒后重新验证
  const semiStaticData = await fetch('https://api.example.com/config', {
    next: { revalidate: 60 },
  })

  // 永久缓存,直到手动 revalidate(等同于 SSG 行为)
  const staticData = await fetch('https://api.example.com/constants', {
    cache: 'force-cache',
  })

  // ...
}

如果一个路由中有任何 cache: 'no-store' 的 fetch,整个路由会被推导为动态渲染(除非设置了 force-static)。

Level 4 · 边界与陷阱(所有人)

陷阱1:如果一个路由中有任何 cache: 'no-store' 的 fetch,整个路由会被推导为动态渲染——即使其他 fetch 设置了缓存。

陷阱2:revalidatePath 和 revalidateTag 必须在 revalidate 调用之后、redirect 调用之前执行——redirect 内部抛出特殊错误,如果放在 try-catch 中可能被意外捕获。

陷阱3:Next.js 15 将 fetch 默认行为从 force-cache 改为 no-store——升级后性能下降通常是这个原因。

小结

App Router 的渲染策略是"约定推导"而非"显式配置":通过 cookies()headers() 等动态信号的使用,或通过 dynamicrevalidate 等显式导出来控制。SSG 适合内容不变的页面;ISR 适合内容周期性更新的页面;SSR 适合实时性要求高或用户专属的页面。按需重新验证(revalidatePath / revalidateTag)将 ISR 的内容新鲜度从"时间驱动"提升为"事件驱动",是大多数内容型网站的最佳选择。

本章评分
4.8  / 5  (43 评分)

💬 留言讨论