Next.js 教程系列(九)增量静态再生 (ISR):动态更新的静态内容

0 阅读7分钟

前言

大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!


第九章 增量静态再生 (ISR):动态更新的静态内容

一、理论讲解

1. ISR 核心理念与底层原理

增量静态再生(Incremental Static Regeneration,简称 ISR)是 Next.js 独有的混合渲染模式,允许开发者在不重新构建整个站点的情况下,按需、定时地更新部分静态页面。它结合了 SSG 的高性能和 SSR 的实时性,极大提升了大规模内容站点的可维护性和性能。

  • 定时再生:通过 revalidate 参数,Next.js 会在后台定期重新生成页面。
  • 按需再生:支持 API 触发(on-demand revalidation),如管理员操作、Webhook 通知等。
  • CDN 协同:与 CDN 缓存结合,保证全球访问速度。
  • 无感知更新:用户访问时总能拿到最新或最近一次生成的内容。
  • 极致性能:首次请求生成静态页面,后续请求直接命中 CDN,只有到期或手动触发时才会后台再生。
ISR 与 CDN 协同流程
  1. 用户访问页面,CDN 检查缓存是否过期。
  2. 若缓存有效,直接返回静态页面。
  3. 若缓存过期,CDN 允许请求到达 Next.js 服务器,后台异步再生页面,用户仍拿到旧内容。
  4. 再生完成后,CDN 缓存自动更新,后续用户访问即为新内容。
revalidate 机制流程图(文字描述)
  • 用户访问页面 → CDN 检查缓存 → 缓存有效:直接返回 → 缓存过期:Next.js 触发再生 → 新页面生成 → CDN 缓存更新

2. revalidate 参数与 on-demand revalidation

  • revalidategetStaticProps 返回对象中的一个字段,单位为秒,表示页面静态内容的最小刷新间隔。
  • 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; }
}

四、最佳实践

  1. 合理设置 revalidate 间隔:根据内容更新频率和业务需求设置,避免过短导致频繁构建。
revalidate: 300 // 5 分钟
  1. on-demand revalidation 权限校验:API 路由需校验 secret,防止被恶意刷接口。
if (secret !== process.env.REVALIDATE_SECRET) return res.status(401).json({ message: '无权限' });
  1. 缓存失效与 CDN 配合:合理设置响应头,利用 CDN 缓存提升全球访问速度。
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate');
  1. 错误处理与监控报警:revalidate 失败需有日志和报警,便于排查。
try { await res.revalidate('/'); } catch (err) { reportError(err); }
  1. 团队协作与权限管理:约定好哪些页面用 ISR,哪些用 SSR/SSG,文档化 revalidate 策略,管理员操作需鉴权。
if (!isAdmin(req)) return res.status(403).json({ message: '无权限' });
  1. 极端场景降级:如再生失败、接口超时,前端兜底提示,保证用户体验。
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: 可在前端提示"内容已更新,点击刷新",或自动轮询。

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!