2024.03.21
18 分钟

Next.js 增量静态再生(ISR)完整指南

理解 Next.js ISR 的工作流程、动态路由、按需重新验证与部署实践。

Next.jsISR

增量静态再生(Incremental Static Regeneration,ISR)允许 Next.js 在应用运行期间创建或更新静态页面,而不必重新构建整个网站。

它保留了静态页面响应快、服务器开销低的优势,同时允许内容按照时间或业务事件更新。因此,ISR 适合商品详情、博客文章、营销页面和文档等公开内容。

本文以 App Router 为例,介绍 ISR 的工作流程、动态路由、按需重新验证、适用场景与部署注意事项。

本文示例使用未开启 cacheComponents 的 App Router 缓存模型。如果项目在 Next.js 16 中启用了 Cache Components,应改用 'use cache'、cacheLife 和 cacheTag 描述缓存行为。

为什么需要 ISR ?

传统静态网站会在构建阶段生成所有页面。生成后的 HTML 可以直接从缓存或 CDN 返回,访问速度很快,但页面数量越多,构建所需的时间通常也越长。

假设一个电商网站拥有 100,000 个商品页面,每个页面平均需要 50ms 才能生成。仅生成这些页面就需要大约 83 分钟,而且每次修改价格或描述后重新构建整个网站,会重复大量没有必要的工作。

动态渲染可以在每次请求时读取最新数据,但也会增加数据库、接口和服务器的压力。当内容不需要精确到每一次请求都保持最新时,ISR 提供了更合适的折中方案:

  • 在构建阶段只预渲染访问量较高的页面,缩短构建时间。
  • 首次访问其他页面时按需生成,并缓存生成结果。
  • 页面过期后在后台重新生成,访问者仍可立即获得旧的可用版本。
  • CMS 或数据库发生变化时,只让相关路径或数据失效。

ISR 如何工作?

ISR 采用 stale-while-revalidate 模式。假设一个页面的重新验证时间为 60 秒,请求会按照以下流程处理:

  1. Next.js 在构建阶段或首次访问时生成页面,并保存到缓存。
  2. 60 秒内的请求直接获得缓存页面。
  3. 60 秒后到达的第一个请求仍会立即获得旧页面,同时触发后台重新生成。
  4. 新页面生成成功后会替换旧缓存,之后的请求将看到更新内容。
  5. 如果重新生成失败,Next.js 会继续提供最后一次成功生成的页面,并在后续请求中重试。

regeneration-regeneration

需要注意的是,60 秒表示页面可以开始重新验证的最短间隔,而不是一个定时任务。没有新请求到达时,Next.js 不会仅因为时间已过就主动重新生成页面。

配置基于时间的重新验证

在 App Router 的页面文件中导出 revalidate,即可设置路由的重新验证间隔。下面的商品列表最多每小时进入一次重新验证流程:

app/products/page.tsx
interface Product {
id: string;
name: string;
}
export const revalidate = 3600;
export default async function ProductsPage() {
const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const products: Product[] = await response.json();
return (
<main>
<h1>Products</h1>
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</main>
);
}

当缓存超过 3600 秒后,下一位访问者仍会获得已有页面,Next.js 则在后台生成新版本。生成成功后,后续访问者会看到最新商品列表。

重新验证间隔应根据业务能够接受的陈旧时间设置。博客列表可以使用一小时,商品库存可能只适合几分钟。如果内容必须实时准确,应考虑动态渲染,而不是将间隔缩短到一两秒。

为动态路由使用 ISR

商品详情通常使用 app/products/[id]/page.tsx 这样的动态路由。如果构建阶段生成全部商品会耗费太长时间,可以通过 generateStaticParams 只返回最常访问的商品:

app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
interface Product {
id: string;
name: string;
description: string;
}
export const revalidate = 3600;
export async function generateStaticParams() {
const response = await fetch('https://api.example.com/products/popular');
const products: Product[] = await response.json();
return products.map((product) => ({
id: product.id,
}));
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const response = await fetch(`https://api.example.com/products/${id}`);
if (response.status === 404) {
notFound();
}
if (!response.ok) {
throw new Error('Failed to fetch product');
}
const product: Product = await response.json();
return (
<main>
<h1>{product.name}</h1>
<p>{product.description}</p>
</main>
);
}

这个路由会按以下方式生成页面:

  • generateStaticParams 返回的热门商品会在 next build 期间预渲染。
  • 未返回的商品 ID 会在首次请求时按需生成,生成结果会用于后续请求。
  • 不存在的商品通过 notFound() 返回 404 页面。
  • 已缓存的商品页面会按照 revalidate 设置重新验证。

dynamicParams 默认为 true,因此未在 generateStaticParams 中列出的参数仍然可以按需生成。如果商品集合固定,并且希望未知 ID 直接返回 404,可以添加以下配置:

app/products/[id]/page.tsx
export const dynamicParams = false;

这种方式让构建时间与最重要的页面数量相关,而不是与网站的全部页面数量相关。应优先预渲染热门、可预测或对首次访问速度要求较高的页面,其余页面交给运行时按需生成。

按需重新验证

基于时间的重新验证适合允许短暂陈旧的内容,但它无法准确知道数据何时发生变化。例如,CMS 中的文章刚刚发布,或者商品价格刚刚调整,此时可以通过按需重新验证立即让相关缓存失效。

App Router 提供两种常用方式:

  • revalidatePath:使指定页面或布局路径失效。
  • revalidateTag:使带有指定标签的数据失效,可同时影响多个使用这份数据的页面。

这两个函数都只能在服务器环境中使用,例如 Server Action 或 Route Handler,不能在客户端组件或 Proxy 中调用。

使用 revalidatePath 更新指定页面

当更新操作与具体路由对应时,可以在 Server Action 中调用 revalidatePath:

app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function updateProduct(id: string, formData: FormData) {
await saveProduct(id, formData);
revalidatePath(`/products/${id}`);
}

数据保存成功后,Next.js 会让对应商品路径失效。在 Server Action 中调用时,如果用户正在查看受影响的路径,界面会立即更新;在 Route Handler 中调用时,则会先标记该路径,并在下一次访问时重新生成内容。它不会立即批量生成所有匹配页面。

如果传入的是动态路由模式,还必须说明要重新验证页面还是布局:

revalidatePath('/products/[id]', 'page');

调用 revalidatePath('/products/[id]', 'page') 会让所有匹配该页面文件的路径在后续访问时重新验证。只更新一个商品时,应传入 /products/123 这样的具体路径,避免让无关页面失效。

使用 revalidateTag 更新共享数据

同一份商品数据可能同时出现在详情页、分类页和首页。逐个列出路径容易遗漏,此时可以为请求添加缓存标签:

app/products/[id]/page.tsx
const response = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product:${id}`] },
});

数据发生变化后,使用相同标签进行重新验证:

app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function updateProduct(id: string, formData: FormData) {
await saveProduct(id, formData);
revalidateTag(`product:${id}`, 'max');
}

revalidateTag(tag, 'max') 会将相关数据标记为陈旧。下次使用这些数据时,Next.js 先返回旧内容,再在后台获取新内容。单参数形式的 revalidateTag(tag) 已被弃用,不应继续使用。

如果 Server Action 提交成功后必须让当前用户立即看到自己的修改,可以使用 updateTag 立即使标签过期。updateTag 只支持 Server Action,适合 read-your-own-writes 场景;普通内容更新仍可使用 revalidateTag 的后台刷新行为。

通过 CMS Webhook 触发重新验证

外部 CMS 无法直接调用 Server Action,可以创建 Route Handler 接收 webhook。下面的接口会验证密钥,然后让对应文章路径失效:

app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import type { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const secret = process.env.REVALIDATE_SECRET;
const authorization = request.headers.get('authorization');
if (!secret || authorization !== `Bearer ${secret}`) {
return Response.json({ message: 'Unauthorized' }, { status: 401 });
}
const body: { slug?: string } = await request.json();
if (!body.slug) {
return Response.json({ message: 'Missing slug' }, { status: 400 });
}
revalidatePath(`/posts/${body.slug}`);
return Response.json({ revalidated: true });
}

将 REVALIDATE_SECRET 保存在服务器环境变量中,并让 CMS 在内容发布后使用 Authorization 请求头调用该接口。不要把重新验证接口公开为无需认证的端点,否则任何人都可以反复使缓存失效,增加服务器负载。

如果应用配置了 rewrite,revalidatePath 必须接收实际路由文件对应的目标路径,而不是浏览器中显示的源路径。

ISR 与其他渲染方式的区别

Next.js 允许在同一个应用中组合多种渲染方式。选择方案时,应根据内容更新频率、是否依赖请求信息,以及能否接受短暂陈旧来判断。

方式生成时机适合场景主要取舍
静态生成构建阶段小型官网、很少变化的文档响应快,但更新需要重新构建
ISR构建阶段或首次访问,并在运行时重新验证商品页、博客、营销页面响应快,但可能短暂显示旧内容
动态渲染每次请求个性化页面、实时库存、权限相关内容数据新鲜,但服务器开销和 TTFB 更高
客户端请求页面到达浏览器后高交互界面、仅当前用户可见的数据交互灵活,但初始 HTML 可能没有完整内容

适合使用 ISR 的情况

以下内容通常适合使用 ISR:

  • 页面可以公开缓存,不依赖用户身份或请求 Cookie。
  • 内容需要更新,但允许短时间内显示旧版本。
  • 页面数量较多,全部预渲染会明显拖慢构建。
  • 页面访问分布不均,可以优先生成热门路径。
  • CMS 或业务系统可以通过 webhook 通知内容变化。

不适合使用 ISR 的情况

以下情况应优先考虑其他渲染方式:

  • 账户、购物车等内容因用户而异,不能共享页面缓存。
  • 金融报价、库存确认等数据必须在每次请求时保持最新。
  • 网站规模较小,完整构建只需很短时间,传统静态生成更简单。
  • 页面只能导出为纯静态文件,没有可执行 Next.js 服务器的运行环境。

错误处理

重新生成页面时,不应使用错误响应覆盖一个仍然可用的缓存版本。数据请求失败时应抛出错误,让 Next.js 保留最后一次成功生成的内容:

const response = await fetch('https://api.example.com/products');
if (!response.ok) {
throw new Error(`Failed to fetch products: ${response.status}`);
}
const products = await response.json();

后台重新验证失败后,访问者仍会获得旧页面。下一次请求到达时,Next.js 会再次尝试重新验证。这种行为优先保证了页面可用性,但也意味着应通过日志和监控及时发现持续失败的更新任务。

部署和运行限制

ISR 依赖 Next.js 服务器维护并更新缓存,因此部署时需要注意以下限制:

  • ISR 只支持 Node.js Runtime,不支持 Edge Runtime。
  • Node.js 服务器和 Docker 容器支持 ISR,output: 'export' 静态导出不支持。
  • 多个实例默认拥有各自的文件系统缓存。应配置共享缓存处理器,使缓存内容和失效事件在实例之间同步。
  • 如果一个预渲染路由包含多个不同 revalidate 时间的 fetch 请求,路由会采用其中最短的时间进行 ISR。
  • 路由中的请求使用 cache: 'no-store' 或 revalidate: 0 时,该路由会改为动态渲染。
  • 按需重新验证不会执行 Proxy 中的路径处理,因此必须传入真实目标路径。

Next.js 会自动为 ISR 页面设置适当的缓存响应头,不要手动用 Cache-Control 覆盖框架生成的策略。自托管在 CDN 后方时,还应保留 Next.js 设置的 Vary 响应头,避免 HTML 与 React Server Components 数据使用不同的缓存版本。

在本地验证 ISR

开发模式下的请求和缓存行为与生产环境不同。验证 ISR 时,应先构建应用,再启动生产服务器:

bash
bun run build
bun run start

可以在 .env 中临时启用缓存调试日志:

.env
NEXT_PRIVATE_DEBUG_CACHE=1

服务器日志会显示构建阶段生成的页面,以及运行时的缓存命中和重新生成过程。还可以查看响应中的 x-nextjs-cache 标头:

  • HIT:页面直接来自缓存。
  • STALE:返回了旧缓存,同时在后台重新验证。
  • MISS:缓存中没有页面,本次请求进行了新渲染。
  • REVALIDATED:页面通过按需重新验证完成了更新。

要点

ISR 让 Next.js 可以在构建后继续生成和更新静态页面。它通过缓存响应降低服务器压力,并使用 stale-while-revalidate 在后台更新过期内容。

使用 revalidate 可以按时间刷新路由;使用 generateStaticParams 可以只在构建阶段生成重要的动态路径;使用 revalidatePath 和 revalidateTag 可以在内容真正发生变化时精确地使缓存失效。

ISR 的重点不是让所有页面都缓存,而是为能够容忍短暂陈旧的公开内容选择合理的生成和更新策略。实时或个性化数据仍应使用动态渲染,而小型且很少变化的网站通常继续使用传统静态生成即可。

参考资料