渲染模式 SSR / SSG / ISR
这一课你将能做到
- 用一句话区分静态生成(SSG)、服务端渲染(SSR)、增量静态再生(ISR),并说出各自典型场景
- 看
npm run build 输出的路由标记(○ / ● / ƒ),说出每个页面将以什么方式渲染
- 掌握
export const revalidate、dynamic 配置与「哪些代码会把页面打成动态」这两件核心工程技能
为什么要学
上一课的缓存决定了「数据打不打后端」,本课的渲染模式决定「页面在什么时候、在哪里被生成」——这是 Next.js 面试出题率最高的一块,没有之一。因为它是选型题:给你一个业务场景,问你用哪种模式,考的是真实工程判断力。
先把三类模式的本质用一张图钉住:
SSG(静态生成):页面在「构建时」生成好,存成静态文件
构建 → 出 HTML → 所有用户共享同一份 → CDN 直出,最快
SSR(服务端渲染):页面在「每次请求时」生成
用户请求 → 服务器现场取数渲染 → 返回 HTML → 人人不同
ISR(增量静态再生):SSG + 过期时间
构建/首次访问 → 缓存 N 秒 → 过期后后台重建 → 兼顾速度与新鲜度
对应到真实业务:产品官网、帮助文档 → SSG;用户个人中心、强实时大盘 → SSR;商品详情、文章页 → ISR。本站课程目录这类内容也适合 ISR 思路,而用户进度页必须走动态。
注意一个易混点:在 Next.js 语境里「动态渲染」≠ Client 渲染。SSR/SSG/ISR 说的是 HTML 在服务端什么时候生成;水合之后的交互永远是 React 的事。上一课的 Server/Client 组件划分,与渲染模式是正交的两个维度。
先建立图像
Next 如何决定一个页面是静态还是动态?看渲染输入里有没有「每次请求才知道的东西」:
页面里出现以下任一情况 → 动态渲染(ƒ):
1. fetch 使用了 cache: "no-store" 或 revalidate: 0
2. 读取了 cookies() / headers()
3. 页面接收并 await 了 searchParams
4. 读了动态段且未开启 generateStaticParams(见下)
5. 配置了 export const dynamic = "force-dynamic"
以上都没有 → 静态渲染(○ / ●),构建时出结果
build 输出的标记(务必眼熟):
Route (app) Size Revalidate
● / 5 kB ─ 静态
● /docs/[slug] … 1h 带参数的 ISR
○ /about 1 kB ─ 静态
ƒ /tracks/[slug]/lessons/[lessonSlug] … ─ 动态(按请求)
三种模式的响应时序对比:
SSG : 用户 ──────→ CDN 边缘节点 ──(命中预生成 HTML)──→ 秒回
SSR : 用户 ──→ 服务器:取数(几十~几百ms) + 渲染 ──→ 返回
ISR : 用户 ──→ 缓存新鲜?──是──→ 秒回
└─否──→ 先回旧版,后台重建,下次是新版
跟着做
步骤 1:观察一个默认静态页
// src/app/static-demo/page.tsx
export default function StaticPage() {
const builtAt = new Date().toISOString();
return <p className="p-6">本页构建于:{builtAt}</p>;
}
执行 npm run build && npm run start,访问 /static-demo,反复刷新——时间不变。因为它没有动态输入,Next 在 build 时就算好了 HTML,new Date() 只在构建那一刻执行过一次。
步骤 2:让它变成 ISR——加一个导出
// src/app/isr-demo/page.tsx
export const revalidate = 10; // 缓存 10 秒,过期后台重建
export default function IsrPage() {
const generatedAt = new Date().toISOString();
return <p className="p-6">本页生成于:{generatedAt}(每 10 秒后台刷新)</p>;
}
build + start 后观察:10 秒内刷新时间不变;超过 10 秒后第一次刷新仍是旧时间(先回旧缓存),再刷新变成新时间(后台已重建)。这正是 stale-while-revalidate,亲手看到一次胜过背十遍。
带动态段的 ISR 需要告诉 Next 有哪些参数可预渲染:
// src/app/tracks/[slug]/page.tsx
export const revalidate = 3600;
export async function generateStaticParams() {
const tracks = await getTracks(); // 构建时取一次全量列表
return tracks.map((t) => ({ slug: t.slug }));
}
export default async function TrackPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
return <h1 className="p-6 text-2xl">{slug} 的课程详情(ISR 1h)</h1>;
}
generateStaticParams 返回的参数在 build 时预渲染成静态页;用户访问了列表之外的 slug 时,Next 会按需渲染并缓存它(动态参数的 ISR 兜底)。
步骤 3:做一个真正的 SSR 页
// src/app/ssr-demo/page.tsx
export const dynamic = "force-dynamic"; // 强制每次请求都渲染
export default async function SsrPage() {
const res = await fetch("http://localhost:8080/api/server-time", {
cache: "no-store",
});
const { time } = await res.json();
return (
<main className="p-6">
<h1 className="text-2xl font-bold">实时服务器时间</h1>
<p className="mt-2">后端返回:{time}</p>
</main>
);
}
build 输出里这页会标 ƒ(Dynamic)。每次刷新时间都变——数据每次现取、HTML 每次现渲。
步骤 4:读 cookies 把页面打成动态(用户级页面的原理)
// src/app/me-demo/page.tsx
import { cookies } from "next/headers";
export default async function MePage() {
const token = (await cookies()).get("token")?.value;
return (
<main className="p-6">
{token ? <p>已登录,token 以 ****{token.slice(-4)} 显示</p> : <p>未登录</p>}
</main>
);
}
不需要任何配置,cookies() 一出现,build 输出自动变 ƒ。这就是「用户个人页为什么天生动态」的机制——读取请求专属信息(cookie/header)意味着结果因人而异,无法预生成共享。
步骤 5:用 build 输出做一次全面体检
对着输出的路由表逐行问自己:这页为什么是 ○/●/ƒ?答案总能归结到步骤开头的判定清单。以后每接手一个 Next 项目,先 build 一遍看这张表,渲染架构一目了然——这也是排查「页面为什么总是旧的/为什么这么慢」的第一步。
讲清楚要点
- 选型公式:内容对所有人相同且变化少 → SSG;内容因人而异或强实时 → SSR;内容相同但会定期变 → ISR。拿不准就 ISR(先静态后刷新,最平衡)。同一项目可以混用——Next 按「每个路由」独立决定模式。
- 静态与动态由「渲染输入」自动推断:没有请求数据的页面天生静态;用了
cookies()/headers()/searchParams/no-store 的页面天生动态。export const dynamic = "force-dynamic" 和 export const revalidate = N 是人工干预的两个总开关(前者强制动态,后者给静态页加过期时间)。
- SSR ≠ 慢,SSG ≠ 快就无脑用:SSR 页面可以把数据请求并行化、流式返回(先发骨架),TTFB 依然可观;SSG 用在「该动态而错配成静态」的场景(比如把用户页生成成静态)出的是逻辑事故,比慢严重得多。
- ISR 的正确心智:它是「把 SSR 的成本摊薄」——绝大多数请求由缓存承接,后端只在过期重建时被打一次。代价是存在最长 revalidate 秒的数据延迟窗口;不能容忍延迟的数据老老实实走动态。
- 渲染模式与本站对照:platform 课时页顶部
export const dynamic = "force-dynamic"——因为要读登录 token、返回个性化锁定状态,个人化页面必须动态;这是真实项目选型的活教材。
你可能会懵的地方
-
ISR 到期那一刻,用户看到的是新是旧?
旧。SWR 语义:过期后首个请求立刻返回缓存旧值,同时后台重建,重建完成后缓存更新。「最多旧 revalidate 秒」就是 ISR 的数据延迟上界,选型时按业务容忍度定这个数。
-
generateStaticParams 和不在名单里的 slug 怎么处理?
名单内的 build 时预渲染;名单外的首次访问时服务端按需渲染并缓存(等价于访问时 ISR),后续用户命中缓存。想改成「名单外一律 404」可配 dynamicParams = false。
-
静态页不是永久不变,为什么叫「静态」?
「静态」指「生成与请求解耦」,不是「永远不变」。build 全量重跑、revalidate 定时刷新、revalidateTag 主动刷新,都能更新静态产物。静态 = 默认不随请求变化,而非内容死亡。
-
SSR 页面里还能再缓存某个数据吗?
能,两个维度正交。页面动态(每次渲染)不妨碍其中某个 fetch 单独带 revalidate——页面渲染每请求都发生,但那个数据 5 分钟内直接用缓存。反之静态页里的 fetch 也可能因 revalidate 独立刷新。
-
整站该全选 force-dynamic 图省事吗?
不该。全动态等于放弃 Next.js 最大的性能资产,所有流量都压到渲染层与后端。正确姿势:让默认推断生效,仅对确需个性化的页面强制动态——本站也就是课时/进度这类页面才 force-dynamic。
错法对照
| 错法 | 问题 | 正解 |
|---|
用户个人中心配了 revalidate = 60 | A 的页面缓存可能展示给 B | 用户级页面 force-dynamic / no-store |
内容页忘了 generateStaticParams | 无法构建期预生成,首访变慢 | 列表数据构建时拉一次生成参数 |
| 看到页面「数据是旧的」就骂 SSR | 实际是缓存层级在生效 | 从 Data Cache → Route Cache 逐层排查 |
全站 export const dynamic = "force-dynamic" | 放弃静态优化,TTFB 与后端压力全面上升 | 默认推断 + 按页最小干预 |
在静态页用 new Date() 展示「当前时间」 | 时间是构建时刻的,用户看到冻结值 | 此需求走动态渲染或客户端展示 |
卡点急救
- build 通过,start 后动态页 500:动态页在请求时才取数,多半是生产环境后端不可达。检查生产环境变量里的 API 地址(容器内要用内网地址,本站
API_INTERNAL_URL 就是干这个的)。
- ISR 一直不刷新:确认是
npm run start 跑生产版(dev 模式行为不同);多个实例部署时缓存不共享,需一致的失效策略或接自定义缓存 Handler。
cookies() 报错只能在 Server 环境调用:把它放回 Server 组件/Server Action;Client 组件里请用 document.cookie 或由服务端读好经 props 传入。
面试常问
-
SSR、SSG、ISR 的区别与选型?(必考,背熟)
SSG 构建时生成、CDN 直出、最快,适合内容稳定的公开页;SSR 每次请求现场渲染、数据最新,适合个性化与强实时页面;ISR 以 revalidate 为周期让静态页后台重建,SWR 语义先回旧值,适合「内容共享但会变化」的场景(商品、文章)。选型三问:内容是否因人而异?变化频率?可容忍多少延迟?同一应用可按路由混用。
-
Next.js 怎么决定页面是静态还是动态?
自动推断 + 显式配置。读取 cookies/headers/searchParams、no-store 的 fetch、未预生成的动态段会把页面判为动态;否则静态。可用 export const dynamic = "force-dynamic" 强制动态、export const revalidate = N 给静态页加过期、generateStaticParams 声明动态段的预渲染名单。build 输出的路由表(●/ƒ 标记)就是最终判定结果。
-
ISR 数据延迟窗口怎么向业务交代?
上界是 revalidate 秒数,且过期后首个请求返回旧值(SWR)。方案:把 revalidate 调到业务可容忍的粒度;对时效敏感的字段拆到客户端单独请求或用 revalidateTag 事件驱动失效;交易类数据一律动态渲染。
自测三问
- 一个「课程列表 + 用户学习进度」页面,你会怎么拆分渲染策略?
- build 输出里 ○、●、ƒ 各代表什么?哪个标记最该引起警觉?
- ISR 第一次过期请求为什么返回旧数据?这个设计的收益是什么?
小结一句话
渲染模式的本质是「页面何时生成」:不变走 SSG、定期变走 ISR、因人而异走 SSR;Next 按「渲染输入是否依赖请求」自动判定,build 路由表是最终真相。
延伸