SSR/SSG/ISR(上):峡谷与渲染策略的时间预算

一、技术难点

Day26 讲的是发现段 (资源什么时候被浏览器知道),Day27 讲的是传输段与连接段 (字节上路之后发生什么)。这一篇讲它们前面那一段:HTML 是谁生成的。

先给结论:渲染位置不是一种"性能优化",它是一次坐标变换。 它把"看不见"与"点不动"这两段时间在时间轴上互相搬家,但既不改变两段之和的下限,也不改变其中任何一段的宽度由什么决定。本文要拆掉的三个前提,都是团队里被当作常识的口头禅:

  1. 「SSR 让页面更快 」------实测(render-budget.mjs)SSR 把 FCP 从 945.8ms 提前到 541.4ms,同时把 TTI 从 1065.8ms 推到 1888.2ms(晚 822.5ms,+77.2%)。同一份 JS,服务端渲染之后的页面对"看一眼"更快,对"点一下"更慢。
  2. 「静态化能解决性能问题 」------实测(render-budget.mjs)SSG 相对 SSR 把 TTFB、FCP、TTI 各提前了 264ms,峡谷一动不动:Δ峡谷 = 0.0ms。它把整条曲线向左平移,不改变曲线的形状。
  3. 「revalidate = 60 就是 60 秒 」------实测(freshness-ledger.mjs)同一份配置下,Top1 页面的平均内容陈旧是 30s ,最冷页面是 45.5 分钟 (差 90.9 倍 );而在 10000 个页面里有 9933 个 ,ISR 的回源次数与 SSR 完全相同(省 0 次),却平均白添了 24.3 分钟的内容陈旧。

本文所有数字都来自三份零依赖 Node 脚本的实跑输出,每份都带不变量断言(合计 317 条),断言失败即 exit 1。和 Day25~Day27 一样,它们是确定性简化模型 (参数与边界在文末「运行环境」写清楚了),价值是把不同策略放进同一个坐标系里比较,而不是预测某台机器上的绝对毫秒数。

注意边界:本篇只讲渲染位置与可交互性 ------服务端渲染、静态生成、增量再生成、流式、岛屿、可恢复。缓存与传输留给 Day27(已讲),拆包留给 Day30,图片字体留给 Day31,运行时性能与 INP 细节留给 Day29。混着讲的结果通常是四件事都说不透。

难点一:SSR 没有让页面更快,它把"空白"换成了"峡谷"

先看同一张页面的八种渲染方式。页面模型:2400 个组件、9000 个 DOM 节点、全量水合需要 700KB raw / 230KB gzip 的 JS、HTML 180KB raw / 59KB gzip,链路 1.5KB/ms(≈12Mbps)、RTT 40ms、客户端 4× CPU 节流。

策略 TTFB FCP TTI 峡谷 空白期 运的 JS(gzip)
CSR 客户端渲染 40.0 945.8 1065.8 120.0 945.8 230KB
SSR 字符串渲染 304.0 541.4 1888.2 1346.8 541.4 230KB
SSR 流式 + Suspense 75.0 312.4 1659.2 1346.8 312.4 230KB
SSG 构建期静态化 40.0 277.4 1624.2 1346.8 277.4 230KB
ISR 增量再生成(命中) 40.0 277.4 1624.2 1346.8 277.4 230KB
SSG + 岛屿(同框架运行时) 40.0 277.4 429.3 151.9 277.4 69KB
SSG + RSC(服务端组件不运 JS) 40.0 277.4 368.6 91.1 277.4 29.6KB
可恢复(resumability) 40.0 277.4 316.4 39.0 277.4 9.9KB

把"空白期"(导航 → 第一次有内容)和"峡谷"(有内容 → 真的能点)分开看,这张表讲的是一件很简单的事:

  • CSR 的空白期是 SSR 的 1.75 倍(945.8ms vs 541.4ms)------用户盯着白屏。但 CSR 的峡谷只有 120.0ms,因为当内容出现时,JS 早就跑完了。
  • SSR 的峡谷是 CSR 的 11.2 倍(1346.8ms vs 120.0ms)------用户看得见页面,点下去没反应。
  • 两种路线都没有"更快",只是把难受的时刻挪了位置。

而且这个"挪位置"的净效果是可以算出来的。以 SSR 与 CSR 的 TTI 之差为例:

ini 复制代码
ΔTTI = TTI(SSR) − TTI(CSR)
     = 服务端渲染 + 水合 − 客户端建 DOM − 挂载监听
     = 264.0    + 1215.0 − 540.0      − 120.0
     = 822.5ms

也就是说,SSR 的 TTI 劣势是一个有条件的量 ,不是定律。脚本解出了它的临界点:当"由 JS 创建 DOM 节点"的单价超过 0.15 ms/节点 (基线 0.06)时,CSR 的 TTI 反超 SSR;反过来,一个"重 DOM、轻组件"的页面(大量静态结构、很少交互组件)上,SSR 反而能赢回 TTI。所以"SSR 一定更慢"和"SSR 一定更快"都是错的------要算的是这两项哪一项更大。

难点二:峡谷的宽度由 JS 决定,与渲染发生在哪里无关

这是本篇的主线。脚本里所有时间都记在四笔账上,三行恒等式把它们锁死(81 组参数扫描下全部成立,容差 1e-6):

ini 复制代码
HTML 账 = HTML 传输 + HTML 解析 + 绘制          = 39.4 + 90.0 + 108.0 = 237.4ms
JS 就绪  = preload 重叠 + JS 传输 + JS 解析      =  5.9 + 153.3 + 210.0 = 369.2ms
水合     = 组件数 × 0.45 + 节点数 × 0.015        = 1080 + 135        = 1215.0ms

FCP = TTFB + HTML 账
TTI = TTFB + max(HTML 账, JS 就绪) + 水合
峡谷 = max(HTML 账, JS 就绪) + 水合 − HTML 账

代入全量水合:峡谷 = 369.2 + 1215.0 − 237.4 = 1346.8ms,与实跑完全一致。于是三件事同时被解释了:

  1. 静态化动的是 TTFB,不是峡谷。 SSG 把 TTFB 从 304.0ms 打到 40.0ms(少 264.0ms),因为公式里 TTFB 是加数,所以 FCP 与 TTI 各自也提前了 264.0ms------但峡谷里的三个量(HTML 账、JS 就绪、水合)一个都没变。Δ峡谷 = 0.0ms。
  2. 流式渲染动的是 TTFB 的左边界,也不是峡谷。 流式让壳先刷出去(75.0ms),FCP 从 541.4ms 提前到 312.4ms,TTI 也从 1888.2ms 提前到 1659.2ms(因为 preload 也随首字节提前被发现)------同样,Δ峡谷 = 0.0ms。流式缩短的是"看见"的等待,不是"能点"的等待。
  3. 真正的大头是执行,不是体积。 JS 账里三段的比是 153.3 : 210.0 : 1215.0 ------传输只占 9.7%,解析占 13.3%,水合占 76.7%。水合是传输的 7.9 倍。 这解释了一个非常常见的困惑:"我把 bundle 砍掉一半,为什么没变快?"------你砍掉的是 153.3ms 里的一半(76.7ms),而 TTI 的大头是那 1215ms 的执行。

还有一个细节值得单独说:gzip 只省传输,不省解析 。脚本里把 gzip 减半,jsTransfer 从 153.3ms 降到 76.7ms(约减半),而 jsParse 精确不变------因为 parse 走的是解压后的字节。压缩是网络层的收益,与主线程无关。

难点三:JS 少到一定程度,TTI 的地板会从 JS 变成 HTML

公式里的 max(HTML 账, JS 就绪) 不是装饰。全量水合时 JS 就绪(369.2ms)压过 HTML 账(237.4ms),所以 TTI 被 JS 压着;一旦 JS 足够少,TTI 的地板就变成 HTML 账,此时峡谷退化成"水合本身":

策略 JS 就绪 水合 峡谷 峡谷 = 水合?
全量水合 369.2 1215.0 1346.8 否(被 JS 压着)
岛屿 114.9 151.9 151.9 是
RSC 52.6 91.1 91.1 是
可恢复 21.5 39.0 39.0 是

这条结论的实用含义是:当 JS 已经很少时,继续砍 JS 的边际收益会突然消失 (地板变成 HTML 的解析与绘制,那部分你砍不掉)。所以"全站上岛屿"不是一个可以无限推的方向------它的收益上限是 HTML 账 − 自己的水合,到 0 就到底了。

但反过来,在 JS 很大时,岛屿的收益是巨大的 :峡谷从 1346.8ms 压到 151.9ms(8.9 倍),RSC 压到 91.1ms(14.8 倍),可恢复压到 39.0ms(34.5 倍)。

难点四:峡谷里点下去,到底发生了什么

这是 SSE/SSG 讨论里几乎没人量化的一段。hydration-ledger.mjs 把页面切成 24 个区域,在 [FCP, 全量水合 TTI] 这个 1346.8ms 的窗口里采样 4000 次点击,目标区域按交互面积加权,得到:

模式 石沉大海 排队重放 立刻响应 排队者平均等 P95 最坏
全量水合·单包 9.8% 36.4% 53.9% 417.6ms 827ms 1179ms
全量水合·分包(等齐) 0% 46.1% 53.9% 434.9ms 889ms 1280ms
选择性水合(边到边水合) 0% 31.5% 68.6% 379.5ms 691ms 1082ms
岛屿(只水合交互组件) 0% 0% 100% 0ms 30ms 30ms
可恢复(不重放) 0% 0% 100% 0ms 30ms 30ms

三件事:

  1. 峡谷的最前面那一段,点击是完全无效的。 9.8% ------ 精确等于"根监听挂上之前占峡谷的比例"(脚本里有这条断言)。此时页面上连一个事件处理器都没有,点下去什么都不发生,也没有任何东西被排队。这不是"慢",是功能缺失。
  2. 根监听挂上之后,点击会被排队重放------但用户等的是真真切切的时间。 平均 417.6ms,P95 827ms,最坏 1179ms。这段时间恰好是 Core Web Vitals 里 INP 统计的输入延迟。所以"SSR 页面 LCP 好看、INP 难看"不是一个玄学现象,它就是这个峡谷的直方图。
  3. 选择性水合(Suspense 边界边到边水合)改善的是前半段,不是后半段。 平均覆盖率从 53.9% 提到 68.5%,TTI 从 1624.2ms 提到 1426.1ms(省的是"水合 CPU 与分包下载的重叠")。但总水合 CPU 一分没少(1215.0ms,脚本有断言),峡谷仍不低于这个 CPU 的 90%。它是把等待藏进了下载里,不是把工作变没了。

与之对照:岛屿与可恢复模式下,这 1346.8ms 的峡谷里 100% 的点击立刻响应 ------因为它们的 TTI(277.4ms)已经落进了首帧之内(FCP 也是 277.4ms)。它们不是"让峡谷变窄",是让峡谷不存在。

难点五:岛屿的收益是"交互比例"的函数,而它的下限是运行时地板

岛屿架构(Astro 的 client:*、React 的 RSC + 客户端组件)的核心主张是"只给需要交互的部分运 JS"。这个主张成立,但它的收益曲线是有形状的,而且下限不在你的控制里。

先看收益函数。全量水合的 JS 结构是 运行时 + 全部组件 × 单价,岛屿是 运行时 + 交互组件 × 单价(运行时 140KB raw / 46KB gzip,单价 0.233KB/组件):

交互组件占比 岛屿 JS(raw) 岛屿 JS(gzip) 相对全量 省下
0% 140KB 46KB 20% 80%
2% 151.2KB 49.7KB 21.6% 78.4%
12.5%(本篇页面) 210KB 69KB 30% 70%
25% 280KB 92KB 40% 60%
50% 420KB 138KB 60% 40%
75% 560KB 184KB 80% 20%
100% 700KB 230KB 100% 0%

两条边界是精确的:交互占比 100% 时,岛屿 JS 恰好等于全量水合的 JS (脚本里有断言);占比 0% 时也还剩 46KB gzip 的运行时(占总 JS 的 20%)。也就是说,岛屿不是"零 JS",它是"常数 + 比例",而那个常数(框架运行时)是你换不掉的。

于是有了本节最实用的一条:多框架的运行时地板。

框架数 运行时地板(raw) gzip 占全量 JS
1 140KB 46KB 20%
2 280KB 92KB 40%
3 420KB 138KB 60%
5 700KB 230KB 100%
6 840KB 276KB 120%

3 个框架的运行时地板(138KB gzip)已经超过单框架岛屿的全部 JS(69KB);5 个框架光运行时就等于全量水合要运的全部 JS。 现实里"渐进式迁移"最常见的做法恰恰是"新页面用 Vue 岛屿、老组件继续 React 岛屿、营销页再引一个 Svelte"------三份运行时,把岛屿省下的 70% 全部吃回去还倒欠。

再看成本随页面规模的增长。把组件数按 1×/2×/4×/8× 放大(DOM 与 HTML 不变,模拟"组件树越来越深、包装层越来越多"的真实退化):

规模 组件数 全量水合 CPU / TTI 岛屿 CPU / TTI 可恢复 CPU / TTI
1× 2400 1215ms / 1624.2ms 151.9ms / 429.3ms 39ms / 316.4ms
2× 4800 2295ms / 2704.2ms 286.9ms / 564.3ms 63ms / 340.4ms
4× 9600 4455ms / 4864.2ms 556.9ms / 834.3ms 111ms / 388.4ms
8× 19200 8775ms / 9184.2ms 1096.9ms / 1506.1ms 207ms / 484.4ms

8× 时全量水合的 TTI 是 9184.2ms,岛屿 1506.1ms,可恢复 484.4ms。但要准确说出可恢复(resumability)的性质:它不是"零成本",是"零重放" 。它的固定启动是 15ms,再按交互组件数摊一个更小的系数(0.08 ms/组件,对比水合的 0.45 ms/组件),所以它的成本是 O(交互组件) 而不是 O(全部组件)------在本篇页面里,8× 规模下它只有全量水合 CPU 的 2.4%。

这里要特别标一个模型边界:可恢复模式真正的代价在"序列化状态"上,而我的模型没有让它随页面增长 。Qwik 的做法是把监听器、组件树/边界信息与订阅关系、以及应用状态序列化进 HTML 与 QRL 里,这部分是 O(页面规模) 的 (HTML 与序列化状态都会变大),而且它仍要在启动时预加载 若干 chunk。所以"可恢复的启动是常数级"只对急切执行的 JS 成立,不是对总成本成立。

难点六:水合不匹配的成本不进平均值,它进长尾和开发期

React 的水合不匹配(mismatch)有两级回退,React 的错误码里写得明明白白:错误 422 是"从这个 Suspense 边界开始改为客户端渲染",错误 423 是"整个根改为客户端渲染"(因为错误发生在一个 Suspense 边界之外)。这个两级结构在 React 18 与 19 里都存在 ------React 19 只是把日志改成了带 diff 的单条错误,并会为第三方脚本插入的内容强制一次客户端重渲染。所以"React 19 把 mismatch 变成了按子树恢复"这个说法是错的:按子树恢复的前提是那个错误发生在 Suspense 边界之内。

量化这两级的差距(以全量水合路线 TTI = 1624.2ms 为基准):

回退级别 额外代价 受影响用户的 TTI
根级(丢掉整份 HTML 重画 9000 个节点 + 重挂 2400 个组件的监听) +660ms 2284.2ms
子树级(只重画那一棵 400 节点 / 90 组件) +28.5ms 1652.7ms

两者差 23.2 倍。更关键的是这个成本在统计上的形状:

不匹配率 页面级期望代价 10 万 PV/天受影响用户数
0.1% 0.7ms 100
1% 6.6ms 1000
5% 33ms 5000
100%(系统性) 660ms 100000

1% 的不匹配率平均只让页面慢 6.6ms------任何聚合报表都看不见它。 但那 1000 个用户看到的是整页闪一下、状态被重置、TTI 晚 660ms。这就是为什么 mismatch 的账不能记在平均值上:它是方差问题,而性能看板只看均值。

而"系统性不匹配"是另一种东西:在渲染路径里读了客户端时间、Math.random()、typeof window、或者浏览器 locale,那么每一次都是不匹配(不匹配率 100%)。此时的账是:

ini 复制代码
系统性不匹配的 SSR 路线 TTI = 1624.2 + 660.0 = 2284.2ms
同页面的 CSR 路线 TTI        = 1177.2ms

服务器渲染白付了,客户端照样重画一遍------比一开始就不 SSR 还差 1107ms。 这是"SSR 上线后反而更慢"最容易被忽略的一种成因,而且它不会报错、不会告警,只会在用户的屏幕上闪一下。

还有一个和岛屿架构有关的结构性事实值得点出:未被水合的区域,React 根本不会去核对它的 HTML。所以在岛屿架构下,服务端与客户端输出不一致不会触发回退,代价从"重画 + 660ms"退化成"视觉错位"。这既是好处(没有灾难性回退),也是坏事(错位不会自己暴露,只会被用户看见)。

难点七:ISR 的陈旧窗口与流量成反比------一份配置,两种人生

revalidate = 60 这个名字太像"每 60 秒刷新一次"了。它实际上的语义是过期后等下一个请求来触发再生成 (stale-while-revalidate,Next.js 官方文档与源码里的 response-cache 都是这个行为:先把旧副本返回给调用方,再在后台重新生成)。既然触发是"下一个请求",那么触发时刻就取决于请求间隔,也就取决于流量:

scss 复制代码
请求密(λ · R ≥ 1):平均陈旧 = R/2,              最坏陈旧 = R
请求稀(λ · R < 1):平均陈旧 = R/2 + 1/(2λ),     最坏陈旧 = R + 1/λ + 渲染时间

10000 个页面、100 万请求/天、Zipf α=0.9 的流量分布下,同一份 revalidate = 60:

页面 λ(请求/s) 请求/天 回源/天 回源占请求 平均陈旧 最坏陈旧 相对 60s
Top 1 0.74 63739 1440 2.3% 30s 1min 1×
Top 5 0.17 14974 1440 9.6% 30s 1min 1×
Top 10% 1/11.3min 127 127 100% 6.2min 12.3min 12.3×
Top 25% 1/25.8min 56 56 100% 13.4min 26.8min 26.8×
Top 50% 1/48.2min 30 30 100% 24.6min 49.2min 49.2×
Top 75% 1/1.2h 21 21 100% 35.2min 1.2h 70.4×
Top 99% 1/1.5h 16 16 100% 45.1min 1.5h 90.1×
最冷页 1/1.5h 16 16 100% 45.5min 1.5h 90.9×

同一份配置,在热门页上平均陈旧 30 秒,在最冷页上 45.5 分钟------差 90.9 倍。

更难受的是后两列的乘积含义:从 Top 10% 往后,回源占请求比就是 100% 。也就是说,在 10000 个页面里的 9933 个 页面上,ISR 相对 SSR 一次回源都没省 (回源次数逐位等于请求数),却白添了平均 24.3 分钟的内容陈旧。脚本把这类页面叫"净亏"页面。

还有一条反直觉的推论:在这些低流量页面上,把 revalidate 从 60s 调大到 3600s 不会省下任何回源,只会让陈旧从 45.5 分钟变成 1.2 小时 (最坏从 1.5h 变成 2.5h,脚本有断言)。因为请求间隔(1.5 小时)本来就远大于窗口,每次请求都发现条目已过期------此时决定陈旧的是流量,不是配置。

最后是"回源节省落在谁身上"的分布:在全站口径下,ISR 的全部回源节省几乎都来自头部 5% 的页面(脚本实测这部分贡献了 100% 的节省)。于是:

收益落在热门页------它们本来也最不陈旧;代价落在长尾页------它们最陈旧。看板上看到的是平均值,而平均值被流量加权后,恰好把最难受的那批用户筛掉了。

脚本把两个口径都算了出来:ISR R=60 的全站平均陈旧,页面加权是 24.2 分钟,流量加权是 7.7 分钟 (差 3.2 倍);p95(页面加权)是 43.4 分钟。你的发布流程活在页面加权里,你的用户活在流量加权里------两边的数字都能自洽地证明"没问题"。

顺带一个可用于自我诊断的数:平均陈旧 > 10 分钟的页面有 8223 个(82.2%),它们承担 25.4% 的流量。如果你在自己的站点上看到类似形态,那么"revalidate = 60"这句话在报表上永远不会暴露问题。

难点八:回源风暴属于"事件流量",不属于平均流量;SWR 与请求合并救的是两拨人

ISR 的源站成本是渲染(不像静态站点是"发一个文件"),所以在"过期瞬间"的并发上它非常脆弱。先算清楚它什么时候会发生:

复制代码
一次再生成期间的并发请求数 ≈ λ × 渲染时间

平均流量下根本不会发生:Top1 页 λ=0.74/s,300ms 渲染窗口内期望并发只有 0.22 个请求------风暴不存在。风暴属于事件流量 :λ = 200/s(秒杀、大促、热搜)时,窗口内并发 60 个请求。

配置 回源次数/天 卡在渲染上的用户/天
不合并请求 86400 ---
合并请求(同一时刻只跑一次再生成) 1440 ---
无 SWR(用户等这次渲染) 1440 86400

差 60 倍,精确等于 λ × 渲染时间(脚本有断言)。这两件事救的是不同的人:

  • 请求合并(coalescing)救的是源站 。它的实现是"把正在进行的再生成的 Promise 挂起来,后来的请求复用同一个 Promise"------Next.js 的 ResponseCache 里就是这么写的(getBatcher / revalidateBatcher)。但要注意:它是进程内的 ,在 Next.js 的默认实现里,多个实例之间并不共享(官方文档明确说 revalidation 事件默认是本地的),到 CDN 层还需要另一层请求合并。所以"我配了 ISR,源站应该只被打一次"这句话,只有在单实例 + 单区域时才成立。
  • SWR 救的是用户 。没有 SWR,每次再生成都有 60 个用户卡在那 300ms 上(一天 86400 个用户次);有 SWR,没有任何用户等在这次渲染上------他们拿到的是 60 秒以内的旧内容。

而"发布驱动再生成"(on-demand revalidate:内容一变就 invalidate,而不是等时间到期)在两个维度上同时赢。以热门页为例:

策略 回源/天 平均陈旧 最坏陈旧
ISR R=60 + SWR 1440 30s 1min
ISR R=3600 + SWR 24 30min 1h
ISR + 发布驱动 24 1s 2s
SSG 全量重建 × 24 次/天 0(请求侧) 0s 0s

发布驱动用和 R=3600 一样的回源次数(24 次/天),把最坏陈旧从 1 小时压到 2 秒 ------因为陈旧不再由"流量何时到来"决定,而由"内容何时变化"决定。这也是它能把上面那条"90.9 倍"抹平的原因:把触发从"时间"换成"事件",陈旧就与流量解耦了。

"那为什么不干脆全都静态生成?"因为全量预渲染的成本不是线性的,而是有乘数的。

第一笔:构建时间。

页面数 全量构建 每天 24 次发布 增量构建(0.5% 变更)
1,000 4.3min 1.7h 1.1s
10,000 38min 15.2h 11.3s
100,000 6.3h 150.2h 1.9min
1,000,000 62.5h 1500.2h 18.8min

1 万页全量构建 38 分钟,按每天 24 次发布算就是 15.2 小时/天 ------流水线一天只有 24 小时。这是 ISR 存在的真正理由:不是为了"更快",是为了让构建时间与发布频率解耦。 增量构建(只重建变更的 0.5%)把 38 分钟压到 11.3 秒,代价是它要求你有一个可靠的"哪些页面受影响"的映射------而这恰恰是脏标记/依赖追踪的工程问题,不是配置问题。

第二笔:键空间。 个性化只要落在服务端渲染路径上,就会按维度乘积爆炸:

ini 复制代码
语言/地区×8 × 货币×5 × A/B 分桶×4 × 登录状态×2 × 会员等级×4 = 1280 种组合
1280 × 10000 页 = 1280 万个对象 × 26KB gzip = 317.4GB(边缘预算 50GB)
全量预渲染这 1280 万个组合 = 800 小时

第三笔:Vary: Cookie。 这是最贵的一笔,因为它的键数量等于用户数(100 万),同一 URL 的命中率趋近于 0。需要更新一个 2023 年以来的说法:这已经不只是"命中率下降" 。Vercel 在 2026 年 9 月 30 日起不再缓存 Vary 包含 Cookie 的响应 (响应照常返回,但不进缓存,诊断字段是 x-vercel-cache: MISS 与 vary_key_denied:cookie)。所以正确的说法是:高基数请求头(Cookie、Authorization)会让响应根本不进入缓存 ,修法是把它从 Vary 里去掉(或明确 Cache-Control: private),而不是"期望命中率低一点"。


二、完整解法

上面九个难点可以归结成一句话:渲染位置是一次坐标变换,你要先知道自己在变换哪一根轴。 三份零依赖脚本把这根轴量出来------设计期算时间预算,评审期算可交互性,上线后记新鲜度与回源。它们共用同一套单位成本,所以同一个页面的数字可以在三份账之间互相印证。

2.1 工具一:把"渲染策略"变成一个可算的时间预算

render-budget.mjs 是设计期用的尺子。它把页面抽象成"组件数 / DOM 节点数 / 交互组件数 / HTML 字节 / JS 字节",把预算抽象成四个加数(TTFB、HTML 账、JS 就绪、水合),然后回答三个问题:这个页面换成另一种渲染方式,哪一段时间会变、变成多少、形状变了没有。

它的核心不是那张策略表,而是三条恒等式。有了它们,"换成 SSR 会不会更快"就不再是经验判断:

css 复制代码
FCP = TTFB + HTML 账
TTI = TTFB + max(HTML 账, JS 就绪) + 水合
峡谷 = max(HTML 账, JS 就绪) + 水合 − HTML 账

脚本还解出了一个临界点:当"由 JS 创建 DOM 节点"的单价超过 0.15 ms/节点 (基线 0.06)时,CSR 的 TTI 反超 SSR。这个解不是靠调参试出来的,而是把 ΔTTI = 服务端渲染 + 水合 − 客户端建 DOM − 挂载 令其为零解出来的------能解出临界点,才说明你理解了这两者的取舍关系;解不出,说明你只是在背结论。

为了对抗"单位成本是我编的"这个问题,脚本做了一个 81 组参数的扫描 (水合单价 0.15/0.45/0.90 ms/组件 × 解析单价 0.15/0.30/0.60 ms/KB × 建 DOM 单价 0.03/0.06/0.12 ms/节点 × 惰性恢复单价 0.04/0.08/0.16 ms/组件),并断言 5 条保序结论在所有 81 种组合下都成立:

  1. 静态化与流式不改变峡谷(Δ峡谷 恒为 0);
  2. 静态化对流式的 ΔTTI 恒等于 ΔTTFB;
  3. 岛屿 < 全量水合的峡谷,RSC < 岛屿;
  4. 岛屿/RSC/可恢复的峡谷恒等于各自的水合成本(HTML 账成为地板);
  5. TTI = TTFB + max(HTML 账, JS 就绪) + 水合 恒成立。

结论只在参数取任何值时都成立,才值得拿去做架构决策。 具体毫秒数只在同一坐标系的比较里有意义。

js 复制代码
#!/usr/bin/env node
'use strict';
/*
 * render-budget.mjs ------ 渲染策略的时间预算账本
 *
 * 零依赖、确定性。回答四个问题:
 *   1. 八种渲染策略,TTFB / FCP / TTI 各是多少,每一毫秒记在哪一段账上?
 *   2. SSR 到底把哪一段时间换成了哪一段时间("峡谷"有多宽)?
 *   3. 静态化(SSG / ISR)与流式改变了什么,又没有改变什么?
 *   4. 峡谷的宽度由谁决定------服务器?网络?还是你运出去的那几百 KB JS?
 *
 * 断言失败即 exit 1。
 */

// ============ 模型参数(与正文「运行环境」逐条对齐)============
const CFG = {
  RTT: 40,              // ms,一次请求-响应往返
  DNS_TLS: 100,         // ms,冷连接(DNS 20 + TCP 1RTT 40 + TLS1.3 1RTT 40)
  BW: 1.5,              // KB/ms,链路 ≈ 12Mbps(与 Day27 同一坐标系)
  CPU: 4,               // 客户端 CPU 节流倍数(中端手机 ≈ 4×)

  // ------ 4× 节流下的单位成本(量级常数,可调;见「运行环境」)------
  HTML_PARSE_PER_NODE: 0.010,   // ms,浏览器 HTML 解析器建一个 DOM 节点
  PAINT_PER_NODE: 0.012,        // ms,样式/布局/绘制一个节点
  JS_PARSE_PER_KB: 0.30,        // ms/解压后 KB,parse + compile
  JS_DOM_PER_NODE: 0.060,       // ms,由 JS 创建 DOM 节点(≈ 浏览器解析器的 6×)
  HYDRATE_PER_COMP: 0.45,       // ms/组件,水合:重建 fiber、跑 hooks、排 effects
  HYDRATE_PER_NODE: 0.015,      // ms/节点,水合时遍历既有的 DOM
  ATTACH_PER_COMP: 0.05,        // ms/组件,CSR 下挂监听(水合路径里已含在 HYDRATE 内)
  RESUME_STARTUP: 15,           // ms,可恢复(resumability)的固定启动成本
  RESUME_PER_ISLAND: 0.08,      // ms/交互组件,惰性恢复事件处理器
  LCP_EXTRA: 40,                // ms,主图在 FCP 之后还要走的时间
  SERVER_PER_COMP: 0.06,        // ms/组件,服务端渲染(Node,无节流)
  GZIP_RATIO: 230 / 700,        // 文本资源的 gzip 后 / 前
};

// ============ 页面模型:一个中等规模电商详情页 ============
const PAGE = {
  components: 2400,     // 组件总数(水合是"重放整棵树",所以全都要水合)
  nodes: 9000,          // DOM 节点总数
  interactive: 300,     // 真正需要事件/状态的组件(岛屿/客户端组件的候选集)
  htmlKB: 180,          // SSR/SSG 输出的 HTML(未压缩)
  shellKB: 9,           // CSR 的 HTML 壳
  jsRawKB: 700,         // 全量水合要下载并执行的 JS(未压缩)
  dataMs: 120,          // 后端数据聚合
  shellNodes: 180,      // CSR 壳里已有的节点数
};
PAGE.htmlGzipKB = PAGE.htmlKB * CFG.GZIP_RATIO;
PAGE.shellGzipKB = PAGE.shellKB * CFG.GZIP_RATIO;
PAGE.jsGzipKB = PAGE.jsRawKB * CFG.GZIP_RATIO;

const RUNTIME_KB = 140;                                            // 框架运行时(React + ReactDOM 量级)
const PER_COMP_KB = (PAGE.jsRawKB - RUNTIME_KB) / PAGE.components;  // ≈0.233 KB/组件
const gz = (rawKB) => rawKB * CFG.GZIP_RATIO;

const ms = (x) => Math.round(x * 10) / 10;
const r1 = (x) => Math.round(x * 10) / 10;
const r2 = (x) => Math.round(x * 100) / 100;
const pct = (a, b) => r1((a / b - 1) * 100);

// ============ 断言框架 ============
let assertions = 0;
const failures = [];
function ok(cond, msg) {
  assertions++;
  if (!cond) failures.push(msg);
}
function near(a, b, tolPct, msg) {
  const d = Math.abs(a - b) / Math.max(Math.abs(b), 1e-9) * 100;
  ok(d <= tolPct, `${msg}(|${ms(a)} vs ${ms(b)}| = ${r1(d)}% 超出容差 ${tolPct}%)`);
}
function gt(a, b, msg) { ok(a > b, `${msg}(${ms(a)} 应 > ${ms(b)})`); }
function gte(a, b, msg) { ok(a >= b - 1e-9, `${msg}(${ms(a)} 应 ≥ ${ms(b)})`); }

// ============ 策略定义 ============
// mode: 'csr'    ------ 内容由 JS 建出来(DOM 从 JS 来)
//       'ssr'    ------ 内容来自 HTML,之后全量水合
//       'resume' ------ 内容来自 HTML,不做重放,只按需恢复交互
const js = (rawKB) => ({ jsRawKB: rawKB, jsGzipKB: gz(rawKB) });
const STRATEGIES = [
  { key: 'CSR', name: 'CSR 客户端渲染', mode: 'csr',
    htmlKB: PAGE.shellKB, serverMs: 0, ...js(PAGE.jsRawKB) },

  { key: 'SSR', name: 'SSR 字符串渲染', mode: 'ssr',
    htmlKB: PAGE.htmlKB, serverMs: PAGE.dataMs + PAGE.components * CFG.SERVER_PER_COMP,
    ...js(PAGE.jsRawKB) },

  { key: 'STREAM', name: 'SSR 流式 + Suspense', mode: 'ssr',
    htmlKB: PAGE.htmlKB, serverMs: PAGE.dataMs + PAGE.components * CFG.SERVER_PER_COMP,
    flushMs: 35,                       // 壳先刷出去,不等数据与整页渲染
    ...js(PAGE.jsRawKB) },

  { key: 'SSG', name: 'SSG 构建期静态化', mode: 'ssr',
    htmlKB: PAGE.htmlKB, serverMs: 0, ...js(PAGE.jsRawKB) },

  { key: 'ISR', name: 'ISR 增量再生成(命中)', mode: 'ssr',
    htmlKB: PAGE.htmlKB, serverMs: 0, ...js(PAGE.jsRawKB) },

  { key: 'SSG+ISL', name: 'SSG + 岛屿(同框架运行时)', mode: 'ssr',
    htmlKB: PAGE.htmlKB, serverMs: 0,
    ...js(RUNTIME_KB + PAGE.interactive * PER_COMP_KB), hydrates: PAGE.interactive },

  { key: 'SSG+RSC', name: 'SSG + RSC(服务端组件不运 JS)', mode: 'ssr',
    htmlKB: PAGE.htmlKB, serverMs: 0,
    ...js(90), hydrates: PAGE.interactive * 0.6 },

  { key: 'RESUME', name: '可恢复(resumability)', mode: 'resume',
    htmlKB: PAGE.htmlKB, serverMs: 0, ...js(30) },
];

// ============ 时间线求解 ============
function simulate(s, cfg = CFG, cold = false) {
  const t = {};
  const connMs = cold ? cfg.DNS_TLS : 0;
  const htmlGzip = s.htmlKB * cfg.GZIP_RATIO;

  // ------ HTML 账:从 TTFB 到"屏幕上有内容"(FCP)------
  t.htmlTransfer = htmlGzip / cfg.BW;
  t.htmlParse = (s.mode === 'csr' ? PAGE.shellNodes : PAGE.nodes) * cfg.HTML_PARSE_PER_NODE;
  t.paintMs = (s.mode === 'csr' ? PAGE.shellNodes : PAGE.nodes) * cfg.PAINT_PER_NODE;

  // ------ 服务器/边缘段(TTFB)------
  const serverWork = s.mode === 'csr' ? 0 : s.serverMs;
  const firstByteWork = s.flushMs != null ? s.flushMs : serverWork;
  t.ttfb = connMs + cfg.RTT + firstByteWork;
  t.serverWork = serverWork;          // 源站真正干完活的时间(流式下用户不必等它)

  // ------ JS 账:从 TTFB 到"JS 就绪",再加上水合 ------
  const jsStart = t.ttfb + t.htmlTransfer * 0.15;   // preload:与 HTML 解析并行
  t.jsTransfer = s.jsGzipKB / cfg.BW;
  t.jsParse = s.jsRawKB * cfg.JS_PARSE_PER_KB;
  t.jsReady = jsStart + t.jsTransfer + t.jsParse;

  if (s.mode === 'csr') {
    // 内容全部由 JS 建出来:FCP 必须等 JS 跑完
    t.domMs = PAGE.nodes * cfg.JS_DOM_PER_NODE;
    t.hydrateMs = 0;
    t.fcp = t.jsReady + t.domMs + t.paintMs;
    t.attachMs = PAGE.components * cfg.ATTACH_PER_COMP;
    t.tti = t.fcp + t.attachMs;
    t.jsReadyOffset = t.fcp - t.ttfb - t.paintMs - t.domMs + t.paintMs; // CSR 下 FCP 本身就含 JS
  } else {
    // 内容来自 HTML:浏览器解析器建 DOM(便宜,且不与主线程抢 JS 的执行)
    t.domMs = 0;
    t.attachMs = 0;
    t.fcp = t.ttfb + t.htmlTransfer + t.htmlParse + t.paintMs;
    if (s.mode === 'resume') {
      t.hydrateMs = cfg.RESUME_STARTUP + PAGE.interactive * cfg.RESUME_PER_ISLAND;
    } else {
      const comps = s.hydrates != null ? s.hydrates : PAGE.components;
      t.hydrateMs = comps * cfg.HYDRATE_PER_COMP
                  + PAGE.nodes * (comps / PAGE.components) * cfg.HYDRATE_PER_NODE;
    }
    t.tti = Math.max(t.fcp, t.jsReady) + t.hydrateMs;
    t.jsReadyOffset = t.jsReady - t.ttfb;               // TTFB → "JS 就绪"的一段
  }
  t.tti = Math.max(t.tti, t.fcp);
  t.lcp = t.fcp + cfg.LCP_EXTRA;
  t.htmlAccount = t.htmlTransfer + t.htmlParse + t.paintMs;
  t.jsAccount = t.tti - t.ttfb;                        // TTFB → TTI 的整段
  t.jsBound = t.jsReadyOffset > t.htmlAccount;          // TTI 是被 JS 压着,还是被 HTML 压着
  t.blank = t.fcp;                     // 空白期:导航 → 第一次有内容
  t.valley = t.tti - t.fcp;            // 峡谷:看着有内容 → 真的能点
  t.total = t.tti;
  return t;
}

// ============ 参数扫描(结论必须在参数不确定时保持方向)============
function sweep(fn, axes) {
  const out = [];
  const keys = Object.keys(axes);
  const walk = (i, acc) => {
    if (i === keys.length) { out.push(fn(acc)); return; }
    for (const v of axes[keys[i]]) walk(i + 1, { ...acc, [keys[i]]: v });
  };
  walk(0, {});
  return out;
}

const byKey = Object.fromEntries(STRATEGIES.map((s) => [s.key, s]));
const R = {};
for (const s of STRATEGIES) R[s.key] = simulate(s);
R.CSR_COLD = simulate(byKey['CSR'], CFG, true);
R.SSR_COLD = simulate(byKey['SSR'], CFG, true);
R.SSG_COLD = simulate(byKey['SSG'], CFG, true);

// ============ 输出 ============
console.log('='.repeat(100));
console.log('render-budget.mjs ------ 渲染策略时间预算');
console.log(`页面:${PAGE.components} 组件 / ${PAGE.nodes} DOM 节点 / JS ${PAGE.jsRawKB}KB raw·${r1(PAGE.jsGzipKB)}KB gzip / HTML ${PAGE.htmlKB}KB raw`);
console.log(`链路 ${CFG.BW}KB/ms(≈12Mbps)· RTT ${CFG.RTT}ms · 客户端 CPU ${CFG.CPU}× 节流 · 复用连接`);
console.log('='.repeat(100));
console.log('');
console.log('策略'.padEnd(30) + 'TTFB'.padStart(8) + 'FCP'.padStart(8) + 'LCP'.padStart(8) +
            'TTI'.padStart(8) + '峡谷'.padStart(8) + '空白'.padStart(8) + 'JS(gz)'.padStart(9) + '可交互组件'.padStart(11));
console.log('-'.repeat(100));
for (const s of STRATEGIES) {
  const t = R[s.key];
  console.log(
    s.name.padEnd(30) +
    ms(t.ttfb).toFixed(1).padStart(8) +
    ms(t.fcp).toFixed(1).padStart(8) +
    ms(t.lcp).toFixed(1).padStart(8) +
    ms(t.tti).toFixed(1).padStart(8) +
    ms(t.valley).toFixed(1).padStart(8) +
    ms(t.blank).toFixed(1).padStart(8) +
    (r1(s.jsGzipKB) + 'KB').padStart(9) +
    String(s.hydrates != null ? s.hydrates : (s.mode === 'resume' ? PAGE.interactive : PAGE.components)).padStart(11));
}
console.log('');
console.log('每一毫秒记在哪一段账上(ms)------HTML 账决定 FCP,JS 账决定 TTI:');
console.log('-'.repeat(100));
console.log('策略'.padEnd(30) + '请求+边缘'.padStart(11) + 'HTML传输'.padStart(10) + 'HTML解析'.padStart(10) +
            '绘制'.padStart(7) + 'JS传输'.padStart(9) + 'JS解析'.padStart(9) + 'JS建DOM'.padStart(10) + '水合'.padStart(9));
for (const s of STRATEGIES) {
  const t = R[s.key];
  console.log(
    s.name.padEnd(30) +
    ms(t.ttfb).toFixed(1).padStart(11) +
    ms(t.htmlTransfer).toFixed(1).padStart(10) +
    ms(t.htmlParse).toFixed(1).padStart(10) +
    ms(t.paintMs).toFixed(1).padStart(7) +
    ms(t.jsTransfer).toFixed(1).padStart(9) +
    ms(t.jsParse).toFixed(1).padStart(9) +
    ms(t.domMs).toFixed(1).padStart(10) +
    ms(t.hydrateMs).toFixed(1).padStart(9));
}
console.log('');
console.log('相对 SSR 的差额(ms)------正数 = 更快:');
console.log('-'.repeat(100));
console.log('策略'.padEnd(30) + 'ΔTTFB'.padStart(9) + 'ΔFCP'.padStart(9) + 'ΔTTI'.padStart(9) + 'Δ峡谷'.padStart(9));
for (const s of STRATEGIES) {
  const t = R[s.key];
  console.log(
    s.name.padEnd(30) +
    ms(R.SSR.ttfb - t.ttfb).toFixed(1).padStart(9) +
    ms(R.SSR.fcp - t.fcp).toFixed(1).padStart(9) +
    ms(R.SSR.tti - t.tti).toFixed(1).padStart(9) +
    ms(R.SSR.valley - t.valley).toFixed(1).padStart(9));
}

// ---------- 断言 ----------
const csr = R.CSR, ssr = R.SSR, stream = R.STREAM, ssg = R.SSG, isr = R.ISR;
const isl = R['SSG+ISL'], rsc = R['SSG+RSC'], resume = R.RESUME;

ok(STRATEGIES.length === 8, '应有 8 种策略进入对比');
for (const s of STRATEGIES) {
  const t = R[s.key];
  gte(t.fcp, t.ttfb, `${s.key}: TTFB 不可能晚于 FCP`);
  gte(t.tti, t.fcp, `${s.key}: TTI 不可能早于 FCP`);
  gte(t.lcp, t.fcp, `${s.key}: LCP 不可能早于 FCP`);
  near(t.blank + t.valley, t.total, 0.01, `${s.key}: 空白 + 峡谷 = TTI`);
  // 核心恒等式:FCP = TTFB + HTML 账;TTI = TTFB + max(HTML 账, JS 就绪) + 水合
  if (s.mode !== 'csr') {
    near(t.ttfb + t.htmlAccount, t.fcp, 0.01, `${s.key}: FCP = TTFB + HTML 账`);
    near(t.ttfb + Math.max(t.htmlAccount, t.jsReadyOffset) + t.hydrateMs, t.tti, 0.01,
         `${s.key}: TTI = TTFB + max(HTML 账, JS 就绪) + 水合`);
    near(t.valley, Math.max(t.htmlAccount, t.jsReadyOffset) + t.hydrateMs - t.htmlAccount, 0.01,
         `${s.key}: 峡谷 = max(HTML 账, JS 就绪) + 水合 − HTML 账`);
  } else {
    near(t.ttfb + 0.15 * t.htmlTransfer + t.jsTransfer + t.jsParse + t.domMs + t.paintMs, t.fcp, 0.01,
         'CSR: FCP = TTFB + JS 全流程 + 绘制(JS 建 DOM)');
  }
}

// 1. 首字节:SSR 是"服务器先干活",CSR 是"先给壳"
gt(ssr.ttfb, csr.ttfb, 'SSR 的 TTFB 应显著高于 CSR(服务器要先渲染)');
near(ssr.ttfb, CFG.RTT + PAGE.dataMs + PAGE.components * CFG.SERVER_PER_COMP, 0.5,
     'SSR 的 TTFB = RTT + 数据 + 服务端渲染');
near(ssg.ttfb, CFG.RTT, 0.5, 'SSG 的 TTFB = 1 个 RTT(边缘命中,无源站计算)');
near(isr.ttfb, ssg.ttfb, 0.01, 'ISR 命中时与 SSG 完全一致');

// 2. 首屏:CSR 的 FCP 要等 JS 跑完;SSR/SSG 的 FCP 只等 HTML
gt(csr.fcp, ssr.fcp, 'CSR 的 FCP 应晚于 SSR');
ok(csr.fcp / ssr.fcp > 1.5, `CSR 的 FCP 至少是 SSR 的 1.5 倍(实际 ${r2(csr.fcp / ssr.fcp)}×)`);
near(ssg.fcp - ssr.fcp, ssg.ttfb - ssr.ttfb, 0.5, '静态化对 FCP 的改善 = 它对 TTFB 的改善');

// 3. 核心一:峡谷是与渲染策略无关的不变量
near(ssg.valley, ssr.valley, 0.01, '静态化一动不动地保留峡谷');
near(isr.valley, ssr.valley, 0.01, 'ISR 命中路径的峡谷与 SSR 相同');
near(stream.valley, ssr.valley, 0.01, '流式一动不动地保留峡谷');
near(ssg.tti - ssr.tti, ssg.ttfb - ssr.ttfb, 0.5, '静态化对 TTI 的改善也 = 它对 TTFB 的改善');

// 4. 核心二:峡谷 = JS 就绪 + 水合 − HTML 账(所以由 JS 决定,不由渲染位置决定)
for (const k of ['SSR', 'SSG', 'ISR', 'STREAM']) {
  near(R[k].valley, R[k].jsReadyOffset + R[k].hydrateMs - R[k].htmlAccount, 0.01,
       `${k}: 峡谷 = JS 就绪 + 水合 − HTML 账`);
  ok(R[k].jsBound, `${k}: 全量水合下 TTI 应被 JS 压着(JS 就绪 > HTML 账)`);
}
ok(ssr.jsAccount > ssr.htmlAccount * 5, `TTFB→TTI 应是 HTML 账的 5 倍以上(${r1(ssr.jsAccount / ssr.htmlAccount)}×)`);
// 4b. JS 少到一定程度,TTI 的地板变成 HTML 账,峡谷退化为"水合本身"
for (const k of ['SSG+ISL', 'SSG+RSC', 'RESUME']) {
  ok(!R[k].jsBound, `${k}: JS 足够少时 TTI 应被 HTML 压着(不再是 JS 瓶颈)`);
  near(R[k].valley, R[k].hydrateMs, 0.01, `${k}: 此时峡谷恰好等于水合成本`);
}
near(isl.valley, isl.hydrateMs, 0.01, '岛屿的峡谷 = 它自己的水合成本');
ok(isl.valley / isl.hydrateMs === 1, '岛屿:峡谷与水合完全重合');

// 5. 反直觉但可算:全量水合的 SSR,TTI 比 CSR 更晚
gt(ssr.tti, csr.tti, '同一份 JS:SSR 的 TTI 应晚于 CSR(服务端渲染 + 重放 > 客户端建 DOM + 挂载)');
ok(ssr.tti - csr.tti > 300, `SSR 的 TTI 劣势应 > 300ms(实际 ${ms(ssr.tti - csr.tti)}ms)`);
gt(ssr.valley, csr.valley * 5, 'SSR 的峡谷应远宽于 CSR');

// 6. 流式:把整条曲线左移,不改峡谷
gt(ssr.ttfb, stream.ttfb, '流式应把 TTFB 提前');
gt(ssr.fcp, stream.fcp, '流式应把 FCP 提前');
gt(ssr.tti, stream.tti, '流式应把 TTI 提前(JS 的发现也随之提前)');

// 7. 只有减少 JS 才收窄峡谷(并顺带前移 TTI)
ok(isl.valley < ssr.valley * 0.3, `岛屿应把峡谷压掉 70% 以上(${ms(isl.valley)} vs ${ms(ssr.valley)})`);
ok(rsc.valley < isl.valley, 'RSC(服务端组件不运 JS)应进一步收窄峡谷');
ok(resume.valley < rsc.valley, '可恢复应进一步收窄峡谷');
gt(ssg.tti, isl.tti, '岛屿的 TTI 应早于全量水合(尽管 TTFB 相同)');
gt(isl.tti, rsc.tti, 'RSC 的 TTI 应早于"同运行时岛屿"');
gt(rsc.tti, resume.tti, '可恢复的 TTI 应最早');
ok(byKey['SSG+ISL'].jsGzipKB / PAGE.jsGzipKB < 0.35, '岛屿运的 JS 应少于全量的 35%');
near(isr.ttfb, isl.ttfb, 0.01, '岛屿与全量水合的 TTFB 相同------差别全在 JS 账里');

// 8. 压缩只省传输,不省解析
const gzHalf = simulate({ ...byKey['SSG'], jsGzipKB: byKey['SSG'].jsGzipKB / 2 }, CFG);
near(gzHalf.jsParse, ssg.jsParse, 0.001, 'gzip 减半不改变 parse 成本(parse 走解压后的字节)');
ok(gzHalf.jsTransfer <= ssg.jsTransfer * 0.51, 'gzip 减半后传输时间约减半');
ok(Math.abs(gzHalf.jsTransfer - ssg.jsTransfer / 2) < 0.1, 'gzip 减半 → 传输时间精确减半');
ok(gzHalf.tti < ssg.tti, 'gzip 减半仍能换来 TTI 改善(它买的是传输那一段)');

// 9. JS 账的内部构成
const jsa = ssr.jsAccount;
near(jsa, 0.15 * ssr.htmlTransfer + ssr.jsTransfer + ssr.jsParse + ssr.hydrateMs, 0.01,
     'SSR 的 JS 账 = preload 重叠 + 传输 + 解析 + 水合');
ok(ssr.hydrateMs / jsa > 0.5, `水合占 JS 账的一半以上(实际 ${r1(ssr.hydrateMs / jsa * 100)}%)`);
ok(ssr.jsParse / ssr.hydrateMs < 0.3, `解析只占水合的一小部分(${r1(ssr.jsParse / ssr.hydrateMs * 100)}%)------所以要减的是执行,不只是下载`);

// 10. 保序扫描:把不可靠的单位成本在两个方向上推,核心结论必须不变
const axes = {
  HYDRATE_PER_COMP: [0.15, 0.45, 0.90],
  JS_PARSE_PER_KB: [0.15, 0.30, 0.60],
  JS_DOM_PER_NODE: [0.030, 0.060, 0.12],
  RESUME_PER_ISLAND: [0.04, 0.08, 0.16],
};
const scanned = sweep((o) => {
  const c = { ...CFG, ...o };
  const a = simulate(byKey['SSR'], c), b = simulate(byKey['SSG'], c), d = simulate(byKey['STREAM'], c);
  const e = simulate(byKey['SSG+ISL'], c), f = simulate(byKey['SSG+RSC'], c), g = simulate(byKey['RESUME'], c);
  return {
    valleyInvariant: Math.abs(b.valley - a.valley) < 1e-6 && Math.abs(d.valley - a.valley) < 1e-6,
    ttiShift: Math.abs((b.tti - a.tti) - (b.ttfb - a.ttfb)) < 1e-6,
    jsOrder: e.valley < a.valley && f.valley < e.valley,
    htmlFloor: Math.abs(e.valley - e.hydrateMs) < 1e-6 && Math.abs(f.valley - f.hydrateMs) < 1e-6,
    ttiIdentity: Math.abs(a.ttfb + Math.max(a.htmlAccount, a.jsReadyOffset) + a.hydrateMs - a.tti) < 1e-6,
  };
}, axes);
ok(scanned.length === 81, `扫描组合数应为 81(实际 ${scanned.length})`);
for (const k of ['valleyInvariant', 'ttiShift', 'jsOrder', 'htmlFloor', 'ttiIdentity']) {
  const bad = scanned.filter((x) => !x[k]).length;
  ok(bad === 0, `扫描 81 组参数下 "${k}" 必须恒成立(${bad} 个反例)`);
}

// 11. SSR 的 TTI 劣势是有条件的:解出它的临界点
// TTI_CSR = TTFB + JS 全流程 + 建DOM*n + 绘制 + 挂载;解出与 SSR 相等时的建 DOM 单价
const csrFixed = csr.ttfb + 0.15 * csr.htmlTransfer + csr.jsTransfer + csr.jsParse + csr.paintMs + csr.attachMs;
const domStar = (ssr.tti - csrFixed) / PAGE.nodes;
ok(domStar > CFG.JS_DOM_PER_NODE, '临界"JS 建 DOM"单价应高于基线 0.06(基线处于"SSR 的 TTI 更晚"区间)');
const atStarCsr = simulate(byKey['CSR'], { ...CFG, JS_DOM_PER_NODE: domStar });
near(atStarCsr.tti, ssr.tti, 0.5, '在临界单价上,SSR 与 CSR 的 TTI 应相等');
const above = simulate(byKey['CSR'], { ...CFG, JS_DOM_PER_NODE: domStar * 1.5 });
const below = simulate(byKey['CSR'], { ...CFG, JS_DOM_PER_NODE: domStar * 0.5 });
gt(above.tti, ssr.tti, 'DOM 更贵时 CSR 的 TTI 反超 SSR');
ok(below.tti + below.ttfb < ssr.tti, 'DOM 便宜时 CSR 的 TTI 一定优于 SSR');
ok(resume.hydrateMs > rsc.hydrateMs || true, '(占位:可恢复与 RSC 的先后取决于单位成本,不作为断言)');

console.log('');
console.log('=== 关键结论 ===');
console.log(`SSR 相对 CSR 多付的 TTFB(服务器渲染)      : ${ms(ssr.ttfb - csr.ttfb)}ms`);
console.log(`SSR 的 JS 账 / HTML 账                     : ${ms(ssr.jsAccount)}ms / ${ms(ssr.htmlAccount)}ms = ${r1(ssr.jsAccount / ssr.htmlAccount)}×`);
console.log(`SSR 峡谷                                    : ${ms(ssr.valley)}ms(= JS 就绪 ${ms(ssr.jsReadyOffset)} + 水合 ${ms(ssr.hydrateMs)} − HTML 账 ${ms(ssr.htmlAccount)})`);
console.log(`JS 账里 传输 : 解析 : 水合                  : ${ms(ssr.jsTransfer)} : ${ms(ssr.jsParse)} : ${ms(ssr.hydrateMs)}` +
            `(占比 ${r1(ssr.jsTransfer / ssr.jsAccount * 100)}% : ${r1(ssr.jsParse / ssr.jsAccount * 100)}% : ${r1(ssr.hydrateMs / ssr.jsAccount * 100)}%)`);
console.log(`水合 / JS 传输 的倍数                       : ${r1(ssr.hydrateMs / ssr.jsTransfer)}×`);
console.log(`HTML 账分解 传输 : 解析+绘制 : 合计         : ${ms(ssr.htmlTransfer)} : ${ms(ssr.htmlParse + ssr.paintMs)} : ${ms(ssr.htmlAccount)}ms`);
console.log(`JS 传输 + 解析(不含 preload 重叠)         : ${ms(ssr.jsTransfer + ssr.jsParse)}ms`);
console.log(`JS 就绪(TTFB → JS 可用) 全量/岛屿/RSC/可恢复: ${ms(ssr.jsReadyOffset)} / ${ms(isl.jsReadyOffset)} / ${ms(rsc.jsReadyOffset)} / ${ms(resume.jsReadyOffset)}ms`);
console.log(`CSR 空白期 / SSR 空白期                     : ${r2(csr.blank / ssr.blank)}×`);
console.log(`SSR 峡谷 / CSR 峡谷                         : ${r1(ssr.valley / csr.valley)}×`);
console.log(`SSR 的 TTI 比 CSR 晚                        : ${ms(ssr.tti - csr.tti)}ms(${pct(ssr.tti, csr.tti)}%)`);
console.log(`静态化(SSG) 的 ΔTTFB / ΔTTI / Δ峡谷          : ${ms(ssr.ttfb - ssg.ttfb)} / ${ms(ssr.tti - ssg.tti)} / ${ms(ssr.valley - ssg.valley)}`);
console.log(`流式的 ΔTTFB / ΔTTI / Δ峡谷                  : ${ms(ssr.ttfb - stream.ttfb)} / ${ms(ssr.tti - stream.tti)} / ${ms(ssr.valley - stream.valley)}`);
console.log(`全量水合峡谷 / 岛屿峡谷                      : ${r1(ssr.valley / isl.valley)}×`);
console.log(`全量水合峡谷 / RSC 峡谷                      : ${r1(ssr.valley / rsc.valley)}×`);
console.log(`全量水合峡谷 / 可恢复峡谷                    : ${r1(ssr.valley / resume.valley)}×`);
console.log(`冷连接额外成本                              : ${CFG.DNS_TLS}ms(对八种策略一视同仁)`);
console.log(`  SSG 冷连接 TTFB ${ms(R.SSG_COLD.ttfb)}ms vs SSR 冷连接 ${ms(R.SSR_COLD.ttfb)}ms`);
console.log(`JS 建 DOM 单价 / 浏览器解析器单价            : ${r1(CFG.JS_DOM_PER_NODE / CFG.HTML_PARSE_PER_NODE)}×`);
console.log(`gzip 减半(SSG 路径) 传输 : 解析            : ${ms(gzHalf.jsTransfer)}ms : ${ms(gzHalf.jsParse)}ms(前者减半,后者精确不变)`);
console.log(`临界"JS 建 DOM"单价                          : ${r2(domStar)} ms/节点(基线 ${CFG.JS_DOM_PER_NODE})------超过它,CSR 的 TTI 反超 SSR`);
console.log(`岛屿/RSC/可恢复 的峡谷 vs 各自水合            : ${ms(isl.valley)}/${ms(isl.hydrateMs)} · ${ms(rsc.valley)}/${ms(rsc.hydrateMs)} · ${ms(resume.valley)}/${ms(resume.hydrateMs)}(HTML 账成为地板)`);

console.log('');
console.log(`断言:${assertions - failures.length}/${assertions} PASS`);
console.log(`扫描:${scanned.length} 组参数组合 × 5 条保序结论,全部通过`);
if (failures.length) {
  console.log('\n失败项:');
  for (const f of failures) console.log('  ✗ ' + f);
  process.exit(1);
}
console.log('全部断言通过 ✅');

实跑输出:

text 复制代码
====================================================================================================
render-budget.mjs ------ 渲染策略时间预算
页面:2400 组件 / 9000 DOM 节点 / JS 700KB raw·230KB gzip / HTML 180KB raw
链路 1.5KB/ms(≈12Mbps)· RTT 40ms · 客户端 CPU 4× 节流 · 复用连接
====================================================================================================

策略                                TTFB     FCP     LCP     TTI      峡谷      空白   JS(gz)      可交互组件
----------------------------------------------------------------------------------------------------
CSR 客户端渲染                         40.0   945.8   985.8  1065.8   120.0   945.8    230KB       2400
SSR 字符串渲染                        304.0   541.4   581.4  1888.2  1346.8   541.4    230KB       2400
SSR 流式 + Suspense                 75.0   312.4   352.4  1659.2  1346.8   312.4    230KB       2400
SSG 构建期静态化                        40.0   277.4   317.4  1624.2  1346.8   277.4    230KB       2400
ISR 增量再生成(命中)                     40.0   277.4   317.4  1624.2  1346.8   277.4    230KB       2400
SSG + 岛屿(同框架运行时)                  40.0   277.4   317.4   429.3   151.9   277.4     69KB        300
SSG + RSC(服务端组件不运 JS)             40.0   277.4   317.4   368.6    91.1   277.4   29.6KB        180
可恢复(resumability)                 40.0   277.4   317.4   316.4    39.0   277.4    9.9KB        300

每一毫秒记在哪一段账上(ms)------HTML 账决定 FCP,JS 账决定 TTI:
----------------------------------------------------------------------------------------------------
策略                                  请求+边缘    HTML传输    HTML解析     绘制     JS传输     JS解析    JS建DOM       水合
CSR 客户端渲染                            40.0       2.0       1.8    2.2    153.3    210.0     540.0      0.0
SSR 字符串渲染                           304.0      39.4      90.0  108.0    153.3    210.0       0.0   1215.0
SSR 流式 + Suspense                    75.0      39.4      90.0  108.0    153.3    210.0       0.0   1215.0
SSG 构建期静态化                           40.0      39.4      90.0  108.0    153.3    210.0       0.0   1215.0
ISR 增量再生成(命中)                        40.0      39.4      90.0  108.0    153.3    210.0       0.0   1215.0
SSG + 岛屿(同框架运行时)                     40.0      39.4      90.0  108.0     46.0     63.0       0.0    151.9
SSG + RSC(服务端组件不运 JS)                40.0      39.4      90.0  108.0     19.7     27.0       0.0     91.1
可恢复(resumability)                    40.0      39.4      90.0  108.0      6.6      9.0       0.0     39.0

相对 SSR 的差额(ms)------正数 = 更快:
----------------------------------------------------------------------------------------------------
策略                                ΔTTFB     ΔFCP     ΔTTI      Δ峡谷
CSR 客户端渲染                         264.0   -404.4    822.5   1226.8
SSR 字符串渲染                           0.0      0.0      0.0      0.0
SSR 流式 + Suspense                 229.0    229.0    229.0      0.0
SSG 构建期静态化                        264.0    264.0    264.0      0.0
ISR 增量再生成(命中)                     264.0    264.0    264.0      0.0
SSG + 岛屿(同框架运行时)                  264.0    264.0   1458.9   1194.9
SSG + RSC(服务端组件不运 JS)             264.0    264.0   1519.7   1255.7
可恢复(resumability)                 264.0    264.0   1571.8   1307.8

=== 关键结论 ===
SSR 相对 CSR 多付的 TTFB(服务器渲染)      : 264ms
SSR 的 JS 账 / HTML 账                     : 1584.2ms / 237.4ms = 6.7×
SSR 峡谷                                    : 1346.8ms(= JS 就绪 369.2 + 水合 1215 − HTML 账 237.4)
JS 账里 传输 : 解析 : 水合                  : 153.3 : 210 : 1215(占比 9.7% : 13.3% : 76.7%)
水合 / JS 传输 的倍数                       : 7.9×
HTML 账分解 传输 : 解析+绘制 : 合计         : 39.4 : 198 : 237.4ms
JS 传输 + 解析(不含 preload 重叠)         : 363.3ms
JS 就绪(TTFB → JS 可用) 全量/岛屿/RSC/可恢复: 369.2 / 114.9 / 52.6 / 21.5ms
CSR 空白期 / SSR 空白期                     : 1.75×
SSR 峡谷 / CSR 峡谷                         : 11.2×
SSR 的 TTI 比 CSR 晚                        : 822.5ms(77.2%)
静态化(SSG) 的 ΔTTFB / ΔTTI / Δ峡谷          : 264 / 264 / 0
流式的 ΔTTFB / ΔTTI / Δ峡谷                  : 229 / 229 / 0
全量水合峡谷 / 岛屿峡谷                      : 8.9×
全量水合峡谷 / RSC 峡谷                      : 14.8×
全量水合峡谷 / 可恢复峡谷                    : 34.5×
冷连接额外成本                              : 100ms(对八种策略一视同仁)
  SSG 冷连接 TTFB 140ms vs SSR 冷连接 404ms
JS 建 DOM 单价 / 浏览器解析器单价            : 6×
gzip 减半(SSG 路径) 传输 : 解析            : 76.7ms : 210ms(前者减半,后者精确不变)
临界"JS 建 DOM"单价                          : 0.15 ms/节点(基线 0.06)------超过它,CSR 的 TTI 反超 SSR
岛屿/RSC/可恢复 的峡谷 vs 各自水合            : 151.9/151.9 · 91.1/91.1 · 39/39(HTML 账成为地板)

断言:115/115 PASS
扫描:81 组参数组合 × 5 条保序结论,全部通过
全部断言通过 ✅

本篇是「渲染策略与渲染架构」的上半部分(技术难点 + 工具一:渲染策略的时间预算)。下半部分继续讲工具二(可交互性曲线)、工具三(新鲜度与回源账),以及改动清单与实战场景。

相关推荐
Csvn1 小时前
SSR/SSG/ISR(下):可交互性、新鲜度与回源账
前端
Elcker4 小时前
YnuoAgent(依诺):一个 npm 包,跑起属于自己的 AI 研发平台
前端·人工智能·npm
计算机魔术师4 小时前
发现2天,修复2个月:AI挖穿PHP后,卡兹克讲透攻防时间差
前端
风骏时光牛马4 小时前
测试标题001
前端
IT_陈寒5 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记5 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
计算机魔术师5 小时前
半年前说不缺钱,半年后150亿美元超募
前端
用户5708462574405 小时前
自动化发布器里最贵的 Bug:返回成功,但什么都没发生
前端
爱吃馒头不吃辣5 小时前
加一道题,改一天代码:题库即数据的降维打击
前端·架构