渲染策略 —— SSG / ISR / SSR 到底怎么选
静态生成、增量再生成、服务端渲染三者的区别与适用场景,以及本项目里每一类页面分别落在哪种策略、为什么文章详情页会从 SSG 改成 SSR
Leo
2025.10.17 · 更新于 2026.08.24
博客是「读多写少」的内容站,渲染策略直接决定性能和部署体验。这一篇先把三种策略讲透,再给出项目里每一类页面的真实分布——注意,文章详情页并不是 SSG,而是被迫改成了 SSR。
一、三种策略一句话
| 策略 | 何时渲染 | 何时更新 | 适合 |
|---|---|---|---|
| SSG(静态生成) | 构建时 | 重新部署 | 几乎不变的内容 |
| ISR(增量静态再生成) | 构建时 + 过期后后台重建 | revalidate 秒数 / revalidatePath 触发 | 变化不频繁、可接受短暂延迟 |
| SSR(服务端渲染) | 每次请求 | 每次请求 | 强实时、依赖用户态 |
Next 的默认约定:能静态就静态。页面若没有动态 API 也没有动态函数,默认就是 SSG / ISR。
二、项目里的真实分布地图
| 页面 | 策略 | 依据 |
|---|---|---|
首页 / | ISR(revalidate = 300) | 5 分钟自愈,写操作另有 on-demand 刷新 |
文章列表 /posts | ISR(revalidate = 60) | 每个 ?page=N 独立缓存,60s 重建 |
文章详情 /posts/[slug] | SSR(force-dynamic) | 见下文「为什么不是 SSG」 |
标签列表 /tags | SSG | 无 revalidate、无动态函数,默认静态 |
标签详情 /tags/[tag] | SSR(force-dynamic) | 与文章详情同理 |
拾语 /shiyu | ISR(revalidate = 60) | 高频更新,60s 后台重建 |
搜索 /search | SSR | 按 ?q= 实时查库 |
后台 /dashboard | SSR | 必须每次请求实时校验 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 是缓存/静态的,不查库。要更新必须:
revalidate秒数到期,后台自动重建;或- 写操作后主动调
revalidatePath(项目里 Hono 已在做);或 - 重新部署(全量重建)。
项目策略:内容页 = 写操作后主动 revalidate;拾语/首页/列表 = 主动 revalidate + 时间到期兜底。双保险。
十、本节小结
- SSG(标签列表、双语言)→ 构建时生成,访问零查询。
- ISR(首页 5min、列表 60s、拾语 60s)→ 时间兜底,后台异步重建。
- SSR(文章详情、标签详情、后台)→
force-dynamic每次请求渲染;文章详情页是因 next-intl 读headers()的坑而从 SSG 改过来的。 revalidatePath→ 写操作后按语言逐个刷,保证前台立即可见。generateStaticParams→ 枚举 slug / locale,动态路由静态化。router.refresh()→ 客户端写操作后重跑 Server Component。
读者留言
COMMENTS · 0发表留言