拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Next.js ISR增量静态再生实战:从SSG与SSR两难到混合渲染

Next.js ISR增量静态再生实战:从SSG与SSR两难到混合渲染

我一直觉得,做内容型站点的人,都应该认真看一眼 Next.js 的增量静态再生(ISR)。如果你只听说过 SSG 和 SSR,却不知道怎么在一个项目里同时拿到两者的好处,这篇就是写给你的。ISR 解决的是我从 2019 年开始做内容站时特别头疼的问题:静态页面速度快,但内容更新太被动;服务端渲染实时性强,但每个请求都跑数据源,流量稍微大一点就喘。增量静态再生把这两件事捏在一起——大部分时间页面是静态的,到了时间节点自动后台重新生成,用户永远不感知。

这篇不仅讲 ISR 怎么用,我还会把我自己踩过的坑一起交代清楚,包括 revalidate 参数的真实行为、fallback 三种模式怎么选、生产环境里为什么会出现页面不更新的假象、以及什么时候你压根不该用 ISR。看完你应该能判断自己的项目适不适合 ISR,并且能直接照着代码落地。

1. ISR 出现之前:SSG 和 SSR 的两难选择

1.1 SSG 的静态红利与更新代价

静态站点生成做了这么多年,核心优势一句话就能说清:构建时把数据全塞进 HTML,部署到 CDN 上,用户访问走边缘节点,没有数据库查询、没有服务端计算、没有动态渲染开销。这套架构对流量非常友好,一个页面哪怕一天被刷十万次,成本也几乎不变。

但 SSG 有个天然的死穴:数据变了,页面不变。假设我的商品详情页在构建时生成了,「价格 ¥299」写死在 HTML 里,后台上调价格到 ¥349,除非重新触发一次构建,否则用户永远看到 ¥299。小型站点手动跑一次构建还好,内容织一多、页面成百上千,每次改一篇文章就全量重新构建,构建时间线性膨胀,更新窗口越长越难受。

1.2 SSR 的实时性代价

SSR 换了条路:每次请求都实时执行服务端渲染,访问瞬间去数据库拿最新数据,拼出 HTML 返回。数据永远是最新的,用户体验也不会差,因为没有客户端白屏。问题出在资源占用和响应时间上,高并发时每个请求都穿透到数据库和模板渲染层,CPU、内存、数据库连接全在燃烧。就算加了缓存层,也得权衡缓存什么、缓存多久、怎么失效。

我在一个中等流量的电商项目里被 SSR 的数据库压力搞到半夜起来扩容,那次之后我对「每个请求都实时」这个方案产生了警惕。用户的访问模式是稀疏随机的,绝大部分请求访问的数据可能几个小时都没变过,却白白消耗了后端资源。

1.3 增量静态再生是怎么补上这两块的

Next.js 的增量静态再生本质上是一种混合模式:构建时预渲染一批页面,同时约定一个「过期时间」,用户请求时先拿到已生成的静态页,哪怕这个页面在后台已经被标记为过期,用户也不等,返回旧版本的同时系统在后台重新生成新版本,下个请求开始就是新内容了。

这套思路其实不是 Next.js 原创,HTTP 层面的 stale-while-revalidate 缓存策略早就存在。但 Next.js 把它变成了一个正经的框架能力,开发者不需要自己搭缓存系统、不需要写后台任务队列、不需要手动管理 CDN 缓存刷新,一个 revalidate 参数就把整件事串起来了。增量这个关键词在 Next.js 里的意思不是「生成新页面」这个动作增量,而是更新策略增量——每次只重新生成需要变的页面,不动其他页面,构建成本不再随总页面数线性膨胀,时间和流量都只花在真正变化了的内容上。

2. ISR 的核心机制:一次请求如何从旧页面平滑过渡到新页面

2.1 stale-while-revalidate 模型在 Next.js 里的映射

要理解 ISR,脑子里必须先想清楚 stale-while-revalidate 这个缓存模型。它把缓存资源的使用周期拆成两段:新鲜期之内请求一律走缓存,零成本返回;新鲜期一过,缓存进入过期状态,但这会儿不直接回源,而是先把准时的旧缓存丢给当前请求,保证响应速度不塌方,然后异步触发一次后台回源,把新数据补位。

Next.js 的 ISR 把这个模型搬到了页面级别:页面构建完成的那一刻开始计时,revalidate 的秒数就是新鲜期。比如我设revalidate = 60,那么页面上次生成后的 60 秒内,所有请求都是直接拿现成 HTML;第 61 秒之后第一个请求来了,用户依然拿到旧 HTML(用户完全不察觉),后台同时跑一次页面重新生成;这场重新生成结束后,再往后都是新页面天下了。

这个概念一定要记牢——不是「过期后页面立刻变新」,而是「过期后下次访问触发更新」。你要是理解成「每 60 秒页面自动刷新一次」,后续排查问题会绕很多弯路。

2.2 revalidate 参数的真面目:生效条件与触发时机

设置 revalidate 时容易产生一种错觉:我设了 60,那系统肯定每 60 秒准时更新。不是的,系统的逻辑简单说就是:请求进来,看一眼距离上次生成时间是否超过了 revalidate 秒数,没超过直接返回缓存,超过就返回旧页面同时触发后台重新生成。如果这 60 秒内一条请求都没有,那 60 秒这个数字就只是个数字,没有任何后台定时器在跑,自然也不会生成新页面。

实际运作里的一个重要分支是:页面重新生成失败怎么办。假如后台重新运行 getStaticProps 时你的数据库崩了,或者上游 API 超时了,ISR 不会给你返回一张错误页面顶掉旧内容,它会保留当前这份旧页面,然后把「下次生成重试」的概率继续留着,等下一个请求来了再试一次。这是 ISR 很人性化的一个设计——旧页面永远是你的兜底,后台失败不伤害线上可用性,只会延迟内容更新。

2.3 首次访问与动态路由:fallback 的角色

前面说的都是「构建时已经生成的页面」,但 ISR 还有一层增量生成能力,专门处理动态路由的页面。试想一个电商系统,商品型号是在运营后台不断录入的,构建时不可能预先知道未来会上架哪些商品。这是 fallback 参数登场的场景——fallback: true,首次访问某个尚不存在的商品路径时,Next.js 立即给用户返回一个带骨架的同页面的 HTML,同时在后台执行 getStaticProps 生成真实页面,生成完成后存起来,之后这个商品的请求就直接落到真实页面上了。fallback: 'blocking'的做法不同,用户的第一个请求会一直在服务端等待,直到页面生成完毕再返回,而不是先给骨架。

这三种模式的本质差异我后面在实战部分展开,你只需要先建立一个大框架:ISR = 构建时预生成 + 过期后异步重建 + 首次访问按需生成。

3. 代码实战:从 Page Router 到 App Router 的 ISR 实现

3.1 Page Router 下的 getStaticProps + revalidate

Page Router 时代的写法非常直白,写一个普通的 getStaticProps,加一行 revalidate 就够了:

// pages/products/[id].js export async function getStaticProps({ params }) { const product = await fetchProductById(params.id); return { props: { product }, revalidate: 300, }; } export async function getStaticPaths() { const products = await fetchAllProducts(); const paths = products.map((p) => ({ params: { id: p.id } })); return { paths, fallback: 'blocking', }; }

这段代码的含义是:构建时先把所有已知商品生成一遍,响应 header 缓存时间相关状态由框架管理;每个页面在上次生成后的 300 秒内走缓存,超过后触发后台重新生成;如果用户访问了一个构建时没生成的商品路径,fallback: 'blocking' 会撑住连接,等页面首次生成完再返回。

3.2 fallback 的三种模式:该怎么选

这是不少人容易搞拧的地方,我把三种模式和我的选型经验放一起说。

模式首次访问行为适用场景风险与代价
fallback: false没构建过的路径直接 404路径全集在构建时确定,比如固定文档站新增内容必须触发重新构建
fallback: true先返回骨架页,后台生成,通过router.isFallback区分状态内容量大、新增频繁的列表页或详情页首次访问有轻微体验折损,需要处理骨架状态
fallback: 'blocking'服务端等生成完成后一次性返回完整 HTML对 SEO 敏感的页面,不希望出现临时骨架首次请求耗时增加,生成时间略长时体验受影响

就电商场景来说,我通常用fallback: 'blocking',原因是 SEO 团队不接受返回骨架页给搜索引擎,搜索引擎拿到的如果是空骨架,收录质量会受影响。fallback: true更适合后台管理系统内部页面,用户登录进来等个几百毫秒无所谓,骨架还能提示加载状态。fallback: false适合那种文章集合固定、不频繁增删的场景,比如产品帮助中心,路径在文档发布流水线里是可控的。

3.3 App Router 下的 revalidate 写法

Next.js 13.4 之后 App Router 成为推荐路线,ISR 的配置从函数参数变成了更分散的导出声明方式,但核心模型完全没变。

页面级写法:

// app/products/[id]/page.js export const revalidate = 300; async function getProduct(id) { const res = await fetch(`https://api.example.com/products/${id}`); return res.json(); } export default async function ProductPage({ params }) { const product = await getProduct(params.id); return <ProductView product={product} />; }

数据级写法,只对某个 fetch 请求设置过期时间:

export default async function ProductPage({ params }) { const res = await fetch( `https://api.example.com/products/${params.id}`, { next: { revalidate: 300 } } ); const product = await res.json(); return <ProductView product={product} />; }

这俩的区别在于:页面级导出revalidate管的是整页重新生成;数据级配置只管这条 fetch 的数据失效时间,页面里其他数据还能单独控制。大多数项目用页面级就够了,但如果某个页面里既有商品信息又有评论信息,价格 5 分钟更新一次、评论 1 分钟更新一次,数据级配置会更灵活。

App Router 里 ISR 的重新生成机制与 Page Router 一致,都遵循 stale-while-revalidate,只是路由和数据获取的代码组织方式换了而已。从老项目迁移过去时,你只需要把原来 getStaticProps 的返回值拆成「页面组件直接异步取数 + revalidate 导出」,逻辑本身是不变的。

4. 增量再生的关键细节:持久化、数据源与 CDN 协同

4.1 生成后的页面到底存哪了:磁盘持久化与多实例注意点

ISR 页面构建完成后不是放在内存里完事的,Next.js 会在服务器上持久化生成结果。自托管部署时,这些页面存在于.next/server相关目录下;跑在 Vercel 这类平台时,生成结果会落到平台的持久化存储里。这个细节对多实例部署特别重要:如果你把 Next.js 自托管在多个 Node 实例后面,每个实例的 ISR 页面是独立缓存的,同一个路径在实例 A 上可能已经生成了新版本,实例 B 上还是旧的。

集群环境下我建议把 ISR 的触发频次和自然流量错开,或者考虑用 Sticky Session 让同一个页面的请求尽量落到同一个实例,再或者直接换成容器重启频繁度可控的部署方式。很多人以为 ISR 是自己写的全局缓存,其实它更多依赖部署平台的持久化机制,这个认知误差在踩坑时很致命。

4.2 为什么说 ISR 天然是 CDN 友好的

ISR 页面在服务端生成的产物是纯静态 HTML,响应头里会带上与 stale-while-revalidate 相关的缓存头。CDN 拿到这份响应后就能直接缓存,并在过期后按照同样的策略回源,而不是穿透到数据库。这意味着你的 CDN 边缘节点实际上继承了一套「用户无感的页面更新机制」,回源频率被压到每次 data change 后最多一两次,成本很低,用户体验却是实时的。

我之前在博客里分享过一个对比数据:同一个博客站,纯 SSR 模式下 CDN 回源率大概是每小时几千次;切到 ISR 后按小时维度回源率降到几十次。这个差距在源站成本上的体现非常直观。

4.3 按需重新验证:把更新时间线从轮询变成事件驱动

纯时间驱动的 revalidate 更新策略有一个弱点:你可能希望在特定事件发生时立刻更新页面,而不是等待下一个窗口期。比如运营在 CMS 里把一篇文章从草稿改成了已发布,你迫不及待想让线上页面立刻更新,靠 revalidate 60 秒也能更新,但总觉得不够专业。

Next.js 提供了按需重新验证,用 API Route 主动触发某个页面的重新生成。写一个简单的接口:

// pages/api/revalidate.js export default async function handler(req, res) { if (req.query.secret !== process.env.REVALIDATE_SECRET) { return res.status(401).json({ error: 'Invalid token' }); } try { await res.revalidate('/products/123'); return res.json({ ok: true }); } catch (err) { return res.status(500).json({ error: 'Revalidation failed' }); } }

CMS 那边在文章发布的时候,顺手请求一次这个接口,路径对应的页面立刻进入后台重新生成流程,完成时新内容就在线了。这个思路把 ISR 从「固定时间轮询」升级成了「事件驱动」,内容更新链路更干净。

5. 验证与实测:一个商品页的 ISR 全流程观察

5.1 搭一个最小的 ISR 工程

理论讲再多,不如亲手验证一遍 ISR 的网络行为。我建议你花十分钟搭个最小的测试环境:新建一个 Next.js 项目,用 Page Router 写一个带 getStaticProps 的页面,页面里渲染一个服务端生成时间戳,revalidate 设成 10 秒。

// pages/time.js export default function TimePage({ now }) { return <div>Server time: {now}</div>; } export async function getStaticProps() { return { props: { now: new Date().toISOString() }, revalidate: 10, }; }

构建并启动生产模式,这个页面就能用来观察 ISR 的行为细节。

5.2 观察响应头里的 Cache-Control 变化

部署完这个测试页以后,用 curl 去盯响应头:

curl -I http://localhost:3000/time

你会看到响应头里有类似这样的字段:

Cache-Control: public, max-age=0, s-maxage=10, stale-while-revalidate

s-maxage=10就是 CDN 层的缓存新鲜期,对应 revalidate 的 10 秒;stale-while-revalidate则表示允许在过期状态下继续服务旧内容并异步更新。只要看到这个响应头,就说明 ISR 生效了,它也解释了为什么这套机制能跟 CDN 顺畅配合。

5.3 实战观察时间戳:刷新十次的演进过程

现在做一组连续刷新实验,把页面内容变化和响应头放一起看。我第一次访问http://localhost:3000/time,页面显示一个时间 A;接下来 10 秒内疯狂按刷新,时间一直是 A,因为新鲜期没过;大约第 11 秒再刷新,你可能依然看到时间 A,因为过期后的第一个请求是直接返回旧页面的,但这个请求已经触发后台重新生成;紧接着再刷新一次,看到时间 B,新页面开始持续生效。

这组实验还原了 ISR 最核心的语义:用户不会在自己的某一次请求上看到「页面正在更新」的状态,旧页面一直服务到新页面落定为止。而且你可以注意到,重新生成的触发是「过期后第一个请求」,不是「第 10 秒整的那个请求」——没有请求,就没有生成。

6. 避坑清单:ISR 实战中我踩过的五个坑

6.1 坑一:开发模式和生产模式的行为完全不同

开发模式下,Next.js 为了 debug 方便,每次请求都可能直接重新执行 getStaticProps,revalidate 的限制并不严格。我第一次跑 ISR 时在 dev 环境观察时间戳,发现每次刷新都变化,以为 ISR 没生效,差点把这套方案否了。后来用生产模式构建跑了一遍才发现行为正常。记住一条铁律:ISR 的缓存与过期行为只在 production build 中真实存在,本地调试需要跑npm run build && npm start。

6.2 坑二:构建时数据库不可达导致整个构建失败

getStaticProps 在构建阶段要被真实执行一遍,也就是构建机器必须能访问你的数据库和外部 API。我曾碰过一次生产环境构建,数据库连接串配错了环境变量,构建直接因数据库拒绝连接而失败。这个问题在纯 SSR 项目里不一定会致命,因为很多错误可以在运行期兜住;但 ISR 的构建行为非常实在——拿到路径列表就跑去取数,取不到就报错。

规避方案也很明确:构建环境的数据源访问能力、超时设置、错误边界,必须和运行时环境一起列入部署演练清单。页面的 getStaticProps 里最好做一层容错:上游不可用时返回某种降级数据,确保构建能顺利完成。

6.3 坑三:缓存页面里藏了不该被复用的用户数据

ISR 页面是全站共享的,同一个 URL 的用户拿到的都是同一份 HTML。如果你在 getStaticProps 里根据某种用户态(比如请求头里的 cookie、登录信息)去拼装页面内容,做出来的页面就出大问题了——用户 A 的数据可能被缓存在公共页面上,用户 B 刷新时看到的是 A 的私人信息。ISR 只适合存那些对所有用户都一致的内容,任何个性化内容千万别塞进 getStaticProps 的输出。

这个坑其实和 SSG 一致,但 ISR 的异步重新生成给人造成一种「页面是动态的」错觉,更容易把个性化逻辑顺手写进去。我建议每次在 getStaticProps 里取数据前,先问一句:这个数据是否与当前用户无关?如果答案是「有关」,请把它挪到客户端渲染或用 SSR 处理。

6.4 坑四:revalidate 值设太小,反而把后端拖垮了

revalidate 越小,后台重新生成的触发越频繁。假设你有 10 万个页面,revalidate 设为 1 秒,高流量下每秒可能有大量页面同时过期、同时触发重建,数据源一秒被打几百次,效果比 SSR 更糟。因为 SSR 至少还有请求级并发控制,ISR 的重建是后台批量发起的。我建议 revalidate 先从小时级或分钟级起步,再根据实际内容和流量观察逐步调低,不要凭感觉往小了设。

6.5 坑五:动态参数路径的 fallback 误配置

有人为了让所有动态页面都能实时生成,把所有路径都从 getStaticPaths 里省掉,只留一个fallback: true,让每个页面的首次访问都走按需生成。这样一来首次访问的响应时间会显著下降,SEO 爬虫还可能在骨架状态下抓取内容,而且高流量情形下大量首次请求同时触发后台生成,数据源瞬间过载。

更合理的做法是:热门页面提前在 getStaticPaths 里列进去,构建时完成生成;长尾页面靠 fallback 按需兜底。这样既控制了构建时间,又保证长尾内容可用,两全其美。

7. 什么时候该用 ISR:一张决策表

7.1 ISR vs SSG vs SSR 的对比

维度SSGISRSSR
构建阶段行为全量预生成预生成 + 增量重建不预生成
数据实时性构建时固化过期后异步更新请求时实时
首次访问速度极快极快取决于数据源延迟
高并发后端压力极低低高
内容变化响应需手动重建时间或事件驱动即时
个性化内容支持差差好

这张表格一眼能看出 ISR 的田野:内容型、公开访问、更新频率不太夸张的场景。一旦涉及登录态、用户专属数据,ISR 不仅不合适,还容易踩出安全问题。

7.2 内容更新频率与延迟接受度

我给自己总结过一个判断框架,按内容更新频率和可接受延迟来对号入座:

  • 内容几个月不变,比如公司官网主页:直接 SSG,连 revalidate 都不用设,省心。
  • 内容每天固定时间更新,比如日报类栏目:SSG + 按需 revalidate,上午一条通知触发更新即可。
  • 内容每隔几分钟到几十分钟变化一次,比如商品价格、库存:ISR 是首选,revalidate 按分钟设,再加按需更新接口配合 CMS 变更事件。
  • 内容秒级变化,比如抢购倒计时、实时榜单:别用 ISR,直接用 SSR 或客户端动态获取,避免在旧数据上叠加复杂的失效逻辑。

7.3 几条经验层面的建议

ISR 的价值在于把「静态化红利」和「内容新鲜度」很好地缝合了起来,但你要始终记住它是给「公开数据 + 低频变更 + 可容忍小段延迟」设计的方案。新项目如果是从零开始,我建议直接在 App Router 下用fetch数据级 revalidate 和页面级导出搭配引路;老项目从 Page Router 迁移时,先在访问量最低、内容最稳的页面试水,观察一段时间后台构建日志的反应,再逐步扩大应用面。

还有一点要提醒:如果你的页面在 ISR 下出现「看起来没有更新」的情况,先别急着怀疑框架,按这个顺序排查——确认你跑的是生产模式、确认响应头里 Cache-Control 确实带上了 revalidate 数值、确认距离上次生成时间是否真的超过了 revalidate 秒数、确认 CDN 层没有缓存过长的 s-maxage。这四个环节里,绝大多数问题都出在 CDN 配置上,Next.js 侧的机制反而是最让人省心的那部分。

返回列表