ISR Skills
网页缓存链路一追到底
ISR是什么
ISR(Incremental Static Regeneration)可以理解为:
- 页面先按静态内容产出
- 内容在一段时间内直接命中缓存
- 超过重验证时间后, 由后续请求触发后台重建
- 重建成功后, 新内容替换旧缓存
它不是"每次请求都重新渲染", 也不是"永远不变的纯静态文件", 而是介于两者之间的页面缓存策略。
适合 ISR 的页面通常具备两个特征:
- 首屏 HTML 对所有访问者应当一致
- 内容会更新, 但不要求每次请求都实时重新计算
常见例子:
常见 ISR 页面
| 页面类型 | 说明 |
|---|---|
| 首页 | 公共站点首页、品牌页、专题首页 |
| 博客列表页 | 文章索引、分类列表、时间归档 |
| 文章详情页 | 公共内容详情页 |
| 文档页 | 产品文档、教程、说明页 |
| 定价页 | 公共套餐和价格说明页 |
| 法律页面 | Privacy、Terms、DPA 等公共页面 |
ISR过期与重建机制
当页面声明了 revalidate = 3600:
ISR 生命周期
| 阶段 | 行为 |
|---|---|
| 3600 秒内 | 返回缓存内容 |
| 超过 3600 秒后的下一次请求 | 通常先返回旧内容 |
| 同时 | 平台在后台触发重建 |
| 重建成功后 | 新页面替换旧缓存 |
| 重建失败后 | 继续使用旧缓存 |
这本质上是 stale-while-revalidate 思路。
因此, ISR 的"过期"不是立即不可访问, 而是进入"允许返回旧内容, 同时后台刷新"的状态。
根Layout配置ISR
如果一个站点的根 layout 想支持 ISR, 核心要求不是"必须没有 await", 而是:
根 Layout 走 ISR 的核心要求
| 要求 | 说明 |
|---|---|
| 不能依赖当前用户态输出首屏 HTML | 首屏内容应对所有访问者一致 |
不能直接读取 cookies()、headers()、draftMode() | 这些都属于请求级 API |
| 不能按当前登录用户查库并输出不同 HTML | 会破坏静态复用前提 |
| 不能显式关闭缓存 | 例如 force-dynamic、revalidate = 0、no-store |
允许存在的内容通常包括:
根 Layout 中允许存在的内容
| 内容类型 | 说明 |
|---|---|
| 固定翻译文案 | 本地静态消息、固定词条 |
| 本地 JSON / MDX / 配置文件读取 | 公共内容加载 |
| 公共 SEO 信息生成 | 与当前用户无关的 metadata |
| 静态导航、静态列表、公共内容拼装 | 公共首屏 UI |
| 服务端组件包裹客户端组件 | 仅传递公共数据时可接受 |
一个常见误区是:
await不等于动态渲染- 读取固定翻译不等于动态渲染
决定是否能 ISR 的关键, 不是有没有异步, 而是首屏 HTML 是否依赖"当前这次请求"的实时私有状态。
可以用一条总原则快速判断:
- 如果 request-time 信息参与了服务端首屏 HTML 生成, 页面通常应当动态
- 如果 request-time 信息只参与客户端交互或后续 API 请求, 页面仍然可以静态或 ISR
这里的 request-time 信息通常包括:
常见 Request-time 信息
| 类型 | 说明 |
|---|---|
| 无法预先穷举的动态路径参数 | 构建期无法完整枚举 |
服务端读取的 searchParams | 查询参数参与首屏渲染 |
| 请求头 | 例如地区、UA、语言协商等 |
| Cookie | 登录态、实验开关、会话等 |
| 服务端鉴权上下文 | auth()、currentUser() 等 |
| 当前用户私有数据 | 会话、订阅、积分、账户状态等 |
但需要进一步区分:
常见例外
| 情况 | 是否必须动态 | 说明 |
|---|---|---|
可通过 generateStaticParams 穷举的动态路径参数 | 否 | 仍可静态化 |
| 仅用于客户端交互的 URL 查询参数 | 否 | 可放到客户端处理 |
| 仅在客户端处理的登录态和私有信息 | 否 | 不必进入服务端首屏 |
还要注意一个常见误区:
- 不只是
layout自己不能直接读取headers()/cookies() - 只要当前静态渲染链路中的任意 Server Component、服务函数、聚合层、鉴权层间接读取了请求级 API, 整个静态链路都会失效
这类冲突的本质通常是:
- 路由段已经声明为静态或 ISR
- 但首屏渲染树中又包含了"按当前用户决定输出内容"的服务端逻辑
一旦首页、Hero、导航、会话列表、用户摘要、个性化推荐等首屏服务端组件在渲染期间读取请求头、Cookie 或服务端鉴权上下文, 就不再满足 ISR 的前提。
因此, 判断一个页面能否 ISR 时, 不能只看 layout.tsx 或 page.tsx 表层代码, 还要看它依赖的服务端组件和服务函数是否间接触碰了请求级状态。
根Layout启用ISR
根 layout 一般需要两部分:
- 为动态段提供静态参数
- 声明重验证时间
最常见的是 locale 路由:
export const revalidate = 3600;
export function generateStaticParams() {
return appConfig.i18n.locales.map((locale) => ({ locale }));
}含义:
generateStaticParams告诉框架要预生成哪些locale路径revalidate告诉框架这棵静态路由树默认多久重建一次
如果没有 generateStaticParams, 动态段通常无法在构建期完整静态产出。
Segment Config写法限制
revalidate、dynamic、runtime 等 segment config 建议始终使用可静态分析的字面量。
推荐写法:
export const revalidate = 604800;
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';不推荐写法:
export const revalidate = 7 * 24 * 3600;原因不是语义错误, 而是框架在构建时需要稳定地静态分析这些配置。
使用表达式、变量引用或运行时计算值, 可能导致 segment config 无法被正确识别。
常用时间常量可以直接写成字面量:
常用时间常量
| 时长 | 秒数 |
|---|---|
| 1 day | 86400 |
| 7 days | 604800 |
| 10 days | 864000 |
| 30 days | 2592000 |
子页面覆盖ISR配置
layout 上的 revalidate 可以为整棵子路由树提供默认值。
子页面可以单独覆盖:
子页面覆盖策略
| 场景 | 建议 |
|---|---|
| 页面更新更频繁 | 设置更小的 revalidate |
| 页面几乎不变 | 设置更大的 revalidate |
| 页面必须实时 | 改为动态 |
常见规则:
- 父级
layout适合定义站点级默认缓存策略 - 单个
page适合定义更细的页面级缓存策略 - 真正依赖实时用户态的页面, 不应该继续复用 ISR
如果一个页面需要根据当前请求实时变化, 应直接改为动态, 而不是继续依赖父级 ISR。
force-dynamic的适用场景
force-dynamic 的作用是:
- 明确告诉框架该路由不要做静态优化
- 每次请求都走服务端执行路径
适合的场景:
`force-dynamic` 适用场景
| 场景 | 说明 |
|---|---|
| 首屏 HTML 依赖当前登录用户 | 页面输出按用户变化 |
服务端直接读取 auth()、currentUser()、cookies()、headers() | 请求级 API 进入首屏渲染 |
| 服务端查询当前用户数据并直接渲染 | HTML 无法安全复用 |
| 页面必须每次请求实时返回最新结果 | 不接受缓存窗口 |
| 调试页、控制台页、实时状态页 | 更适合动态渲染 |
不适合的场景:
`force-dynamic` 不适合的场景
| 场景 | 说明 |
|---|---|
| 页面主体是公共内容 | 首屏可对所有用户一致 |
| 用户态只在客户端处理 | 不参与服务端首屏 HTML |
| 登录后客户端再请求 API 拉取积分、订阅、账户信息 | 私有数据不进入静态首屏 |
一个常见正确模式是:
- 页面主体走 ISR
- 登录状态由客户端组件处理
- 用户私有数据由客户端调用 API 获取
这种模式既保留 SEO, 又避免缓存污染。
如果首屏必须包含用户相关内容, 通常有三种处理方向:
- 把用户态逻辑移到客户端, 在页面加载后再请求 API
- 保留服务端鉴权, 但把当前页面或当前路由段改为动态渲染
- 把用户态部分拆到单独的动态子树, 不让它落在当前静态首屏路径中
核心原则只有一个:
- ISR 首屏只负责公共内容
- 用户态、会话态、请求态不要混入静态首屏渲染链路
避免ISR缓存污染
ISR 页面必须满足一个原则:
- 缓存结果应当可以安全地返回给任意访问者
因此不要把以下内容直接做进 ISR 首屏 HTML:
不要放进 ISR 首屏 HTML 的内容
| 内容 | 风险 |
|---|---|
| 当前用户积分 | 用户 A 的结果可能被复用给用户 B |
| 当前用户订阅状态 | 首屏内容按用户变化 |
| 当前用户名称、邮箱、组织信息 | 私有信息泄漏风险 |
| 基于 Cookie 或 Session 的个性化结果 | 破坏公共缓存前提 |
推荐做法:
- 首屏输出公共静态页面
- 登录态用客户端 SDK 处理
- 私有数据通过客户端请求 API 获取
如果页面本身必须在首屏就按用户输出不同内容, 就不应使用 ISR。
发布后ISR行为
重新部署后, 应从两个层面理解:
- 新代码和新构建产物已经切换到新 deployment
- 该 deployment 下的静态页面与 ISR 页面继续按新的规则提供内容
通常:
- 构建期已预生成的路径, 会直接带着新版本上线
- 非预构建但可 ISR 的路径, 会在新 deployment 的首次命中时再生成
因此:
- 不是所有 ISR 页面都必须在部署时一次性全部重建
- 也不应该把 ISR 理解成"永远沿用上一个部署的旧页面"
Page与API路由差别
Page路由
page.tsx / layout.tsx 负责页面渲染, 关注的是:
- HTML / RSC 输出
- SEO
- 页面缓存策略
- 静态、ISR、动态渲染
它们的缓存问题, 核心在于:
- 首屏 HTML 是否公共
- 是否可以预渲染
- 是否应该重验证
API路由
route.ts 负责接口处理, 关注的是:
- 请求处理
- 鉴权
- 数据查询
- 流式响应
- Webhook
API 路由通常天然更偏动态, 尤其是:
POST- 用户鉴权接口
- 支付接口
- Webhook
- AI 流式接口
因此, 页面的 ISR 策略和 API 的动态请求处理, 通常应当分开设计。
一句话概括:
- Page 路由关注"页面能否缓存"
- API 路由关注"请求是否需要实时执行"
Runtime类型适用场景
Edge Runtime
- 轻量逻辑
- 低延迟响应
- 请求转发
- 鉴权校验
- AI 流式代理
- 纯 Web API 能完成的逻辑
典型特征:
- 依赖
fetch、Request、Response、ReadableStream - 不依赖 Node.js 专属 API
- 不依赖本地文件系统
- 不依赖重型数据库客户端
Node.js Runtime
- 数据库访问
- Prisma / 原生数据库驱动
- 本地文件系统操作
- 较重的服务端 SDK
- 复杂服务层逻辑
- 需要 Node.js 能力的后端代码
典型特征:
- 依赖 Node 运行时能力
- 有更重的服务端依赖
- 需要与数据库、文件、服务容器深度集成
可以用一句话判断:
- 能否只靠标准 Web Runtime 完成逻辑
如果可以, 优先考虑 edge。
如果不可以, 使用 nodejs。
常用决策原则
页面是否适合ISR
- 首屏内容是公共内容: 适合 ISR
- 首屏内容按用户变化: 不适合 ISR
是否需要force-dynamic
- 请求期必须实时计算: 需要
- 页面主体是公共静态内容: 不需要
用户私有数据
- 不放进 ISR 首屏 HTML
- 放到客户端请求 API 或动态页面中处理
Runtime如何选择
- 纯 Web、轻量、边缘友好:
edge - 依赖 DB、文件、Node 能力:
nodejs
推荐实践
- 根
layout提供统一generateStaticParams - 根
layout提供默认revalidate - 页面主体尽量公共化, 优先 ISR
- 个性化内容通过客户端 + API 注入
- 只有真正需要请求期首屏个性化时, 才使用
force-dynamic - AI、支付、Webhook、用户私有接口默认按动态 API 设计
- Edge 只承担边缘友好的轻量逻辑
- 数据库和复杂服务端依赖放在 Node.js Runtime
在Vercel上验证ISR
不要主要依赖页面访问速度来判断 ISR 是否生效。
原因很简单:
- 网络延迟会干扰判断
- 首次访问、跨区域访问、CDN 预热状态都会影响体感速度
- 页面快不一定是 ISR, 页面慢也不一定说明 ISR 失效
更可靠的判断方式是查看响应头, 尤其是:
x-vercel-cachecache-control
x-vercel-cache典型含义
`x-vercel-cache` 常见状态
| 状态 | 含义 |
|---|---|
PRERENDER | 响应来自预渲染静态产物 |
HIT | 命中缓存 |
MISS | 没命中缓存,发生回源 |
STALE | 返回旧缓存,同时后台触发重建 |
REVALIDATED | 缓存被重新验证后返回 |
对 ISR 页面, 最关键的观察点通常是:
- 首次部署后访问, 可能看到
PRERENDER、MISS或首次冷启动状态 - 后续访问, 通常应逐步出现
HIT - 超过
revalidate时间后再次访问, 可能出现STALE - 再后续访问, 通常恢复为
HIT
为什么不要只看存储大小
在平台观测里看到缓存读写量或缓存存储大小, 并不能直接证明页面 ISR 是否已经生效。
因为通常需要区分两类缓存:
- 页面级静态 / ISR 缓存
- 运行时数据缓存
它们不是同一层。
因此:
- 页面是否命中 ISR, 优先看页面响应头
- 运行时缓存大小、命中率, 只能辅助判断系统整体缓存行为
实际判断原则
- 页面已经构建为静态或 ISR, 只说明"具备缓存资格"
- 页面请求返回
x-vercel-cache的命中或重验证状态, 才说明"缓存正在实际工作"
一句话概括:
- Build 输出决定页面是否可缓存
- 响应头决定当前请求是否真的命中了缓存
本地验证常见问题
在本地或自托管环境中, 即使 ISR 已经命中, 也可能看到这样的响应头特征:
x-nextjs-cache: HITx-nextjs-prerender: 1Cache-Control: no-store, must-revalidate
这并不矛盾。
应当这样理解:
x-nextjs-cache表示 Next.js 自己的页面缓存是否命中x-nextjs-prerender表示该页面属于预渲染产物Cache-Control表示发给浏览器或外层 HTTP 缓存层的缓存策略
也就是说:
- Next.js 内部页面缓存可以命中
- 浏览器仍然可以被要求不要缓存当前 HTML 响应
因此, 本地看到 no-store, 并不等于 ISR 没生效。
只要 x-nextjs-cache: HIT 存在, 就说明服务端侧的页面缓存已经在工作。
本地ISR命中的真实收益
本地 ISR 命中带来的收益, 主要不是浏览器直接复用 HTML, 而是:
- 服务器不再重复生成页面
- 服务端不再重复执行页面查询和渲染逻辑
- 请求回到服务器后, 可以直接返回已生成的页面结果
所以本地 ISR 的提速点主要在:
- 降低服务端重复渲染成本
- 缩短服务端生成页面所需时间
它不一定直接消除:
- 浏览器到服务器的网络往返时间
- middleware 或 rewrite 的处理开销
- 浏览器端本地缓存缺失带来的重复请求
本地和线上观察重点
本地与线上观察重点
| 环境 | 优先观察 |
|---|---|
| 本地或自托管 Next 进程内部 | x-nextjs-cache、x-nextjs-prerender |
| Vercel 线上 | x-vercel-cache |
可以把它们理解成两层:
- 本地或 Next 进程内部: 关注页面是否命中 Next.js 自己的缓存
- Vercel 线上: 关注页面是否进一步命中边缘缓存
缓存分层模型
理解 ISR 时, 最好不要把“缓存”当成单层概念。
在一个典型的页面请求链路中, 至少可以粗略分成三层:
三层缓存模型
| 层级 | 典型对象 | 主要作用 |
|---|---|---|
| 应用层缓存 | ISR、Full Route Cache、Data Cache | 避免服务器重复生成页面 |
| 中间层缓存 | CDN、反向代理、边缘缓存 | 减少回源 |
| 浏览器缓存 | 浏览器本地 HTTP 缓存 | 减少客户端重复请求 |
应用层缓存
应用层缓存主要指框架内部缓存, 例如:
- ISR / Full Route Cache
- Data Cache
- 预渲染产物缓存
这一层的价值在于:
- 页面不必每次请求都重新渲染
- 服务端不必每次都重新执行查询与拼装
- 即使浏览器不缓存, 应用侧仍然可以明显减少重复工作
中间层缓存
中间层可以理解为:
- CDN
- 反向代理
- 边缘缓存层
这一层主要受 HTTP 缓存头影响, 例如:
Cache-ControlVary
它决定的是:
- 是否需要回源到应用
- 某个响应是否可以在边缘节点复用
浏览器缓存
浏览器缓存是客户端本地缓存层。
它同样会参考 HTTP 响应头, 但与应用层缓存不是同一回事。
因此一个页面完全可能同时满足:
- 浏览器不缓存当前 HTML
- CDN 缓存参与有限
- 应用层 ISR 仍然命中
三层缓存的关系
可以用一句话概括:
- ISR 主要解决“服务器是否要重复生成页面”
- CDN 缓存主要解决“请求是否要回源”
- 浏览器缓存主要解决“客户端是否要重复发请求”
这三层可以叠加, 也可以只有其中一部分生效。
Middleware在链路中的角色
Middleware 更适合承担:
- 鉴权入口
- locale 规范化
- rewrite / redirect
- 请求前置判断
它不是页面 HTML 缓存策略的最稳定控制点。
原因是:
- Middleware 发生在页面真正渲染之前
- Rewrite 会让最终页面响应带有“请求协商后得到的结果”特征
- App Router 生成最终 HTML 时, 框架可能对外层缓存头采取更保守策略
因此, 经由 middleware rewrite 的页面, 即使最终是 ISR 页面, 也可能出现:
- 应用层缓存命中
- 外层 HTML 响应头仍然比较保守
这并不表示 ISR 失效, 只表示:
- 应用内部缓存与外层 HTTP 缓存是两套机制
时序图
下面的时序图展示了一个公共内容页在 locale rewrite + ISR 场景下的典型请求流程:
这个流程里最重要的判断是:
- Middleware 负责把请求导向正确页面
- Next 页面缓存决定是否要重新生成页面
- CDN 和浏览器是否缓存, 是后续层的问题
因此, 不要把 rewrite 后外层响应头比较保守, 误解为 ISR 没有工作。
Page HTML的推荐判断顺序
判断 page HTML 是否真的走到了预期缓存模型时, 建议按这个顺序看:
Page HTML 缓存判断顺序
| 顺序 | 判断项 | 作用 |
|---|---|---|
| 1 | Build 输出是否已经是静态或 ISR | 判断页面是否具备缓存资格 |
| 2 | x-nextjs-cache 或 x-vercel-cache 是否命中 | 判断缓存是否真的在工作 |
| 3 | 页面是否误用了请求级 API | 排查静态优化失效原因 |
| 4 | 外层 Cache-Control | 补充判断 HTTP 缓存层行为 |
原因是:
- Build 输出决定“是否具备缓存资格”
- Next / Vercel 的缓存命中头决定“缓存是否真的在工作”
- 外层
Cache-Control只说明 HTTP 缓存层行为, 不能单独代表 ISR 是否失效
典型场景
公共内容站、每日更新、多数据源、SEO 首屏
有一类网站很容易误判成"必须动态":
- 页面是公共内容页
- 首屏内容必须进入 HTML, 便于 SEO
- 内容每天更新
- 一部分数据来自本地数据库
- 另一部分数据来自远程服务
- 查询条件里还会隐含"当天""当前日期之后"之类的时间范围
这类场景通常仍然优先考虑 ISR, 而不是默认 force-dynamic。
原因是:
- 页面内容对所有用户一致
- 更新频率通常是天级、小时级, 而不是每个请求都必须严格实时
- 搜索引擎需要的是稳定可抓取的首屏 HTML, 不是每次请求都重新 SSR
更适合ISR的条件
公共内容站更适合 ISR 的条件
| 条件 | 说明 |
|---|---|
| 首屏内容是公共内容 | 可安全复用给所有用户 |
| 允许缓存窗口内存在短暂延迟 | 不要求每次请求绝对实时 |
| 页面目标是稳定展示最近一次重建结果 | 更重视稳定首屏而非实时 SSR |
| 数据更新可按时间窗口或事件刷新 | 适合 revalidate 或主动重建 |
更适合动态的条件
公共内容站更适合动态的条件
| 条件 | 说明 |
|---|---|
| 首屏必须严格反映当前时刻结果 | 不能接受重验证窗口 |
| 不能接受缓存窗口内旧数据 | ISR 延迟不可接受 |
| 时间条件需要精确到当前请求时刻 | 例如按当前秒、当前分钟计算 |
| 首屏每次都要实时联动多个后端服务 | 更像实时聚合页 |
典型推荐做法
公共内容站推荐做法
| 做法 | 说明 |
|---|---|
| 页面主体继续走 ISR | 保留 SEO 与缓存收益 |
generateStaticParams 负责核心公共路径 | 预生成关键页面 |
revalidate 按小时级或天级设置 | 与业务更新频率匹配 |
数据源更新后主动触发 revalidatePath 或 revalidateTag | 缩短内容切换延迟 |
sitemap 与页面保持一致更新节奏 | 便于搜索引擎发现新内容 |
关于当天等隐形条件
如果查询条件依赖当前日期, 不要立刻得出"只能动态"的结论。
真正的问题是:
- 你能否接受在缓存窗口内, 页面仍然展示上一轮重建后的结果
如果可以接受, 就仍然适合 ISR。
如果完全不能接受, 就更接近动态渲染。
关于多数据源
如果页面首屏需要同时依赖:
- 自己的数据库
- 远程服务返回的补充内容
也不意味着必须动态。
更稳定的方式通常是:
- 在 ISR 重建期间统一拉取并组装首屏数据
- 或者先把远程服务中的关键 SEO 字段同步到自己的内容层
不推荐把"每个用户请求都实时串联多个数据源"作为默认方案。
那更像动态页架构, 会增加首屏波动、外部依赖风险和构建链路复杂度。
总结判断
对于"公共内容 + 每日更新 + 多数据源 + SEO 首屏"这一类网站:
- 默认优先 ISR
- 用合理的
revalidate控制延迟窗口 - 用定时任务、Webhook、后台事件主动触发重建
- 只有在首屏必须严格基于请求当下实时结果时, 才改为动态