前言
大家好,我是鲫小鱼。是一名不写前端代码
的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》
。欢迎点赞、收藏、关注,一键三连!!
第九章 增量静态再生 (ISR):动态更新的静态内容
一、理论讲解
1. ISR 核心理念与底层原理
增量静态再生(Incremental Static Regeneration,简称 ISR)是 Next.js 独有的混合渲染模式,允许开发者在不重新构建整个站点的情况下,按需、定时地更新部分静态页面。它结合了 SSG 的高性能和 SSR 的实时性,极大提升了大规模内容站点的可维护性和性能。
- 定时再生:通过
revalidate
参数,Next.js 会在后台定期重新生成页面。 - 按需再生:支持 API 触发(on-demand revalidation),如管理员操作、Webhook 通知等。
- CDN 协同:与 CDN 缓存结合,保证全球访问速度。
- 无感知更新:用户访问时总能拿到最新或最近一次生成的内容。
- 极致性能:首次请求生成静态页面,后续请求直接命中 CDN,只有到期或手动触发时才会后台再生。
ISR 与 CDN 协同流程
- 用户访问页面,CDN 检查缓存是否过期。
- 若缓存有效,直接返回静态页面。
- 若缓存过期,CDN 允许请求到达 Next.js 服务器,后台异步再生页面,用户仍拿到旧内容。
- 再生完成后,CDN 缓存自动更新,后续用户访问即为新内容。
revalidate 机制流程图(文字描述)
- 用户访问页面 → CDN 检查缓存 → 缓存有效:直接返回 → 缓存过期:Next.js 触发再生 → 新页面生成 → CDN 缓存更新
2. revalidate 参数与 on-demand revalidation
revalidate
是getStaticProps
返回对象中的一个字段,单位为秒,表示页面静态内容的最小刷新间隔。- on-demand revalidation 允许通过 API 路由手动触发某个页面或路径的再生,适合内容管理后台、Webhook 场景。
- 支持按路径、按 tag、全站等多种粒度的再生。
按 tag/on-demand revalidate
- Next.js 13+ 支持
revalidateTag
,可按 tag 精细化刷新相关页面。
3. 与 SSR/SSG 区别
- SSG:构建时一次性生成所有静态页面,后续内容变更需重新构建。
- SSR:每次请求都实时生成页面,性能受限于服务端。
- ISR:首次请求生成静态页面,后续定时或手动再生,兼顾性能与实时性。
4. 适用场景与企业级案例
- 大型内容站点(如博客、新闻、商品详情)
- 需要频繁更新但不要求秒级实时的页面
- 管理后台、内容平台、SEO 友好型站点
- 典型案例:电商商品页、新闻门户、内容聚合平台
5. 常见误区与优化建议
- ISR 不是实时渲染,存在短暂的"旧内容"窗口
- revalidate 过短会导致频繁构建,影响性能
- on-demand revalidation 需做好权限校验,防止滥用
- CDN 配置不当会导致缓存失效不及时
- 建议结合监控,及时发现再生失败等异常
二、代码示例
1. 商品详情页用 ISR 定时更新
// pages/products/[id].tsx
export async function getStaticPaths() {
const products = await fetchProducts();
return {
paths: products.map(p => ({ params: { id: p.id } })),
fallback: 'blocking',
};
}
export async function getStaticProps({ params }) {
const product = await fetchProductById(params.id);
if (!product) return { notFound: true };
return {
props: { product },
revalidate: 60, // 每 60 秒自动再生
};
}
export default function ProductPage({ product }) {
return (
<div className="product-detail">
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>价格:{product.price}</span>
</div>
);
}
2. 多种 on-demand revalidate 场景
按路径手动刷新
// pages/api/revalidate.ts
export default async function handler(req, res) {
const { secret, path } = req.query;
if (secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ message: '无权限' });
}
try {
await res.revalidate(path);
return res.json({ revalidated: true });
} catch (err) {
// 错误监控
reportError(err);
return res.status(500).json({ message: 'revalidate 失败' });
}
}
按 tag 手动刷新(Next.js 13+)
// app/api/revalidate-tag/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(req) {
const { tag, secret } = await req.json();
if (secret !== process.env.REVALIDATE_SECRET) return new Response('无权限', { status: 401 });
try {
revalidateTag(tag);
return Response.json({ revalidated: true });
} catch (err) {
reportError(err);
return Response.json({ message: 'revalidate 失败' }, { status: 500 });
}
}
3. 前端触发手动刷新(管理员操作)
// components/AdminRevalidateButton.tsx
import { useState } from 'react';
export default function AdminRevalidateButton() {
const [loading, setLoading] = useState(false);
const handleRevalidate = async () => {
setLoading(true);
const res = await fetch('/api/revalidate?path=/', {
headers: { 'x-admin-token': localStorage.getItem('adminToken') },
});
setLoading(false);
alert((await res.json()).revalidated ? '刷新成功' : '刷新失败');
};
return <button onClick={handleRevalidate} disabled={loading}>{loading ? '刷新中...' : '手动刷新首页'}</button>;
}
4. 移动端骨架屏动画
.skeleton {
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
background-size: 200% 100%;
animation: skeleton 1.2s infinite linear;
height: 80px;
border-radius: 8px;
margin-bottom: 12px;
}
@keyframes skeleton {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
5. 错误监控与埋点
// pages/_app.tsx
import { useEffect } from 'react';
useEffect(() => {
window.addEventListener('error', (e) => {
// 上报 ISR 相关错误
reportError(e);
});
}, []);
三、实战项目:博客系统热门文章列表(ISR 自动+手动更新)
1. 项目需求
- 博客首页展示热门文章列表,内容每隔 5 分钟自动更新
- 管理员可在后台一键刷新热门列表(on-demand revalidation)
- 移动端友好,SEO 友好,性能高
- 支持骨架屏动画、错误兜底、性能监控、结构化数据
2. 技术选型
- Next.js + TypeScript
- getStaticProps + revalidate
- API 路由手动 revalidate
- CDN 缓存
- Sentry/自研埋点监控
3. 目录结构
/blog-hot-demo
|-- pages/
|-- index.tsx
|-- api/
|-- revalidate.ts
|-- components/
|-- HotList.tsx
|-- AdminRevalidateButton.tsx
|-- Skeleton.tsx
|-- styles/
|-- globals.css
4. 首页热门文章列表(自动+手动再生)
// pages/index.tsx
import HotList from '../components/HotList';
import AdminRevalidateButton from '../components/AdminRevalidateButton';
import Skeleton from '../components/Skeleton';
import Head from 'next/head';
export async function getStaticProps() {
const hotPosts = await fetchHotPosts();
return {
props: { hotPosts },
revalidate: 300, // 5 分钟自动再生
};
}
export default function Home({ hotPosts }) {
return (
<>
<Head>
<title>热门文章 - 博客系统</title>
<meta name="description" content="最新最热的博客文章,定时自动更新,SEO 友好" />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({
"@context": "https://schema.org",
"@type": "ItemList",
"itemListElement": hotPosts.map((p, i) => ({
"@type": "ListItem",
"position": i + 1,
"url": `/posts/${p.id}`
}))
}) }} />
</Head>
<AdminRevalidateButton />
{hotPosts.length === 0 ? <Skeleton /> : <HotList posts={hotPosts} />}
</>
);
}
// components/HotList.tsx
export default function HotList({ posts }) {
return (
<ul className="hot-list">
{posts.map(post => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
);
}
// components/AdminRevalidateButton.tsx
import { useState } from 'react';
export default function AdminRevalidateButton() {
const [loading, setLoading] = useState(false);
const handleRevalidate = async () => {
setLoading(true);
const res = await fetch('/api/revalidate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-admin-token': localStorage.getItem('adminToken') },
body: JSON.stringify({ secret: 'xxx' })
});
setLoading(false);
alert((await res.json()).revalidated ? '刷新成功' : '刷新失败');
};
return <button onClick={handleRevalidate} disabled={loading}>{loading ? '刷新中...' : '手动刷新首页'}</button>;
}
// components/Skeleton.tsx
export default function Skeleton() {
return <div className="skeleton">加载中...</div>;
}
// pages/api/revalidate.ts
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const { secret } = req.body;
if (secret !== process.env.REVALIDATE_SECRET) return res.status(401).json({ message: '无权限' });
try {
await res.revalidate('/');
return res.json({ revalidated: true });
} catch (err) {
reportError(err);
return res.status(500).json({ message: 'revalidate 失败' });
}
}
5. 移动端适配与性能优化
.hot-list { padding: 0; }
.hot-list li { margin-bottom: 12px; }
@media (max-width: 600px) {
.hot-list { font-size: 16px; }
.skeleton { height: 60px; }
}
四、最佳实践
- 合理设置 revalidate 间隔:根据内容更新频率和业务需求设置,避免过短导致频繁构建。
revalidate: 300 // 5 分钟
- on-demand revalidation 权限校验:API 路由需校验 secret,防止被恶意刷接口。
if (secret !== process.env.REVALIDATE_SECRET) return res.status(401).json({ message: '无权限' });
- 缓存失效与 CDN 配合:合理设置响应头,利用 CDN 缓存提升全球访问速度。
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate');
- 错误处理与监控报警:revalidate 失败需有日志和报警,便于排查。
try { await res.revalidate('/'); } catch (err) { reportError(err); }
- 团队协作与权限管理:约定好哪些页面用 ISR,哪些用 SSR/SSG,文档化 revalidate 策略,管理员操作需鉴权。
if (!isAdmin(req)) return res.status(403).json({ message: '无权限' });
- 极端场景降级:如再生失败、接口超时,前端兜底提示,保证用户体验。
if (error) return <div>热门文章加载失败,请稍后重试</div>;
五、常见问题与解决方案
- Q: ISR 页面内容有延迟,怎么解决?
- A: 适当缩短 revalidate 间隔,或结合 on-demand revalidation。
- Q: 手动 revalidate 失败?
- A: 检查 secret、API 路由权限、路径拼写、管理员权限。
- Q: CDN 缓存未及时失效?
- A: 检查 CDN 配置,确保支持 stale-while-revalidate。
- Q: SEO 有问题?
- A: ISR 页面本质为静态页面,SEO 友好,注意 meta 标签和结构化数据。
- Q: 频繁 revalidate 导致性能下降?
- A: 合理设置 revalidate 间隔,避免高频触发。
- Q: 再生失败如何监控?
- A: 集成 Sentry/自研埋点,自动报警。
- Q: 如何做多页面/多 tag 刷新?
- A: 使用 revalidatePath/revalidateTag,或循环调用 revalidate。
- Q: 用户看到旧内容怎么办?
- A: 可在前端提示"内容已更新,点击刷新",或自动轮询。
最后感谢阅读!欢迎关注我,微信公众号:
《鲫小鱼不正经》
。欢迎点赞、收藏、关注,一键三连!!!