一、技术难点
Day26 讲的是发现段 (资源什么时候被浏览器知道),Day27 讲的是传输段与连接段 (字节上路之后发生什么)。这一篇讲它们前面那一段:HTML 是谁生成的。
先给结论:渲染位置不是一种"性能优化",它是一次坐标变换。 它把"看不见"与"点不动"这两段时间在时间轴上互相搬家,但既不改变两段之和的下限,也不改变其中任何一段的宽度由什么决定。本文要拆掉的三个前提,都是团队里被当作常识的口头禅:
- 「SSR 让页面更快 」------实测(render-budget.mjs)SSR 把 FCP 从 945.8ms 提前到 541.4ms,同时把 TTI 从 1065.8ms 推到 1888.2ms(晚 822.5ms,+77.2%)。同一份 JS,服务端渲染之后的页面对"看一眼"更快,对"点一下"更慢。
- 「静态化能解决性能问题 」------实测(render-budget.mjs)SSG 相对 SSR 把 TTFB、FCP、TTI 各提前了 264ms,峡谷一动不动:Δ峡谷 = 0.0ms。它把整条曲线向左平移,不改变曲线的形状。
- 「
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,与实跑完全一致。于是三件事同时被解释了:
- 静态化动的是 TTFB,不是峡谷。 SSG 把 TTFB 从 304.0ms 打到 40.0ms(少 264.0ms),因为公式里 TTFB 是加数,所以 FCP 与 TTI 各自也提前了 264.0ms------但峡谷里的三个量(HTML 账、JS 就绪、水合)一个都没变。Δ峡谷 = 0.0ms。
- 流式渲染动的是 TTFB 的左边界,也不是峡谷。 流式让壳先刷出去(75.0ms),FCP 从 541.4ms 提前到 312.4ms,TTI 也从 1888.2ms 提前到 1659.2ms(因为 preload 也随首字节提前被发现)------同样,Δ峡谷 = 0.0ms。流式缩短的是"看见"的等待,不是"能点"的等待。
- 真正的大头是执行,不是体积。 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 |
三件事:
- 峡谷的最前面那一段,点击是完全无效的。 9.8% ------ 精确等于"根监听挂上之前占峡谷的比例"(脚本里有这条断言)。此时页面上连一个事件处理器都没有,点下去什么都不发生,也没有任何东西被排队。这不是"慢",是功能缺失。
- 根监听挂上之后,点击会被排队重放------但用户等的是真真切切的时间。 平均 417.6ms,P95 827ms,最坏 1179ms。这段时间恰好是 Core Web Vitals 里 INP 统计的输入延迟。所以"SSR 页面 LCP 好看、INP 难看"不是一个玄学现象,它就是这个峡谷的直方图。
- 选择性水合(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 倍"抹平的原因:把触发从"时间"换成"事件",陈旧就与流量解耦了。
难点九:全量静态生成的三笔账------构建时间、键空间、以及 Vary: Cookie
"那为什么不干脆全都静态生成?"因为全量预渲染的成本不是线性的,而是有乘数的。
第一笔:构建时间。
| 页面数 | 全量构建 | 每天 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 种组合下都成立:
- 静态化与流式不改变峡谷(Δ峡谷 恒为 0);
- 静态化对流式的 ΔTTI 恒等于 ΔTTFB;
- 岛屿 < 全量水合的峡谷,RSC < 岛屿;
- 岛屿/RSC/可恢复的峡谷恒等于各自的水合成本(HTML 账成为地板);
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 条保序结论,全部通过
全部断言通过 ✅
本篇是「渲染策略与渲染架构」的上半部分(技术难点 + 工具一:渲染策略的时间预算)。下半部分继续讲工具二(可交互性曲线)、工具三(新鲜度与回源账),以及改动清单与实战场景。