增量静态生成实战:提升网站构建效率与用户体验
优化前:卡得不行
上个月我们上线了一个内容型站点,用的是 Next.js 的静态生成(SSG)。初期几百个页面,构建飞快,部署也稳。但随着内容量涨到 3000+ 页面,每次构建直接干到 15 分钟以上,CI/CD 流水线经常超时,开发体验差到爆——改一个文案,等十几分钟才能预览,谁受得了?
更糟的是,就算只改了其中一篇博客,整个站点都得重新构建。用户访问新内容要等全站部署完,根本谈不上“增量更新”。团队里有人开始嘀咕:“是不是该换动态渲染了?” 但动态渲染又会牺牲首屏性能,Lighthouse 分数掉得厉害,这不就本末倒置了?
找到瓶颈了!
我先用 next build --profile 跑了一次,配合 Chrome DevTools 的 Performance 面板看构建过程。结果一目了然:90% 的时间花在重复生成那些根本没变的页面上。比如首页、分类页、旧文章,这些内容几个月都不动,但每次构建都重跑一遍 getStaticProps 和 React 渲染。
再翻 Next.js 官方文档,发现他们其实早就在 v9.5 引入了 Incremental Static Regeneration(ISR),但我们的代码还是老式写法——所有页面都用 getStaticPaths + fallback: false,压根没启用增量能力。
折腾了半天发现,问题不在工具,而在我们没用对。
核心改动:从全量重建到增量更新
ISR 的核心思想很简单:只重新生成变化的页面,其他页面保留旧的静态文件。Next.js 通过 revalidate 参数控制缓存时间,但对我们这种“内容由 CMS 触发更新”的场景,更合适的是用 on-demand revalidation(按需重验证)。
具体怎么改?分两步:
- 把页面的
getStaticPaths改成fallback: 'blocking'或true(我们选了 blocking,避免闪加载) - 在 API 路由里接收 CMS 的 webhook,调用
res.revalidate触发单个页面重建
来看关键代码对比。
优化前(全量 SSG):
// pages/posts/[id].js
export async function getStaticPaths() {
const posts = await fetch('https://jztheme.com/api/posts').then(res => res.json());
return {
paths: posts.map(post => ({ params: { id: post.id } })),
fallback: false, // 关键问题:一旦构建完成,无法新增页面
};
}
export async function getStaticProps({ params }) {
const post = await fetch(https://jztheme.com/api/posts/${params.id}).then(res => res.json());
return { props: { post } };
}
这个写法的问题是:如果 CMS 新增了一篇 ID 为 1001 的文章,而构建时没包含它,用户访问就会 404。而且每次构建都拉全量数据,慢得离谱。
优化后(启用 ISR + 按需重验证):
// pages/posts/[id].js
export async function getStaticPaths() {
// 只预渲染热门或近期文章,比如最近100篇
const recentPosts = await fetch('https://jztheme.com/api/posts?limit=100').then(res => res.json());
return {
paths: recentPosts.map(post => ({ params: { id: post.id } })),
fallback: 'blocking', // 新页面首次访问时服务端生成,后续走缓存
};
}
export async function getStaticProps({ params }) {
const post = await fetch(https://jztheme.com/api/posts/${params.id}).then(res => res.json());
return {
props: { post },
revalidate: 3600, // 1小时后自动后台更新,防失效
};
}
然后加一个 API 路由,用于接收 CMS 的更新通知:
// pages/api/revalidate.js
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ message: 'Method not allowed' });
}
const { secret, id } = req.body;
// 验证密钥(实际项目中应从环境变量读取)
if (secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
try {
// 触发指定页面的重新生成
await res.revalidate(/posts/${id});
return res.json({ revalidated: true });
} catch (err) {
return res.status(500).json({ message: 'Revalidation failed' });
}
}
CMS 那边配个 webhook,每当文章发布或更新,就 POST 到 /api/revalidate,带上文章 ID 和密钥。这样,只有被修改的页面会被重建,其他几千个页面原封不动,直接从 CDN 返回。
这里注意我踩过好几次坑:一开始忘了设 fallback: 'blocking',导致新文章首次访问返回 404;后来又因为没加 revalidate,万一 webhook 失败,页面就永远卡在旧版本。现在这个组合拳,既保证了即时性,又有兜底策略。
性能数据对比
改完之后,效果立竿见影:
- 全量构建时间:从平均 15 分钟降到 2 分钟(只预渲染 100 个页面)
- 单页面更新延迟:从 15 分钟(等全站部署)降到 3 秒内(CMS 发布后立刻可访问)
- CDN 命中率:从 70% 提升到 98%(因为大部分页面不再变动,长期缓存)
- 首屏加载时间:保持在 800ms 左右(和之前一样快,因为还是纯静态)
最爽的是开发体验:现在改完文章,点发布,刷一下页面,新内容就出来了。再也不用盯着 CI 日志干等。
当然,也不是完美无缺。比如如果 CMS webhook 挂了,页面就不会自动更新(但我们加了定时任务每小时全量 revalidate 一次作为保底)。另外,对于极高频更新的页面(比如实时数据看板),ISR 还是不太合适,那种场景可能得用 SWR + CSR 混合方案。但对我们这种以内容为主的站点,ISR 简直是量身定制。
最后提醒几个细节
如果你也要上 ISR,这几个点一定注意:
- 别在
getStaticProps里做耗时操作,比如复杂计算或大文件处理,ISR 重建时会阻塞请求 - CDN 缓存头要配对,确保
stale-while-revalidate能生效(Vercel 默认处理好了,自建的话得自己配) - 测试时用
next start跑生产模式,开发模式下 ISR 不生效
亲测有效,这套方案已经在线上跑了三周,零故障。构建时间省了 85%,团队幸福感飙升。
以上是我个人对增量静态生成的优化实战总结,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合边缘函数做个性化缓存,后续会继续分享这类博客。

暂无评论