选完模式,问题才刚开始
10.1 节用页面特征选出了模式,10.2 节处理了首帧一致性。走到这里,很多团队会把结论写进架构文档,然后几年不再回看。渲染模式却和流量结构、内容更新频率、团队规模一起变化:上线时内容一天更新一次,一年后变成分钟级;上线时搜索流量占四成,一年后应用内入口占九成;上线时两个人维护 SSR,一年后只剩半个人。
这些变化不会自动触发重新选型。系统仍然能跑,只是成本慢慢变高,首屏慢慢变慢,直到某次大促或一次数据源故障把问题放大。退出判定的价值,是把“什么时候该重新讨论”从偶发的故障复盘,变成可以定期执行的检查。
本节先区分“迁移”与“升级”的语义,再给出五个信号、一个判定函数、一张迁移成本表和一次完整的决策记录。判定函数不判断哪种模式更先进,只判断当前模式成立的前提是否还在。
迁移不是升级,也不是降级
从 SSR 改成 SSG,常被说成“降级”;从 CSR 改成 SSR,常被说成“升级”。这两种说法都会误导决策。渲染模式之间没有统一的高低,只有前提与成本的匹配。
一次迁移至少改变五件事:HTML 在哪里生成、数据在哪里取、缓存粒度是什么、鉴权在哪一层、测试怎么覆盖。SSR 到 SSG 不是把 getServerSideProps 换成 getStaticProps,而是要把“按请求变化”的部分拆出去;CSR 到 SSR 也不只是加一个服务端入口,还要处理会话、超时、降级和双端一致性。
所以判定函数输出的不应该是“更好的模式”,而应该是“当前模式是否已经持续违反它成立的前提”。如果前提还在,即使另一种模式看起来更时髦,也应该明确返回 stay。
五个信号
第一个信号是首屏性能:LCP 与 TBT 的 P75。CSR 的典型问题是 LCP 被 JS 与数据请求拖长;SSR 的典型问题是 TTFB 随数据源变慢,但可交互时间未必更好。单看一个分位数不够,要看 P75,因为长尾用户才是迁移的受益者。
第二个信号是服务端成本:每千次请求的渲染 CPU、渲染相关错误率。SSR 在流量上涨时成本线性增长,错误率也会随下游超时上升。成本高不一定要退出,但如果成本高且 SEO 收益低,退出就有了依据。
第三个信号是缓存能力:边缘缓存命中率。SSG 与流式都依赖缓存,命中率低说明内容不够静态,或者缓存键设计有问题。命中率高而仍用 SSR,通常是明显的浪费。
第四个信号是内容结构:内容陈旧窗口与个性化首屏占比。这两个值决定“能不能预生成”。它们不是估算一次就固定,应该按周或按月统计。
第五个信号是产品结构:搜索流量占比与团队维护渲染链路的小时数。搜索流量低、维护成本高,说明 SSR 的收益可能已经小于负担;反过来,搜索流量高而首屏慢,说明 CSR 的前提可能已经不成立。
判定函数
下面的函数把五个信号收敛成一次迁移或维持的决定。它每次最多迁移一步,避免在一次评审里同时改渲染、缓存和鉴权;没有命中条件时明确返回 stay,并给出 90 天后的复核日期。
// file: src/render/render-exit.ts
// tier: L1
// verify: node --test test/render/render-exit.test.ts
export type Mode = "csr" | "ssr" | "ssg" | "streaming";
export interface RenderMetrics {
/** LCP P75(毫秒) */
lcpP75Ms: number;
/** 可交互前主线程阻塞 P75(毫秒) */
tbtP75Ms: number;
/** 每千次请求的服务端渲染成本(相对单位) */
serverCostPer1k: number;
/** 渲染相关错误率(0-1) */
errorRate: number;
/** 边缘缓存命中率(0-1) */
cacheHitRatio: number;
/** 内容陈旧窗口(分钟) */
contentVolatilityMinutes: number;
/** 个性化首屏占比(0-1) */
personalizedShare: number;
/** 搜索流量占比(0-1) */
seoTrafficShare: number;
/** 团队每周维护渲染链路的小时数 */
maintenanceHoursPerWeek: number;
}
export type ExitAction = "stay" | "migrate";
export interface ExitDecision {
action: ExitAction;
from: Mode;
to: Mode;
reasons: string[];
/** 迁移成本,人周 */
migrationWeeks: number;
/** 建议复核日期(ISO 日期) */
reviewAt: string;
}
/** 这些阈值是案例假设值,团队要按自己的流量结构与预算重新标定。 */
const LCP_BUDGET_MS = 2500;
const TBT_BUDGET_MS = 200;
const VOLATILE_MINUTES = 60;
const DAILY_MINUTES = 24 * 60;
const PERSONALIZED_SHARE_MAX = 0.2;
const SEO_SHARE_MIN = 0.15;
const CACHE_HIT_MIN = 0.8;
const SERVER_COST_HIGH = 5;
const MAINTENANCE_MAX_HOURS = 8;
const MIGRATION_WEEKS: Record<Mode, Record<Mode, number>> = {
csr: { csr: 0, ssr: 4, ssg: 3, streaming: 6 },
ssr: { csr: 2, ssr: 0, ssg: 3, streaming: 2 },
ssg: { csr: 1, ssr: 3, ssg: 0, streaming: 4 },
streaming: { csr: 2, ssr: 1, ssg: 3, streaming: 0 },
};
/**
* 退出判定不是“哪种模式更先进”,而是当前模式是否已经持续违反它成立的前提。
* 每次最多迁移一步;没有命中退出条件就明确返回 stay,避免为了动作而动作。
*/
export function evaluateExit(from: Mode, metrics: RenderMetrics, decidedOn: string): ExitDecision {
const target = nextMode(from, metrics);
const action: ExitAction = target === from ? "stay" : "migrate";
return {
action,
from,
to: target,
reasons: target === from ? stayReasons(from, metrics) : migrateReasons(from, target, metrics),
migrationWeeks: action === "stay" ? 0 : MIGRATION_WEEKS[from][target],
reviewAt: reviewDate(decidedOn, action === "stay" ? 90 : 30),
};
}
function nextMode(from: Mode, metrics: RenderMetrics): Mode {
if (from === "ssr") {
if (staticEnough(metrics)) return "ssg";
if (
metrics.seoTrafficShare < SEO_SHARE_MIN &&
metrics.tbtP75Ms <= TBT_BUDGET_MS &&
metrics.maintenanceHoursPerWeek > MAINTENANCE_MAX_HOURS
) {
return "csr";
}
return "ssr";
}
if (from === "csr") {
if (metrics.lcpP75Ms > LCP_BUDGET_MS) {
return staticEnough(metrics) ? "ssg" : "ssr";
}
return "csr";
}
if (from === "ssg") {
if (
metrics.personalizedShare >= PERSONALIZED_SHARE_MAX ||
metrics.contentVolatilityMinutes < VOLATILE_MINUTES
) {
return "ssr";
}
return "ssg";
}
if (staticEnough(metrics)) return "ssg";
if (metrics.lcpP75Ms <= 1500 && metrics.serverCostPer1k > SERVER_COST_HIGH) return "ssr";
return "streaming";
}
function staticEnough(metrics: RenderMetrics): boolean {
return (
metrics.personalizedShare < PERSONALIZED_SHARE_MAX &&
metrics.contentVolatilityMinutes >= DAILY_MINUTES &&
metrics.cacheHitRatio >= CACHE_HIT_MIN
);
}
function migrateReasons(from: Mode, to: Mode, metrics: RenderMetrics): string[] {
if (to === "ssg") {
return [
`个性化首屏占比 ${pct(metrics.personalizedShare)} 低于阈值`,
`内容陈旧窗口 ${metrics.contentVolatilityMinutes} 分钟,长于构建周期`,
`缓存命中率 ${pct(metrics.cacheHitRatio)},预生成后可直接走边缘`,
];
}
if (to === "csr") {
return [
`搜索流量占比 ${pct(metrics.seoTrafficShare)} 低于阈值`,
`TBT P75 ${metrics.tbtP75Ms}ms 在预算内,客户端有能力接管渲染`,
`每周维护 ${metrics.maintenanceHoursPerWeek} 小时,退出 SSR 可释放服务端链路`,
];
}
if (to === "ssr") {
return [
metrics.lcpP75Ms > LCP_BUDGET_MS
? `LCP P75 ${metrics.lcpP75Ms}ms 超出 ${LCP_BUDGET_MS}ms 预算`
: `首屏个性化占比 ${pct(metrics.personalizedShare)} 或内容陈旧窗口要求逐请求`,
from === "ssg" ? "预生成产物跟不上内容变化或用户差异" : "服务端渲染能更早给出完整首屏",
];
}
return [
`个性化首屏占比 ${pct(metrics.personalizedShare)} 低于阈值`,
`缓存命中率 ${pct(metrics.cacheHitRatio)},可整体预生成`,
];
}
function stayReasons(from: Mode, metrics: RenderMetrics): string[] {
if (from === "ssr") {
return [
"个性化占比、内容新鲜度或 SEO 依赖仍成立,SSR 前提未被推翻",
`每千次渲染成本 ${metrics.serverCostPer1k}、错误率 ${pct(metrics.errorRate)} 尚在可接受范围`,
];
}
if (from === "csr") {
return [
`LCP P75 ${metrics.lcpP75Ms}ms 未超预算,客户端渲染仍能满足首屏`,
`搜索流量占比 ${pct(metrics.seoTrafficShare)},服务端渲染收益不足`,
];
}
if (from === "ssg") {
return [
"内容非个性化且陈旧窗口稳定,预生成仍然成立",
`缓存命中率 ${pct(metrics.cacheHitRatio)},构建产物没有明显过期`,
];
}
return [
`LCP P75 ${metrics.lcpP75Ms}ms 与每千次成本 ${metrics.serverCostPer1k} 未同时触发退出`,
"流式带来的外壳复用仍在抵消复杂度",
];
}
function pct(value: number): string {
return `${Math.round(value * 100)}%`;
}
function reviewDate(decidedOn: string, days: number): string {
return new Date(Date.parse(decidedOn) + days * 86_400_000).toISOString().slice(0, 10);
}
/** 把一次判定固化成可评审的决策记录,避免“当时为什么这么选”只留在聊天里。 */
export function recordDecision(
decision: ExitDecision,
metrics: RenderMetrics,
decidedOn: string,
): string {
const verdict = decision.action === "stay" ? "维持现状" : `迁移到 ${decision.to}`;
return [
"## 渲染模式决策记录",
`- 决策日期:${decidedOn}`,
`- 当前模式:${decision.from}`,
`- 结论:${verdict}`,
`- 迁移成本:${decision.migrationWeeks} 人周`,
`- 复核日期:${decision.reviewAt}`,
"- 依据:",
...decision.reasons.map((reason) => ` - ${reason}`),
`- 观测:LCP P75 ${metrics.lcpP75Ms}ms,TBT P75 ${metrics.tbtP75Ms}ms,` +
`搜索流量 ${pct(metrics.seoTrafficShare)},个性化 ${pct(metrics.personalizedShare)},` +
`缓存命中 ${pct(metrics.cacheHitRatio)}`,
].join("\n");
}
函数里有三个值得说明的取舍。
第一,ssr 的退出顺序是先看能否静态化,再看能否退回 CSR。因为静态化的收益通常更大:服务端 CPU 直接归零,TTFB 降到边缘缓存水平;退回 CSR 只是把渲染成本转移给客户端,首屏未必更好。
第二,csr 的退出条件只看 LCP 是否超预算,但目标模式会区分能否静态化。如果内容本身适合预生成,直接到 SSG 比先到 SSR 再迁 SSG 少一次迁移。
第三,迁移成本用固定矩阵表示,单位是人周。矩阵是案例假设值,团队应该按自己的代码规模、测试覆盖和发布流程重新估算。成本高于收益时,正确的决定可能是继续维持,而不是“既然发现了问题就必须改”。
运行结果
cd .local/checks/echo-client-lab
node --test test/render/render-exit.test.ts
✔ SSR 的内容前提消失后迁移到 SSG (6.294292ms)
✔ SSR 失去 SEO 依赖且维护成本高时退回 CSR (0.129333ms)
✔ CSR 首屏超预算且有搜索流量时迁移到 SSR (0.10225ms)
✔ CSR 首屏超预算但内容可静态化时直接迁移到 SSG (0.069958ms)
✔ SSG 出现个性化或分钟级内容时迁移到 SSR (0.128459ms)
✔ 前提仍成立时明确维持现状,迁移成本为零 (0.109875ms)
✔ 流式在高成本且首屏达标时简化回 SSR (0.064916ms)
✔ 决策记录包含结论、成本、依据与观测值 (0.16575ms)
✔ 同一输入重复判定得到相同记录 (0.478583ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 242.463583
九组测试覆盖了 SSR 的两种退出、CSR 的两种目标、SSG 的两类失效、流式简化、维持现状、决策记录内容与重复判定稳定性。所有阈值都在测试里以具体场景体现,不依赖真实流量。
一次完整的决策记录
下面这条记录由 recordDecision 直接生成,输入是一组案例假设指标:LCP P75 2600ms、个性化占比 5%、内容陈旧窗口 3 天、缓存命中 90%、搜索流量 42%。
cd .local/checks/echo-client-lab
node --input-type=module -e '
import { evaluateExit, recordDecision } from "./src/render/render-exit.ts";
const m = { lcpP75Ms: 2600, tbtP75Ms: 180, serverCostPer1k: 3.2, errorRate: 0.004, cacheHitRatio: 0.9, contentVolatilityMinutes: 4320, personalizedShare: 0.05, seoTrafficShare: 0.42, maintenanceHoursPerWeek: 6 };
const d = evaluateExit("ssr", m, "2026-10-10");
console.log(recordDecision(d, m, "2026-10-10"));
'
## 渲染模式决策记录
- 决策日期:2026-10-10
- 当前模式:ssr
- 结论:迁移到 ssg
- 迁移成本:3 人周
- 复核日期:2026-11-09
- 依据:
- 个性化首屏占比 5% 低于阈值
- 内容陈旧窗口 4320 分钟,长于构建周期
- 缓存命中率 90%,预生成后可直接走边缘
- 观测:LCP P75 2600ms,TBT P75 180ms,搜索流量 42%,个性化 5%,缓存命中 90%
这条记录可以直接贴进架构决策日志。它至少回答了四个问题:当时是什么模式、结论是什么、依据是哪几个指标、什么时候必须回来看。没有第四条,决策记录会变成“历史考古”,而不是“运行中的检查点”。
迁移成本矩阵
| 从 / 到 | CSR | SSR | SSG | 流式 |
|---|---|---|---|---|
| CSR | 0 | 4 | 3 | 6 |
| SSR | 2 | 0 | 3 | 2 |
| SSG | 1 | 3 | 0 | 4 |
| 流式 | 2 | 1 | 3 | 0 |
单位是人周,数字为案例假设值。矩阵的对角线是零,表示维持现状没有迁移成本;从 CSR 到流式的成本最高,因为要同时引入服务端渲染、片段协议与水合边界;从流式回到 SSR 的成本最低,因为大部分基础设施已经存在。
成本矩阵的作用不是精确报价,而是阻止“顺手一起改”。一次评审如果同时提出换渲染模式、换缓存层、换鉴权方案,实际成本会远高于矩阵相加,因为三套变更的失败模式会互相掩盖。
退出的三个前提
判定函数能给出方向,但真正执行迁移前,还要确认三个前提。
可观测性足够。 如果 LCP、TBT、缓存命中率、渲染错误率都没有稳定采集,判定函数读到的只是噪声。迁移前至少要有四周的基线,能区分工作日与高峰、新用户与老用户。
回退路径存在。 迁移不是单向门。SSR 到 SSG 要保留按需重建与回退到 SSR 的开关;CSR 到 SSR 要保留静态兜底。没有回退路径的迁移,会把一次性能优化变成一次不可逆的架构赌博。
收益可以被验证。 迁移前写下预期改善的指标与观察窗口,迁移后按同一口径对比。如果只有“感觉快了”,就无法判断是否应该继续投入。
这三个前提不满足时,正确的动作不是“先迁了再说”,而是先补观测、先设计回退、先定义验收。判定函数只负责指出方向,不负责替团队承担执行风险。
一次没有执行的迁移
某次评审里,一个登录后的工作台被判定为“SSR 可以退回 CSR”:搜索流量占比 4%,TBT P75 在预算内,维护成本每周 12 小时。按函数输出,迁移成本 2 人周,收益是释放服务端渲染链路。
评审最后决定暂不迁移,理由有三个:
- 工作台的首屏包含跨服务聚合,客户端直接请求会让首屏从 1.8 秒变成 2.6 秒(案例假设值),LCP 会进入黄区。
- 团队正在做权限模型改造,此时改渲染路径会让故障归因变难。
- 迁移收益主要是运维成本,而运维成本可以在不改架构的情况下先通过缓存与降载优化。
这个决定被写进了决策记录,复核日期设在 90 天后。它说明“判定函数建议迁移”不等于“必须马上迁移”。函数负责发现前提变化,团队负责排序与时机。
小结
渲染模式的选择不是一次性动作。内容新鲜度、个性化比例、搜索流量、缓存命中与维护成本都会变化,判定函数把这些变化收敛成一次最多一步的迁移建议,并在没有触发条件时明确维持现状。
迁移成本必须和收益放在同一张桌上。固定矩阵不精确,但能阻止“顺手一起改”;三个前提——可观测、可回退、可验证——决定了一次迁移是优化还是赌博。决策记录则保证半年后还能回答“当时为什么这么选”。
至此,渲染路径的三个问题都有了工具:怎么选、怎么保证首屏一致、什么时候退出。下一章转向渲染之后的故障处理:错误边界、弱网降级与端上遥测。
阅读导航:上一节:10.2 水合与首屏一致性 · 下一节:11.1 错误边界与白屏治理 。
常见错误与边界
- 把迁移当成升级。渲染模式没有统一高低,只有前提与成本是否匹配。
- 只看平均值。首屏问题集中在长尾,P75 比均值更能暴露迁移收益。
- 在没有基线时执行迁移。没有四周观测数据,判定函数读到的只是噪声。
- 一次改多个变量。渲染、缓存、鉴权同时改,失败时无法归因。
- 不设计回退路径。单向迁移会把性能优化变成架构赌博。
- 把迁移成本矩阵当报价。它是相对估算,真实成本要按代码规模与测试覆盖重算。
- 认为判定函数建议迁移就必须执行。时机、团队负荷与并行改造都要纳入决策。
- 维持现状时不写理由。
stay也是一次决策,同样需要记录与复核日期。 - 决策记录只写结论不写指标。没有观测值的记录无法复核。
- 用行业阈值代替自己的阈值。案例里的 2500ms、200ms、20% 都需要按产品重新标定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。