先问页面,再问框架
渲染模式经常被当成框架选型:用了某个元框架就默认上了 SSR,用了某个单页框架就默认上了 CSR。这个顺序反了。CSR、SSR、SSG 与流式描述的是“HTML 在什么时候、在哪里生成”,同一套组件代码可以跑在四种模式里,框架只是把其中几种变得便宜。
真正决定模式的是页面特征:它是否承载搜索流量,首屏是否因人而异,内容多久过期,最慢的数据源有多慢,交互有多密,是否需要登录,外壳能否先于数据输出。把这些特征先写清楚,模式往往只剩一个合理答案;反过来先选模式,再给模式找理由,迁移成本会在半年后出现。
「回声」的四类页面正好覆盖四种模式。帮助中心文章长期不变、需要被搜索到;商品详情页有库存与价格、需要 SEO;登录后的收件箱完全个性化、不需要抓取;订单详情页个性化但外壳稳定,最慢的物流查询要等 800 毫秒。它们不应该共用一条渲染路径。
本节先拆成本,再把判断写成可运行的函数,最后复盘一次真实迁移。文中出现的耗时、比例与成本,除非标明为本机测试输出,都是案例假设值。
四种模式各自为什么存在
CSR:把渲染推给客户端
CSR 返回一个近乎空的 HTML 外壳,浏览器下载 JS、执行组件、请求数据,再生成首屏。它的优势很直接:服务端只发静态文件,渲染 CPU 为零,前端可以像写桌面应用一样组织交互;部署也最简单,CDN 放几个文件即可。
代价同样直接。首字节很快,但首屏要等 JS 下载、解析、执行和数据请求串行完成;在低端机上,这段等待会从一秒变成三四秒。搜索引擎虽然能执行 JS,但抓取预算、渲染队列和分享卡片的抓取器都不保证执行,依赖搜索流量的页面不能把正文押在客户端。
CSR 适合登录后的高频交互页面:收件箱、设置、编辑器、后台看板。这些页面本来就不该被索引,首屏可以先用骨架占位,用户对“进入应用后加载一下”也有预期。
SSR:每次请求都算一遍
SSR 在服务端执行组件,把完整 HTML 发给浏览器,浏览器再下载 JS 做水合。它的优势是首屏内容与 HTML 同时到达,爬虫和分享卡片都能拿到正文;个性化页面也能在服务端按会话拼好。
代价是每次请求都要占用服务端 CPU,而且渲染依赖的数据源一旦变慢,TTFB 会直接被拖长。缓存能缓解,但个性化内容天然难缓存;一旦给个性化页面加了错误粒度的缓存,用户会看到别人的购物车。
SSR 适合两类页面:内容新鲜度以分钟计、来不及预生成的页面;以及必须按会话渲染、同时又需要 SEO 或快速首屏的页面。它不适合“内容一周不变”的文章站,那是用逐请求成本买一个构建期就能解决的问题。
SSG:在构建时算好
SSG 在构建阶段生成 HTML,部署后由 CDN 直接返回。它的 TTFB 最低,源站 CPU 几乎为零,缓存命中率最高;内容更新走重新构建与发布,天然带版本。
代价是新鲜度受构建周期限制。价格、库存、秒杀倒计时这类分钟级变化,预生成产物会迅速过期;个性化首屏也无法在构建时完成。另一个隐性成本是构建规模:页面数量到十万级时,全量构建的时间和发布窗口会成为新的瓶颈。
SSG 适合文档、博客、营销页、帮助中心、商品详情中变化不频繁的部分。判断的关键不是“页面是不是静态”,而是“内容陈旧窗口是否长于构建周期”。
流式:先给外壳,再补数据
流式渲染仍然是服务端渲染,但它不等所有数据齐了才发第一个字节。服务端先输出可复用的外壳与占位,再按片段把慢数据补进去。用户更早看到页面结构,感知等待下降;爬虫最终也能拿到完整内容。
代价是复杂度:需要管理片段顺序、错误隔离与降级,水合也要处理“某些区域后到”的情况。如果首屏外壳本身依赖数据,流式就退化成普通 SSR,只剩额外复杂度。
流式适合“个性化 + 外壳稳定 + 至少一个慢数据源”的页面。订单详情是典型:标题、导航、操作区可以先出,物流轨迹慢慢补。反过来,一个所有区域都依赖同一份慢查询的页面,流式帮不上忙。
成本要放在同一张表里比
四种模式的成本项不同,单看一个指标会得出相反结论。把关键成本放在一起:
| 维度 | CSR | SSR | SSG | 流式 |
|---|---|---|---|---|
| 首字节来源 | 静态文件 | 逐请求计算 | 构建产物 | 外壳先发 |
| 服务端渲染 CPU | 无 | 高 | 构建时一次 | 中 |
| 客户端首屏 JS | 最多 | 中 | 中 | 中 |
| 内容新鲜度 | 请求时 | 请求时 | 构建周期 | 请求时 |
| 搜索与分享 | 弱 | 强 | 强 | 强 |
| 个性化首屏 | 客户端补 | 支持 | 不支持 | 支持 |
| 主要风险 | 首屏慢 | 成本与超时 | 产物过期 | 片段复杂度 |
这张表不能直接加权求和。比如“客户端 JS 少”在营销页上是优点,在重交互编辑器里可能意味着功能被砍。正确做法是先排除不满足硬条件的模式,再在剩下的模式里比较成本。
硬条件通常只有三条:需要被抓取就不能只靠 CSR;首屏必须个性化就不能纯 SSG;内容分钟级变化就不能把新鲜度押在构建周期上。三条一过,候选往往只剩一个或两个。
把判断写成选择器
下面的选择器把七项页面特征作为输入,按固定优先级输出模式、理由、被拒绝的选项与相对成本。它不是通用标准,而是把团队已经讨论过的判断顺序固化下来,让每次评审从同一张桌子开始。
// file: src/render/render-modes.ts
// tier: L1
// verify: node --test test/render/render-modes.test.ts
export type RenderMode = "csr" | "ssr" | "ssg" | "streaming";
export interface PageTraits {
/** 页面是否承载搜索流量,爬虫必须拿到完整正文 */
seoCritical: boolean;
/** 首屏是否因登录用户不同而不同 */
personalized: boolean;
/** 内容允许的最大陈旧时间(分钟);Infinity 表示可长期缓存 */
contentVolatilityMinutes: number;
/** 渲染首屏所需最慢数据源的 P75 耗时(毫秒) */
slowestDataSourceMs: number;
/** 页面交互密度 */
interactionLevel: "low" | "medium" | "high";
/** 是否需要登录才能访问 */
authRequired: boolean;
/** 首屏外壳是否能在数据到达前先输出 */
shellStable: boolean;
}
export interface RenderCost {
/** 每千次请求的服务端渲染 CPU 秒 */
serverCpuSecPer1k: number;
/** 客户端首屏 JS 体积(KB,未压缩) */
clientJsKb: number;
/** 缓存命中时的 TTFB(毫秒) */
cachedTtfbMs: number;
/** 首屏可交互时间(毫秒) */
interactiveMs: number;
}
export interface RenderDecision {
mode: RenderMode;
reasons: string[];
rejected: { mode: RenderMode; reason: string }[];
cost: RenderCost;
}
/** 内容陈旧窗口的分界:一分钟级必须逐请求,一天以上可预生成。 */
const VOLATILE_MINUTES = 60;
const DAILY_MINUTES = 24 * 60;
/** 慢数据源的分界:超过 300ms 时,先吐外壳比等整页更有意义。 */
const SLOW_SOURCE_MS = 300;
const REJECTION: Record<RenderMode, string> = {
csr: "首屏需要完整 HTML 或搜索流量占比高,纯客户端渲染会让首屏与抓取变差",
ssr: "内容可以预生成或首屏不需要逐请求计算,逐请求渲染浪费服务端 CPU",
ssg: "首屏个性化或内容陈旧窗口短于构建周期,预生成产物会立刻过期",
streaming: "首屏外壳依赖数据,无法在数据到达前输出稳定骨架",
};
const BASE: Record<RenderMode, RenderCost> = {
csr: { serverCpuSecPer1k: 0, clientJsKb: 180, cachedTtfbMs: 20, interactiveMs: 1200 },
ssr: { serverCpuSecPer1k: 0.9, clientJsKb: 60, cachedTtfbMs: 250, interactiveMs: 600 },
ssg: { serverCpuSecPer1k: 0.02, clientJsKb: 60, cachedTtfbMs: 30, interactiveMs: 400 },
streaming: { serverCpuSecPer1k: 0.6, clientJsKb: 70, cachedTtfbMs: 90, interactiveMs: 450 },
};
/** 数据源耗时对可交互时间的贡献:CSR 全等,SSG 不等待,流式只等一部分。 */
const DATA_WAIT_FACTOR: Record<RenderMode, number> = {
csr: 1,
ssr: 1,
ssg: 0,
streaming: 0.4,
};
/**
* 按固定优先级选择渲染模式。
* 顺序有意为之:先排除“根本不该由服务端渲染”的页面,再判断 SEO 与个性化,
* 最后才在新鲜度与构建周期之间做取舍。
*/
export function chooseRenderMode(traits: PageTraits): RenderDecision {
const mode = pick(traits);
return {
mode,
reasons: reasonsFor(mode, traits),
rejected: (Object.keys(REJECTION) as RenderMode[])
.filter((candidate) => candidate !== mode)
.map((candidate) => ({ mode: candidate, reason: REJECTION[candidate] })),
cost: estimateCost(traits, mode),
};
}
function pick(traits: PageTraits): RenderMode {
if (!traits.seoCritical && traits.authRequired && traits.interactionLevel === "high") {
return "csr";
}
if (traits.seoCritical && traits.personalized) {
if (traits.shellStable && traits.slowestDataSourceMs >= SLOW_SOURCE_MS) {
return "streaming";
}
return "ssr";
}
if (traits.seoCritical && traits.contentVolatilityMinutes <= VOLATILE_MINUTES) {
return "ssr";
}
if (!traits.seoCritical && traits.interactionLevel === "high") {
return "csr";
}
if (!traits.personalized && traits.contentVolatilityMinutes >= DAILY_MINUTES) {
return "ssg";
}
return "ssr";
}
function reasonsFor(mode: RenderMode, traits: PageTraits): string[] {
if (mode === "csr") {
const reasons = ["首屏不需要搜索引擎抓取,交互密度高"];
reasons.push(traits.authRequired ? "登录后页面只存在于浏览器会话中" : "首屏可先给骨架,数据由客户端补齐");
reasons.push("静态托管即可,服务端渲染 CPU 为零");
return reasons;
}
if (mode === "ssr") {
const reasons = traits.personalized
? ["首屏按会话不同,必须逐请求渲染"]
: ["内容陈旧窗口短于预生成周期,构建产物会立即过期"];
if (traits.seoCritical) reasons.push("需要把完整正文交给爬虫与分享卡片");
return reasons;
}
if (mode === "streaming") {
return [
"首屏个性化,但外壳可以复用",
`最慢数据源 ${traits.slowestDataSourceMs}ms,先输出外壳能缩短感知等待`,
"数据到达后按片段补位,不阻塞首字节",
];
}
return [
"内容非个性化且陈旧窗口长于构建周期",
"预生成后可走边缘缓存,TTFB 与源站 CPU 都最低",
];
}
/** 成本是相对估算:只用于同一页面不同模式之间的比较,不是跨产品基准。 */
export function estimateCost(traits: PageTraits, mode: RenderMode): RenderCost {
const base = BASE[mode];
return {
...base,
interactiveMs: Math.round(
base.interactiveMs + traits.slowestDataSourceMs * DATA_WAIT_FACTOR[mode],
),
};
}
代码里有两个刻意的设计。第一,判断顺序固定:先排除“根本不该由服务端渲染”的页面,再判断 SEO 与个性化,最后才在新鲜度与构建周期之间取舍。第二,拒绝理由不是空字符串,每种未选中的模式都带一条具体理由,评审时可以直接看到“为什么不是它”。
阈值只有三个:内容陈旧窗口 60 分钟与 24 小时,慢数据源 300 毫秒。它们都是案例假设值,不是行业标准。团队应该按自己的发布频率、数据源分布和缓存能力重新标定,并把标定结果写进决策记录。
运行结果
在本机用 Node 26 直接跑 TypeScript,不需要先编译:
cd .local/checks/echo-client-lab
node --test test/render/render-modes.test.ts
✔ 长陈旧窗口的内容页选择 SSG (0.672209ms)
✔ 登录后的高频交互页选择 CSR (0.116916ms)
✔ 个性化且外壳不稳定时退回 SSR (0.085042ms)
✔ 个性化、外壳稳定且数据源慢时选择流式 (0.090875ms)
✔ 分钟级新鲜度要求逐请求渲染 (0.08075ms)
✔ 同一页面下成本排序符合直觉 (0.065083ms)
✔ 每种模式都会拒绝另外三种并给出理由 (0.182166ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 152.627416
七组测试覆盖了四种模式的典型页面、分钟级新鲜度、成本排序以及“拒绝列表不含自身”的结构约束。测试只验证纯逻辑,不依赖浏览器、DOM 或任何框架。
三个页面走一遍
把「回声」的三个页面代入选择器:
| 页面 | SEO | 个性化 | 陈旧窗口 | 最慢数据源 | 结论 |
|---|---|---|---|---|---|
| 帮助中心文章 | 是 | 否 | 7 天 | 80ms | SSG |
| 商品详情 | 是 | 否 | 5 分钟 | 200ms | SSR |
| 收件箱 | 否 | 是 | 请求时 | 400ms | CSR |
| 订单详情 | 是 | 是 | 请求时 | 800ms | 流式 |
商品详情的结论值得多说一句:它有个性化的推荐模块,但主体价格与库存对所有人一致。如果推荐模块可以客户端补,主体仍可按 SSG 处理;如果价格必须实时,整页就落到 SSR。选择器把 personalized 当成整页属性,实际系统里更常见的是“页面分区各自选模式”,这属于下一层细化,不改变判断顺序。
收件箱选择 CSR 的前提是它不需要被抓取。一旦产品决定让站内搜索抓取收件箱里的公开内容,seoCritical 变为真,结论会立刻变成 SSR 或流式。渲染模式跟着产品事实走,不跟着技术偏好走。
一次从 SSR 改回 SSG 的复盘
某次商品详情页的迁移是这样的(数字为案例假设值):
- 迁移前:SSR 逐请求渲染,P75 TTFB 420ms,源站渲染 CPU 每千次请求约 0.9 秒,大促前需要额外扩容。
- 观察到:90% 的商品价格与库存变化发生在 15 分钟以外,页面主体可按 5 分钟缓存;只有推荐模块需要个性化。
- 迁移后:主体改为 SSG 加按需重建,推荐模块客户端补;P75 TTFB 降到 60ms,源站 CPU 降到每千次约 0.05 秒。
- 代价:推荐模块从服务端直出变成客户端请求,首屏推荐位晚约 300ms;构建流水线增加了按商品变更触发的增量重建。
这次迁移成立的关键不是“SSG 更先进”,而是页面主体满足三个前提:非个性化、陈旧窗口长于重建周期、缓存命中率足够高。推荐模块被拆出去,是因为它不满足前两条,而不是因为它不重要。
如果价格真的做到秒级变化,这次迁移就应该被回退。退出条件必须提前写清楚,不能等故障发生后再讨论。10.3 节会把退出判定写成函数。
小结
CSR、SSR、SSG 与流式不是四个框架,而是四种“HTML 何时何地生成”的取舍。CSR 把成本推给客户端,SSR 把成本放在每次请求,SSG 把成本前移到构建,流式用外壳换感知速度。没有一种模式在所有页面上更优。
选择顺序应该由硬条件驱动:能否被抓取、首屏是否个性化、内容新鲜度是否短于构建周期。硬条件排除后,再比较服务端 CPU、客户端 JS、缓存命中与复杂度。把顺序写成函数,评审就有了共同起点;把拒绝理由写进输出,讨论就不会退化成偏好之争。
选择器也有边界:它把页面当成一个整体,真实页面往往是分区混合渲染;它用固定阈值,真实团队需要自己标定;它不计算迁移成本,而迁移成本经常比运行成本更大。下一节先处理渲染路径里最容易出事故的一环:服务端与客户端首帧不一致。
阅读导航:上一节:9.3 缓存与版本策略 · 下一节:10.2 水合与首屏一致性 。
常见错误与边界
- 把渲染模式当成框架属性。同一框架可以配置出多种模式,先看页面特征。
- 默认所有页面都上 SSR。内容站会为构建期就能解决的问题持续支付服务端 CPU。
- 默认所有页面都上 SSG。个性化首屏与分钟级内容会让产物一上线就过期。
- 只看 TTFB 不看可交互时间。SSR 的首字节快,但水合与数据请求仍可能让页面长时间不可用。
- 把流式当成性能银弹。外壳依赖数据时,流式只剩复杂度,没有收益。
- 把页面当成不可分割的整体。主体与推荐模块的新鲜度、个性化程度经常不同。
- 用固定阈值当行业标准。60 分钟、24 小时、300 毫秒都是案例假设值。
- 只记录选了什么,不记录拒绝了什么。半年后没人记得当时的取舍依据。
- 认为迁移是一次性动作。数据源、流量结构与发布频率变化后,前提需要重新核对。
- 在测试里断言具体毫秒数。成本模型是相对估算,断言排序关系比断言绝对值稳定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。