《互联网产品的客户端架构·端形态与体验基础》10.3 渲染模式的选择与退出

渲染模式不是一次选型就结束,而是持续核对前提的决策;本节给出退出判定函数、迁移成本矩阵与一次完整决策记录,用九组测试验证 SSR 何时退回 CSR、CSR 何时升级 SSG,并说明为什么没有触发条件时也要留下复核日期,判定不追求先进模式,只追问前提是否仍然成立。

选完模式,问题才刚开始

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%

这条记录可以直接贴进架构决策日志。它至少回答了四个问题:当时是什么模式、结论是什么、依据是哪几个指标、什么时候必须回来看。没有第四条,决策记录会变成“历史考古”,而不是“运行中的检查点”。

迁移成本矩阵

从 / 到CSRSSRSSG流式
CSR0436
SSR2032
SSG1304
流式2130

单位是人周,数字为案例假设值。矩阵的对角线是零,表示维持现状没有迁移成本;从 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% 都需要按产品重新标定。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查