✦ 大道至简 · 时光是画在卷上的河流 · 行到水穷处,坐看云起时

Next.jsSSRSSGISR2025.10.17 · 12 分钟阅读

渲染策略 —— SSG / ISR / SSR 到底怎么选

静态生成、增量再生成、服务端渲染三者的区别与适用场景,以及本项目里每一类页面分别落在哪种策略、为什么文章详情页会从 SSG 改成 SSR

L

Leo

2025.10.17 · 更新于 2026.08.24

1 次浏览
渲染策略 —— SSG / ISR / SSR 到底怎么选

博客是「读多写少」的内容站,渲染策略直接决定性能和部署体验。这一篇先把三种策略讲透,再给出项目里每一类页面的真实分布——注意,文章详情页并不是 SSG,而是被迫改成了 SSR

一、三种策略一句话

策略何时渲染何时更新适合
SSG(静态生成)构建时重新部署几乎不变的内容
ISR(增量静态再生成)构建时 + 过期后后台重建revalidate 秒数 / revalidatePath 触发变化不频繁、可接受短暂延迟
SSR(服务端渲染)每次请求每次请求强实时、依赖用户态

Next 的默认约定:能静态就静态。页面若没有动态 API 也没有动态函数,默认就是 SSG / ISR。

二、项目里的真实分布地图

页面策略依据
首页 /ISRrevalidate = 3005 分钟自愈,写操作另有 on-demand 刷新
文章列表 /postsISRrevalidate = 60每个 ?page=N 独立缓存,60s 重建
文章详情 /posts/[slug]SSRforce-dynamic见下文「为什么不是 SSG」
标签列表 /tagsSSG无 revalidate、无动态函数,默认静态
标签详情 /tags/[tag]SSRforce-dynamic与文章详情同理
拾语 /shiyuISRrevalidate = 60高频更新,60s 后台重建
搜索 /searchSSR?q= 实时查库
后台 /dashboardSSR必须每次请求实时校验 session

三、ISR:export const revalidate

首页和拾语页用时间维度兜底:

// app/[locale]/(blog)/page.tsx —— 首页
export const revalidate = 300;   // 最多 5 分钟自愈

// app/[locale]/(blog)/shiyu/page.tsx —— 拾语
export const revalidate = 60;    // 60 秒后台重建一次

访问时若缓存过期,后台异步重建再返回新页;用户永远拿到「最多 N 秒旧」的页面,且不阻塞请求。

文章列表还利用了 ISR 对 ?page=N 的独立缓存:翻页到没看过的页码时才构建那一页,不需要把所有页一次建完。

四、SSR:为什么文章详情页「不是」SSG

这是本项目最值得记的一个坑。文章详情页 posts/[slug]/page.tsx 顶部写着:

// 强制动态渲染:next-intl 的 getTranslations 在静态生成(含 ISR on-demand)路径下会读
// headers(),触发 DYNAMIC_SERVER_USAGE 500。详情页改走普通 SSR,与首页/列表页一致。
export const dynamic = "force-dynamic";

发生了什么next-intl 的服务端 getTranslations 在静态生成 / ISR on-demand 重建路径下会读取 headers(),而 Next 16 在纯静态上下文里调用 headers() 会抛 DYNAMIC_SERVER_USAGE(动态 API 不能在静态生成里用)→ 详情页构建/重建直接 500。

怎么解决:整页改成 force-dynamic,让详情页走普通 SSR——每次请求查一次库渲染。文章量不大时这个代价可接受,换来的是稳定的可用性。

注意:页面上还留着 generateStaticParams() 枚举所有 slug,配合 force-dynamic 时它不再真正预渲染 HTML,但保留了「构建时校验 slug 都存在」的作用,且将来去掉 dynamic 就能无缝切回 SSG。

标签详情页 tags/[tag]/page.tsx 遇到的是同一个问题,同样用 force-dynamic 解决。

取舍结论:SSR 不是「性能差」,而是「实时性强」。对需要 headers() / cookies / session 的页面,SSR 是正确选择;能静态化才静态化,不能就老老实实 SSR。

五、revalidatePath:写操作后按语言精准刷新

时间维度兜底之外,写操作后要立即让前台更新。因为站点用了 next-intl,实际页面都挂在 /zh/en 前缀下/ 只是中间件重定向),所以刷新必须按语言逐个刷:

lib/revalidate.ts

function revalidateLocalePaths(path: `/${string}`) {
  for (const locale of routing.locales) {
    revalidatePath(`/${locale}${path === "/" ? "" : path}`);
  }
}

export function revalidatePostPaths(slug: string, tagSlugs: string[] = []) {
  revalidateLocalePaths("/");                 // 首页
  revalidateLocalePaths("/posts");            // 文章列表
  revalidateLocalePaths(`/posts/${slug}`);    // 本文详情
  revalidateLocalePaths("/tags");             // 标签页
  for (const tag of tagSlugs) revalidateLocalePaths(`/tags/${tag}`);
}

Hono 里写操作成功后调用(新建/更新/删除文章、评论审核、拾语增删改都会触发对应刷新):

revalidatePostPaths(post.slug, tagSlugs);   // 文章写操作
revalidatePath(`/zh/posts/${slug}`);        // 评论审核后
revalidateShiyuPaths();                     // 拾语写操作

为什么评论、拾语改完要刷前台:它们是 ISR / SSG 页面,改了数据库但 HTML 还是旧的,必须主动触发重建,否则用户看不到新内容。

六、generateStaticParams 与双语言静态化

根布局枚举语言:

// app/[locale]/layout.tsx
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));  // ['zh','en']
}

配合每个 [locale] 页面的 setRequestLocale(locale)(见国际化篇),/zh/en 两套页面各自静态生成,访客访问不查库直接命中 HTML。

七、客户端写操作 + router.refresh()

评论区、点赞这类交互是 client component,写完用 router.refresh() 重新拉取服务端数据:

const res = await fetch("/api/comments", { method: "POST", ... });
if (res.ok) {
  router.refresh();   // 重新执行当前页的 Server Component,拿到新评论
}

router.refresh() 不重载整个页面,而是让当前路由的 Server Component 重新跑一遍——服务端数据一变,UI 同步更新。配合 Hono 写操作 + revalidatePath,形成「写 DB → 刷新 ISR → 重新渲染」的闭环。

八、并行查询优化

页面一次性渲染多处数据,用 Promise.all 并行(Next 官方推荐写法,避免串行瀑布):

const [stats, posts] = await Promise.all([
  getStats(),                 // 首页统计
  getRecentPublishedPosts(5), // 最近文章
]);

九、一个易错点:为什么 ISR 页面「看不到」新数据

经典困惑:数据库有数据了,页面还是旧的

原因:ISR / SSG 页面的 HTML 是缓存/静态的,不查库。要更新必须:

  1. revalidate 秒数到期,后台自动重建;或
  2. 写操作后主动调 revalidatePath(项目里 Hono 已在做);或
  3. 重新部署(全量重建)。

项目策略:内容页 = 写操作后主动 revalidate;拾语/首页/列表 = 主动 revalidate + 时间到期兜底。双保险。

十、本节小结

  • SSG(标签列表、双语言)→ 构建时生成,访问零查询。
  • ISR(首页 5min、列表 60s、拾语 60s)→ 时间兜底,后台异步重建。
  • SSR(文章详情、标签详情、后台)→ force-dynamic 每次请求渲染;文章详情页是因 next-intl 读 headers() 的坑而从 SSG 改过来的。
  • revalidatePath → 写操作后按语言逐个刷,保证前台立即可见。
  • generateStaticParams → 枚举 slug / locale,动态路由静态化。
  • router.refresh() → 客户端写操作后重跑 Server Component。
标签 / TAGSNext.jsSSRSSGISR
L

Leo

博主

独立开发者 / Blogger,原博客「大道至简」维护者。正在把 WordPress 上攒了几年的文章与拾语迁移到 Next.js。

读者留言

COMMENTS · 0

发表留言

评论经审核后展示 · 请友善发言0/100