SSR/SSG/ISR(下):可交互性、新鲜度与回源账

承接上一篇:上篇把「渲染策略」变成了一个可算的时间预算(工具一)。本篇继续工具二(可交互性曲线与点击账)、工具三(新鲜度与回源两本账),以及改动清单、十二纪律与八个实战场景。

2.2 工具二:把"可交互性"记成一条曲线和一本点击账

hydration-ledger.mjs 是评审期用的尺子。它承认一个被 TTI 掩盖的事实:页面不是"可交互 / 不可交互"的二值状态,而是一条随时间上升的覆盖率曲线。 所以它把页面切成 24 个区域,做两件事:

  1. 算覆盖率曲线:每个区域什么时候真正能响应事件,再对时间积分得到"平均覆盖率";
  2. 算点击账:在峡谷里均匀采样 4000 次点击,分成三类------石沉大海(根监听都没挂上)、排队重放(等它水合完)、立刻响应;对"排队重放"的样本统计平均等待与 P95。

这本书给出了 TTI 这个指标看不见的东西:

模式 平均覆盖率 石沉大海 排队重放 排队者平均等 P95
全量水合·单包 53.9% 9.8% 36.4% 417.6ms 827ms
全量水合·分包(等齐) 53.9% 0% 46.1% 434.9ms 889ms
选择性水合 68.5% 0% 31.5% 379.5ms 691ms
岛屿 100% 0% 0% 0ms 30ms
可恢复 100% 0% 0% 0ms 30ms

"全量水合"和"等齐式分包"的平均覆盖率完全相同(都是 53.9%),TTI 也完全相同(1624.2ms) ------分包只改变了下载的并行度,没有改变"必须等 JS 全部到齐再开始水合"这件事。只有选择性水合(Suspense 边界边到边水合)改变了覆盖率的形状(68.5%),而它靠的是让水合 CPU 与分包下载重叠,总 CPU 一分没少。

脚本还包含一个规模扫描(1×~8×)与一本不匹配账(根级 vs 子树级回退、期望代价与长尾用户数),以及"运行时地板"的核算------多框架的岛屿为什么可能比全量水合还重,在这一节里是可算的。

js 复制代码
#!/usr/bin/env node
'use strict';
/*
 * hydration-ledger.mjs ------ 水合账本:可交互覆盖率、峡谷里的每一次点击、以及不匹配的代价
 *
 * 零依赖、确定性。回答四个问题:
 *   1. 页面"有内容"之后到"能点"之前,用户点下去会发生什么?(丢失 / 排队 / 等多久)
 *   2. 全量水合 vs 分包 vs 选择性水合 vs 岛屿 vs 可恢复,可交互覆盖率曲线差多少?
 *   3. 岛屿到底省下了什么?它的下限在哪里(运行时地板)?
 *   4. 水合不匹配(mismatch)的代价有多大------子树级回退与根级回退差几倍?
 *
 * 断言失败即 exit 1。
 */

// ============ 参数(与 Day28 正文「运行环境」逐条对齐)============
const CFG = {
  RTT: 40,                    // ms
  BW: 1.5,                    // KB/ms ≈ 12Mbps
  GZIP_RATIO: 230 / 700,
  HTML_PARSE_PER_NODE: 0.010,
  PAINT_PER_NODE: 0.012,
  JS_PARSE_PER_KB: 0.30,      // 解压后 KB
  JS_DOM_PER_NODE: 0.060,
  HYDRATE_PER_COMP: 0.45,
  HYDRATE_PER_NODE: 0.015,
  ATTACH_PER_COMP: 0.05,
  RESUME_STARTUP: 15,
  RESUME_PER_ISLAND: 0.08,
  HANDLER_MS: 30,             // 处理器本身的执行时间(所有模式相同的公共底噪)
};

const PAGE = {
  components: 2400, nodes: 9000, interactive: 300, regions: 24,
  htmlKB: 180, jsRawKB: 700,
};
PAGE.htmlGzipKB = PAGE.htmlKB * CFG.GZIP_RATIO;
PAGE.jsGzipKB = PAGE.jsRawKB * CFG.GZIP_RATIO;
const RUNTIME_KB = 140;
const PER_COMP_KB = (PAGE.jsRawKB - RUNTIME_KB) / PAGE.components;
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 r3 = (x) => Math.round(x * 1000) / 1000;

let assertions = 0;
const failures = [];
function ok(c, m) { assertions++; if (!c) failures.push(m); }
function near(a, b, tolPct, m) {
  const d = Math.abs(a - b) / Math.max(Math.abs(b), 1e-9) * 100;
  ok(d <= tolPct, `${m}(|${r3(a)} vs ${r3(b)}| = ${r2(d)}% 超出容差 ${tolPct}%)`);
}
function gt(a, b, m) { ok(a > b, `${m}(${r3(a)} 应 > ${r3(b)})`); }
function lt(a, b, m) { ok(a < b, `${m}(${r3(a)} 应 < ${r3(b)})`); }

// ============ 时间线的公共前缀(静态 HTML 命中边缘)============
const T = {};
T.ttfb = CFG.RTT;
T.htmlTransfer = PAGE.htmlGzipKB / CFG.BW;
T.htmlParse = PAGE.nodes * CFG.HTML_PARSE_PER_NODE;
T.paint = PAGE.nodes * CFG.PAINT_PER_NODE;
T.htmlAccount = T.htmlTransfer + T.htmlParse + T.paint;
T.fcp = T.ttfb + T.htmlAccount;
T.jsStart = T.ttfb + T.htmlTransfer * 0.15;   // preload 与 HTML 解析并行

// ============ 区域(region)============
function buildRegions() {
  const out = [];
  const weights = [];
  for (let i = 0; i < PAGE.regions; i++) weights.push(i < 6 ? 3 : 1);   // 首屏 6 个区域交互更密
  const total = weights.reduce((a, b) => a + b, 0);
  for (let i = 0; i < PAGE.regions; i++) {
    out.push({
      idx: i,
      interactive: Math.round(PAGE.interactive * weights[i] / total),
      nodes: Math.round(PAGE.nodes / PAGE.regions),
      components: Math.round(PAGE.components / PAGE.regions),
    });
  }
  const fix = (key, want) => {
    const got = out.reduce((a, r) => a + r[key], 0);
    out[out.length - 1][key] += want - got;
  };
  fix('interactive', PAGE.interactive); fix('nodes', PAGE.nodes); fix('components', PAGE.components);
  return out;
}
const REGIONS = buildRegions();

/** 把一份 JS 切成 n 块(首块前置加载),返回每块 gzip 大小 */
function splitChunks(totalGzipKB, n, frontLoad = 0.4) {
  if (n === 1) return [totalGzipKB];
  const ws = [];
  for (let i = 0; i < n; i++) ws.push(i === 0 ? frontLoad * n : (1 - frontLoad));
  const wsum = ws.reduce((a, b) => a + b, 0);
  const out = [];
  let left = totalGzipKB;
  for (let i = 0; i < n - 1; i++) { const kb = totalGzipKB * ws[i] / wsum; out.push(kb); left -= kb; }
  out.push(left);
  return out;
}

/**
 * 模式:
 *   scope     'all' 全部组件水合 / 'interactive' 只水合交互组件 / 'resume' 不重放
 *   selective true  ------ 某区域的分包一到就可以水合(可重叠)
 *             false ------ 必须等全部 JS 到齐才开始(await Promise.all 式的传统做法)
 */
const MODES = [
  { key: 'FULL-ONE', name: '全量水合·单包', scope: 'all', chunks: 1, selective: false },
  { key: 'FULL-SPLIT', name: '全量水合·分包(等齐)', scope: 'all', chunks: 5, selective: false },
  { key: 'SELECTIVE', name: '选择性水合(边到边水合)', scope: 'all', chunks: 5, selective: true },
  { key: 'ISLANDS', name: '岛屿(只水合交互组件)', scope: 'interactive', chunks: 8, selective: true },
  { key: 'RESUME', name: '可恢复(不重放)', scope: 'resume', chunks: 4, selective: true },
];

function schedule(mode) {
  const jsGzipKB = mode.scope === 'all' ? PAGE.jsGzipKB
                 : mode.scope === 'interactive' ? gz(RUNTIME_KB + PAGE.interactive * PER_COMP_KB)
                 : gz(30);
  const jsRawKB = mode.scope === 'all' ? PAGE.jsRawKB
                : mode.scope === 'interactive' ? RUNTIME_KB + PAGE.interactive * PER_COMP_KB
                : 30;
  const chunks = splitChunks(jsGzipKB, mode.chunks);

  // 1) 分包到达:顺序下载 + 该块解析(主线程串行)
  let t = T.jsStart;
  const chunkArrival = [];
  for (const kb of chunks) {
    t += kb / CFG.BW;
    t += (kb / CFG.GZIP_RATIO) * CFG.JS_PARSE_PER_KB;
    chunkArrival.push(t);
  }
  const allArrived = chunkArrival[chunkArrival.length - 1];
  const regionChunk = REGIONS.map((_, i) => Math.min(mode.chunks - 1, Math.floor(i * mode.chunks / PAGE.regions)));

  // 2) 每个区域的水合成本(主线程串行的一段 CPU)
  const perRegionCost = REGIONS.map((r) => {
    if (mode.scope === 'resume') return CFG.RESUME_STARTUP / REGIONS.length;
    const comps = mode.scope === 'all' ? r.components : r.interactive;
    // 岛屿只水合"交互区域内的 DOM":按交互组件占全站组件的比例切节点
    const nodes = mode.scope === 'all' ? r.nodes : r.nodes * (PAGE.interactive / PAGE.components);
    return comps * CFG.HYDRATE_PER_COMP + nodes * CFG.HYDRATE_PER_NODE;
  });
  const hydrateCpuMs = mode.scope === 'resume'
    ? CFG.RESUME_STARTUP + PAGE.interactive * CFG.RESUME_PER_ISLAND
    : perRegionCost.reduce((a, b) => a + b, 0);

  // 3) 串行 CPU 队列:门槛 = 该区域所属分包到达(selective)或全部到齐(非 selective)
  const ready = [];
  let cpu = 0;
  for (let i = 0; i < REGIONS.length; i++) {
    const gate = mode.selective ? chunkArrival[regionChunk[i]] : allArrived;
    const start = Math.max(gate, cpu);
    cpu = start + perRegionCost[i];
    ready.push(cpu);
  }
  if (mode.scope === 'resume') {
    // 可恢复:一次性序列化状态 + 可见区域惰性恢复,与分包到达取较晚者
    let acc = chunkArrival[0];
    const perRegion = hydrateCpuMs / REGIONS.length;
    for (let i = 0; i < REGIONS.length; i++) {
      acc += perRegion;
      ready[i] = Math.max(chunkArrival[regionChunk[i]], acc);
    }
    cpu = acc;
  }

  const rootListenerAt = chunkArrival[0];       // 根监听挂上后才可能"排队重放"
  const ttiRaw = ready[ready.length - 1];
  const tti = Math.max(ttiRaw, T.fcp);          // 可交互不可能早于首帧(看不到就点不到)
  return {
    jsGzipKB, jsRawKB, chunks, chunkArrival, allArrived, regionChunk, ready, cpu,
    rootListenerAt, ttiRaw, tti, mode, hydrateCpuMs, paintBound: ttiRaw <= T.fcp,
  };
}

/** 覆盖率:t 时刻能响应的交互组件比例 */
function coverageAt(S, t) {
  let live = 0;
  for (let i = 0; i < REGIONS.length; i++) if (S.ready[i] <= t) live += REGIONS[i].interactive;
  return live / PAGE.interactive;
}
function coverageIntegral(S, a, b, steps = 2000) {
  let sum = 0;
  const dt = (b - a) / steps;
  for (let k = 0; k < steps; k++) sum += coverageAt(S, a + (k + 0.5) * dt) * dt;
  return sum;
}
function timeToCoverage(S, frac, a, b, steps = 4000) {
  const dt = (b - a) / steps;
  for (let k = 0; k <= steps; k++) { const t = a + k * dt; if (coverageAt(s0(S)) >= 0) break; }
  for (let k = 0; k <= steps; k++) { const t = a + k * dt; if (coverageAt(S, t) >= frac - 1e-9) return t; }
  return b;
}
const s0 = (S) => S;

/** 峡谷里的每一次点击 */
function clickLedger(S, a, b, samples = 4000) {
  const targets = [];
  for (let i = 0; i < REGIONS.length; i++) for (let k = 0; k < REGIONS[i].interactive; k++) targets.push(i);
  let lost = 0, queued = 0, instant = 0, sumAll = 0, sumQueued = 0, lats = [];
  for (let s = 0; s < samples; s++) {
    const t = a + (b - a) * (s + 0.5) / samples;
    const region = targets[s % targets.length];
    if (S.ready[region] <= t) { instant++; sumAll += CFG.HANDLER_MS; lats.push(CFG.HANDLER_MS); continue; }
    if (t < S.rootListenerAt) { lost++; lats.push(-1); continue; }   // 根监听都没挂上:石沉大海
    queued++;
    const lat = (S.ready[region] - t) + CFG.HANDLER_MS;
    sumAll += lat; sumQueued += lat; lats.push(lat);
  }
  lats.sort((x, y) => x - y);
  const okLats = lats.filter((x) => x >= 0);
  return {
    lostPct: lost / samples * 100,
    queuedPct: queued / samples * 100,
    instantPct: instant / samples * 100,
    avgLatency: sumAll / samples,                                   // 丢失按 0 计(用户没得到任何响应)
    queuedAvg: queued ? sumQueued / queued : 0,
    p50: okLats.length ? okLats[Math.floor(okLats.length * 0.5)] : 0,
    p95: okLats.length ? okLats[Math.floor(okLats.length * 0.95)] : 0,
    maxLatency: okLats.length ? okLats[okLats.length - 1] : 0,
  };
}

// ============ 求解 ============
const S = {};
for (const m of MODES) S[m.key] = schedule(m);
const FO = S['FULL-ONE'], FS = S['FULL-SPLIT'], SEL = S['SELECTIVE'], ISL = S['ISLANDS'], RES = S['RESUME'];
const valleyRef = FO.tti - T.fcp;
const WINDOW = [T.fcp, FO.tti];
const L = {};
for (const m of MODES) L[m.key] = clickLedger(S[m.key], WINDOW[0], WINDOW[1]);
const meanCov = (s) => coverageIntegral(s, WINDOW[0], WINDOW[1]) / (WINDOW[1] - WINDOW[0]) * 100;

console.log('='.repeat(104));
console.log('hydration-ledger.mjs ------ 水合账本');
console.log(`页面:${PAGE.components} 组件 / ${PAGE.nodes} 节点 / ${PAGE.regions} 区域 / 交互组件 ${PAGE.interactive}(${r1(PAGE.interactive / PAGE.components * 100)}%)`);
console.log(`静态 HTML 命中边缘:TTFB ${ms(T.ttfb)}ms · FCP ${ms(T.fcp)}ms · JS ${r1(PAGE.jsGzipKB)}KB gzip · 链路 ${CFG.BW}KB/ms · CPU 4× 节流`);
console.log('='.repeat(104));
console.log('');
console.log('模式'.padEnd(24) + 'JS(gz)'.padStart(9) + '块'.padStart(4) + '首块到达'.padStart(9) + '全部到齐'.padStart(9) +
            '水合CPU'.padStart(9) + 'TTI'.padStart(8) + '50%覆盖'.padStart(9) + '90%覆盖'.padStart(9) +
            '平均覆盖率'.padStart(10) + '受首帧限'.padStart(9));
console.log('-'.repeat(104));
for (const m of MODES) {
  const s = S[m.key];
  console.log(
    m.name.padEnd(24) +
    (s.jsGzipKB.toFixed(1) + 'KB').padStart(9) +
    String(m.chunks).padStart(4) +
    ms(s.chunkArrival[0]).toFixed(1).padStart(9) +
    ms(s.allArrived).toFixed(1).padStart(9) +
    ms(s.hydrateCpuMs).toFixed(1).padStart(9) +
    ms(s.tti).toFixed(1).padStart(8) +
    ms(Math.min(timeToCoverage(s, 0.5, WINDOW[0], s.tti + 1), s.tti)).toFixed(1).padStart(9) +
    ms(Math.min(timeToCoverage(s, 0.9, WINDOW[0], s.tti + 1), s.tti)).toFixed(1).padStart(9) +
    (r1(meanCov(s)) + '%').padStart(10) +
    (s.paintBound ? '是' : '否').padStart(9));
}
console.log('');
console.log(`峡谷里的每一次点击(窗口 = FCP ${ms(WINDOW[0])}ms → 全量水合 TTI ${ms(WINDOW[1])}ms,共 ${ms(valleyRef)}ms):`);
console.log('-'.repeat(104));
console.log('模式'.padEnd(24) + '石沉大海'.padStart(10) + '排队重放'.padStart(10) + '立刻响应'.padStart(10) +
            '排队者平均等'.padStart(13) + 'P50'.padStart(8) + 'P95'.padStart(8) + '最坏'.padStart(9));
for (const m of MODES) {
  const l = L[m.key];
  console.log(
    m.name.padEnd(24) +
    (r1(l.lostPct) + '%').padStart(10) +
    (r1(l.queuedPct) + '%').padStart(10) +
    (r1(l.instantPct) + '%').padStart(10) +
    (ms(l.queuedAvg) + 'ms').padStart(13) +
    ms(l.p50).toFixed(0).padStart(8) +
    ms(l.p95).toFixed(0).padStart(8) +
    ms(l.maxLatency).toFixed(0).padStart(9));
}

// ---------- 断言 ----------
near(T.fcp, T.ttfb + T.htmlAccount, 0.01, 'FCP = TTFB + HTML 账');
near(valleyRef, FO.tti - T.fcp, 0.01, '基准峡谷 = TTI − FCP');
ok(REGIONS.reduce((a, r) => a + r.interactive, 0) === PAGE.interactive, '区域交互组件数之和 = 总数');
ok(REGIONS.reduce((a, r) => a + r.nodes, 0) === PAGE.nodes, '区域节点数之和 = 总数');
ok(REGIONS.reduce((a, r) => a + r.components, 0) === PAGE.components, '区域组件数之和 = 总数');
ok(MODES.length === 4 + 1, '应有 5 种模式进入对比');

for (const m of MODES) {
  const s = S[m.key];
  ok(s.ready.every((x, i) => i === 0 || x >= s.ready[i - 1] - 1e-9), `${m.key}: 区域就绪时刻单调不减`);
  ok(s.chunkArrival.every((x, i) => i === 0 || x > s.chunkArrival[i - 1]), `${m.key}: 分包到达时刻严格递增`);
  near(s.allArrived, s.chunkArrival[s.chunkArrival.length - 1], 0.01, `${m.key}: allArrived = 最后一块`);
  gt(s.tti, T.fcp - 1e-9, `${m.key}: TTI 不可能早于 FCP`);
  gteNear(s.tti, T.fcp, `${m.key}: TTI 不低于首帧`);
  near(coverageAt(s, s.tti), 1, 0.01, `${m.key}: TTI 时刻覆盖率为 100%`);
  for (let k = 0; k < 10; k++) {
    const t1 = WINDOW[0] + (WINDOW[1] - WINDOW[0]) * k / 10, t2 = WINDOW[0] + (WINDOW[1] - WINDOW[0]) * (k + 1) / 10;
    ok(coverageAt(s, t2) >= coverageAt(s, t1) - 1e-9, `${m.key}: 覆盖率单调不减`);
  }
  near(L[m.key].lostPct + L[m.key].queuedPct + L[m.key].instantPct, 100, 0.01, `${m.key}: 三类点击占比之和 = 100%`);
}
function gteNear(a, b, m) { ok(a >= b - 1e-9, m); }

// 1. 全量水合:JS 就绪前点击石沉大海,之后排队重放
ok(coverageAt(FO, T.jsStart) === 0, '全量水合:preload 起点覆盖率为 0');
near(coverageAt(FO, FO.rootListenerAt - 1), 0, 0.01, '全量水合:根监听挂上前覆盖率恒为 0');
gt(L['FULL-ONE'].lostPct, 5, `全量水合应有一批点击石沉大海(实际 ${r1(L['FULL-ONE'].lostPct)}%)`);
near(L['FULL-ONE'].lostPct, (FO.rootListenerAt - WINDOW[0]) / valleyRef * 100, 0.5,
     '石沉大海的比例 = 根监听挂上之前占峡谷的比例');
ok(FO.paintBound === false, '全量水合的 TTI 不应受首帧限制');

// 2. 核心:峡谷的地板 = 水合的 CPU 总量
gt(FO.hydrateCpuMs, FO.jsTransfer !== undefined ? 0 : 0, '占位');
const JS_TRANSFER = PAGE.jsGzipKB / CFG.BW;
const JS_PARSE = PAGE.jsRawKB * CFG.JS_PARSE_PER_KB;
const jsReadyOffset = 0.15 * T.htmlTransfer + JS_TRANSFER + JS_PARSE;
near(FO.hydrateCpuMs, PAGE.components * CFG.HYDRATE_PER_COMP + PAGE.nodes * CFG.HYDRATE_PER_NODE, 0.01,
     '全量水合的 CPU = 组件数 × 单价 + 节点数 × 单价');
near(FO.tti - T.fcp, jsReadyOffset + FO.hydrateCpuMs - T.htmlAccount, 0.01,
     '峡谷 = JS 就绪 + 水合 CPU − HTML 账');
ok(FO.hydrateCpuMs > jsReadyOffset, `水合 CPU 比 JS 传输+解析还大(${ms(FO.hydrateCpuMs)} vs ${ms(jsReadyOffset)})------所以砍体积是最钝的一刀`);
near(SEL.hydrateCpuMs, FO.hydrateCpuMs, 0.01, '选择性水合的总 CPU 一分没少');
near(FS.hydrateCpuMs, FO.hydrateCpuMs, 0.01, '分包水合的总 CPU 一分没少');
near(FO.hydrateCpuMs, FS.hydrateCpuMs, 0.01, '三种全量水合模式的 CPU 完全相同');

// 3. 选择性水合:省的是"等待",不是"工作"
gt(FO.tti, SEL.tti, '选择性水合应把 TTI 提前(水合 CPU 与下载重叠)');
ok(SEL.tti - T.fcp > FO.hydrateCpuMs * 0.9,
   `选择性水合的峡谷仍不低于水合 CPU 的 90%(${ms(SEL.tti - T.fcp)} vs ${ms(FO.hydrateCpuMs)})------地板没被突破`);
gt(meanCov(SEL), meanCov(FO) + 8, `选择性水合应显著提高平均覆盖率(${r1(meanCov(SEL))}% vs ${r1(meanCov(FO))}%)`);
lt(L['SELECTIVE'].queuedAvg, L['FULL-ONE'].queuedAvg, '选择性水合应降低排队者平均等待');
gt(FS.tti, SEL.tti, '等齐式分包拿不到重叠收益(必须等全部 JS 到齐才开始水合)');
gt(L['FULL-SPLIT'].queuedAvg, L['SELECTIVE'].queuedAvg, '等齐式分包的排队等待应长于选择性水合');


// 4. 峡谷平均点击延迟:排队者要等数百毫秒(≈ 峡谷的 1/3)
ok(L['FULL-ONE'].queuedAvg > 250, `全量水合:被排队的点击平均要等数百毫秒(实际 ${ms(L['FULL-ONE'].queuedAvg)}ms)`);
ok(L['FULL-ONE'].queuedAvg / valleyRef > 0.25 && L['FULL-ONE'].queuedAvg / valleyRef < 0.45,
   `排队等待 / 峡谷 应落在 0.25~0.45 之间(实际 ${r2(L['FULL-ONE'].queuedAvg / valleyRef)})`);
ok(L['FULL-ONE'].p95 > 600, `全量水合:P95 点击延迟应达数百毫秒(实际 ${ms(L['FULL-ONE'].p95)}ms)`);

// 5. 岛屿与可恢复:在这张页面上都已落进"首帧之内"
ok(ISL.paintBound && RES.paintBound, '岛屿与可恢复的水合在首帧之前就完成了(TTI 受首帧限制)');
near(ISL.tti, T.fcp, 0.01, '岛屿:TTI = 首帧(可交互不再是瓶颈)');
near(RES.tti, T.fcp, 0.01, '可恢复:TTI = 首帧');
near(L['ISLANDS'].instantPct, 100, 0.01, '岛屿:峡谷里 100% 的点击立刻响应');
near(L['RESUME'].instantPct, 100, 0.01, '可恢复:峡谷里 100% 的点击立刻响应');
lt(ISL.jsGzipKB, SEL.jsGzipKB * 0.4, '岛屿运的 JS 应不到选择性水合的 40%');
gt(SEL.tti, ISL.tti, '岛屿的 TTI 应早于选择性水合');
ok(L['FULL-ONE'].queuedAvg > L['ISLANDS'].queuedAvg * 5,
   `全量水合的排队等待应是岛屿的 5 倍以上(${r1(L['FULL-ONE'].queuedAvg / CFG.HANDLER_MS)}×)`);

// 6. 页面规模扫描:岛屿 vs 可恢复的分水岭在"水合 CPU 超过首帧账"之后
console.log('');
console.log('=== 页面规模扫描(组件数增长,DOM 与 HTML 不变)===');
console.log('-'.repeat(104));
console.log('规模'.padEnd(8) + '组件数'.padStart(9) + '交互组件'.padStart(10) + '全量水合CPU'.padStart(13) +
            '全量TTI'.padStart(10) + '岛屿CPU'.padStart(10) + '岛屿TTI'.padStart(10) +
            '可恢复CPU'.padStart(11) + '可恢复TTI'.padStart(11));
const scaleCase = (scale) => {
  const comps = PAGE.components * scale, inter = PAGE.interactive * scale;
  const fullCpu = comps * CFG.HYDRATE_PER_COMP + PAGE.nodes * CFG.HYDRATE_PER_NODE;
  const islRaw = RUNTIME_KB + inter * PER_COMP_KB;
  const islCpu = inter * CFG.HYDRATE_PER_COMP + PAGE.nodes * (inter / comps) * CFG.HYDRATE_PER_NODE;
  const resCpu = CFG.RESUME_STARTUP + inter * CFG.RESUME_PER_ISLAND;
  const ttiOf = (rawKB, cpu) => {
    const off = 0.15 * T.htmlTransfer + rawKB * CFG.GZIP_RATIO / CFG.BW + rawKB * CFG.JS_PARSE_PER_KB;
    return Math.max(T.fcp, T.ttfb + Math.max(T.htmlAccount, off) + cpu);
  };
  return { scale, comps, inter, fullCpu, islCpu, resCpu, islRaw,
    islJsOffset: 0.15 * T.htmlTransfer + islRaw * CFG.GZIP_RATIO / CFG.BW + islRaw * CFG.JS_PARSE_PER_KB,
    fullTTI: ttiOf(PAGE.jsRawKB, fullCpu), islTTI: ttiOf(islRaw, islCpu), resTTI: ttiOf(30, resCpu) };
};
const SCALES = [1, 2, 4, 8];
const scaleRows = SCALES.map(scaleCase);
for (const r of scaleRows) {
  console.log(
    (r.scale + '×').padEnd(8) +
    String(r.comps).padStart(9) +
    String(r.inter).padStart(10) +
    (ms(r.fullCpu) + 'ms').padStart(13) +
    (ms(r.fullTTI) + 'ms').padStart(10) +
    (ms(r.islCpu) + 'ms').padStart(10) +
    (ms(r.islTTI) + 'ms').padStart(10) +
    (ms(r.resCpu) + 'ms').padStart(11) +
    (ms(r.resTTI) + 'ms').padStart(11));
}
const r1x = scaleRows[0], r8x = scaleRows[scaleRows.length - 1];
ok(scaleRows.every((r, i) => i === 0 || r.fullCpu > scaleRows[i - 1].fullCpu), '全量水合 CPU 随规模线性增长');
ok(scaleRows.every((r, i) => i === 0 || r.fullTTI > scaleRows[i - 1].fullTTI), '全量水合 TTI 随规模单调上升');
ok(scaleRows.every((r, i) => i === 0 || r.islTTI > scaleRows[i - 1].islTTI), '岛屿 TTI 随规模单调上升');
ok(scaleRows.every((r, i) => i === 0 || r.resTTI >= scaleRows[i - 1].resTTI - 1e-9), '可恢复 TTI 单调不减');
near(r1x.fullCpu, FO.hydrateCpuMs, 0.01, '1× 规模与主模型一致');
ok(r8x.fullTTI / r1x.fullTTI > 3, `8× 规模下全量水合 TTI 增长 3 倍以上(${r2(r8x.fullTTI / r1x.fullTTI)}×)`);
ok(r8x.resTTI / r1x.resTTI < 2, `8× 规模下可恢复 TTI 增长不到 2 倍(${r2(r8x.resTTI / r1x.resTTI)}× vs 全量 ${r2(r8x.fullTTI / r1x.fullTTI)}×)`);
near(r8x.resTTI - T.fcp, r8x.resCpu, 0.01, '可恢复的峡谷 = 它自己的惰性恢复 CPU(O(交互组件),不是 O(全部组件))');
near(r8x.islTTI - T.fcp, Math.max(T.htmlAccount, r8x.islJsOffset) + r8x.islCpu - T.htmlAccount, 0.01,
     '岛屿的峡谷 = max(HTML 账, JS 就绪) + 水合 CPU − HTML 账');
ok(r8x.resCpu / r8x.fullCpu < 0.03, `8× 规模下可恢复的 CPU 只有全量水合的 ${r1(r8x.resCpu / r8x.fullCpu * 100)}%`);
ok(r8x.islTTI < r8x.fullTTI * 0.3, `8× 规模下岛屿 TTI 不到全量水合的 30%(${r2(r8x.islTTI / r8x.fullTTI)}×)`);
ok(r8x.islTTI > r1x.islTTI * 1.5, `岛屿 TTI 随规模明显上升(${r2(r8x.islTTI / r1x.islTTI)}×)------它的成本仍是 O(交互组件数)`);

// ============ 岛屿的下限:运行时地板 ============
console.log('');
console.log('=== 岛屿的下限(运行时地板)===');
console.log('-'.repeat(104));
const islandJs = (ratio) => RUNTIME_KB + PAGE.components * ratio * PER_COMP_KB;
console.log('交互组件占比'.padEnd(14) + '岛屿 JS(raw)'.padStart(14) + '岛屿 JS(gzip)'.padStart(15) +
            '相对全量'.padStart(10) + '省下'.padStart(9) + '说明'.padStart(24));
for (const ratio of [0, 0.02, 0.125, 0.25, 0.5, 0.75, 1.0]) {
  const raw = islandJs(ratio);
  console.log(
    (r1(ratio * 100) + '%').padEnd(14) +
    (r1(raw) + 'KB').padStart(14) +
    (r1(gz(raw)) + 'KB').padStart(15) +
    (r1(gz(raw) / PAGE.jsGzipKB * 100) + '%').padStart(10) +
    (r1((1 - gz(raw) / PAGE.jsGzipKB) * 100) + '%').padStart(9) +
    (ratio === 0 ? '只剩运行时' : ratio === 1 ? '恰好等于全量水合' : '').padStart(24));
}
near(islandJs(1.0), PAGE.jsRawKB, 0.01, '交互占比 100% 时,岛屿 JS 恰好等于全量水合');
near(islandJs(0), RUNTIME_KB, 0.01, '一个交互组件都不剩时,JS 只剩运行时');
ok(islandJs(0.125) < PAGE.jsRawKB * 0.35, '本篇页面形态(12.5% 交互)下岛屿 JS 不到全量的 35%');
const ratioSeries = [0.02, 0.125, 0.5, 1.0].map(islandJs);
ok(ratioSeries.every((x, i) => i === 0 || x > ratioSeries[i - 1]), '岛屿 JS 随交互占比单调递增');

console.log('');
console.log(`多框架的运行时地板(每个框架运行时 ${RUNTIME_KB}KB raw / ${r1(gz(RUNTIME_KB))}KB gzip):`);
console.log('-'.repeat(104));
console.log('框架数'.padEnd(9) + '运行时地板(raw)'.padStart(16) + 'gzip'.padStart(10) +
            '占全量 JS'.padStart(11) + '再加 12.5% 交互组件'.padStart(20));
const runtimeFloors = [];
for (const n of [1, 2, 3, 4, 5, 6]) {
  const raw = RUNTIME_KB * n;
  runtimeFloors.push({ n, raw, gzip: gz(raw) });
  console.log(
    String(n).padEnd(9) +
    (r1(raw) + 'KB').padStart(16) +
    (r1(gz(raw)) + 'KB').padStart(10) +
    (r1(gz(raw) / PAGE.jsGzipKB * 100) + '%').padStart(11) +
    (r1(gz(raw + PAGE.components * 0.125 * PER_COMP_KB)) + 'KB').padStart(20));
}
ok(runtimeFloors.every((x, i) => i === 0 || x.raw > runtimeFloors[i - 1].raw), '运行时地板随框架数线性上升');
near(runtimeFloors.find((x) => x.n === 3).raw, 3 * RUNTIME_KB, 0.01, '3 个框架 = 3 份运行时');
ok(3 * RUNTIME_KB > islandJs(0.125), `3 个框架的运行时地板已超过单框架岛屿的全部 JS(${r1(3 * RUNTIME_KB)} > ${r1(islandJs(0.125))})`);
near(runtimeFloors.find((x) => x.n === 5).gzip, PAGE.jsGzipKB, 1.5,
     '5 个框架的运行时地板 ≈ 全量水合要运的全部 JS');
ok(runtimeFloors.find((x) => x.n === 6).gzip > PAGE.jsGzipKB,
   '6 个框架光运行时就已经超过全量水合的 JS');

// ============ 不匹配(mismatch)的代价 ============
console.log('');
console.log('=== 水合不匹配的代价(基准 = 全量水合路线 TTI ' + ms(FO.tti) + 'ms)===');
console.log('-'.repeat(104));
const SUBTREE_NODES = 400, SUBTREE_COMPS = 90;
const rootFallbackMs = PAGE.nodes * CFG.JS_DOM_PER_NODE + PAGE.components * CFG.ATTACH_PER_COMP;
const subFallbackMs = SUBTREE_NODES * CFG.JS_DOM_PER_NODE + SUBTREE_COMPS * CFG.ATTACH_PER_COMP;
console.log(`根级回退(丢掉整份 HTML 重画)  : ${ms(rootFallbackMs)}ms = ${PAGE.nodes} 节点 × ${CFG.JS_DOM_PER_NODE} + ${PAGE.components} 组件 × ${CFG.ATTACH_PER_COMP}`);
console.log(`子树级回退(只重画那一棵)      : ${ms(subFallbackMs)}ms = ${SUBTREE_NODES} 节点 × ${CFG.JS_DOM_PER_NODE} + ${SUBTREE_COMPS} 组件 × ${CFG.ATTACH_PER_COMP}`);
console.log('');
console.log('不匹配率'.padEnd(11) + '受影响用户 TTI·根级'.padStart(20) + '受影响用户 TTI·子树级'.padStart(22) +
            '页面级期望代价'.padStart(15) + '10 万 PV/天受影响'.padStart(18));
for (const p of [0, 0.001, 0.01, 0.05, 0.25, 1.0]) {
  console.log(
    (r1(p * 100) + '%').padEnd(11) +
    (ms(FO.tti + rootFallbackMs) + 'ms').padStart(20) +
    (ms(FO.tti + subFallbackMs) + 'ms').padStart(22) +
    (ms(p * rootFallbackMs) + 'ms').padStart(15) +
    String(Math.round(p * 100000)).padStart(18));
}
console.log('');
ok(rootFallbackMs / subFallbackMs > 10, `根级回退代价应是子树级的 10 倍以上(实际 ${r1(rootFallbackMs / subFallbackMs)}×)`);
ok(0.01 * rootFallbackMs < 20, `1% 不匹配率的页面级期望代价很小(${ms(0.01 * rootFallbackMs)}ms)------平均值发现不了它`);
ok(0.01 * 100000 >= 1000, '但 1% 在 10 万 PV/天下会让上千个用户看到重画与闪烁');
const sysTTI = FO.tti + rootFallbackMs;
const csrEquivalent = T.ttfb + 0.15 * T.htmlTransfer + JS_TRANSFER + JS_PARSE
                    + PAGE.nodes * CFG.JS_DOM_PER_NODE + PAGE.components * CFG.ATTACH_PER_COMP + T.paint;
gt(sysTTI, csrEquivalent, `系统性不匹配会让 SSR 路线比 CSR 路线还差(${ms(sysTTI)} > ${ms(csrEquivalent)})`);
ok(sysTTI - csrEquivalent > 200, `系统性不匹配的劣势应 > 200ms(实际 ${ms(sysTTI - csrEquivalent)}ms)`);
ok(RES.paintBound && !RES.paintBound === false, '占位(可恢复的 TTI 受首帧限制)');
// 岛屿架构下未被水合的区域,React 根本不会去核对它------不匹配不会触发回退
const islandMismatchMs = PAGE.interactive * PER_COMP_KB / CFG.GZIP_RATIO * 0; // 结构性说明,不做数值断言
ok(islandMismatchMs === 0, '岛屿架构:未被水合的区域不会核对 HTML,因此不会触发回退(代价变成视觉错位而非重画)');

console.log('');
console.log('=== 关键结论 ===');
console.log(`基准峡谷(全量水合·单包)                  : ${ms(valleyRef)}ms`);
console.log(`峡谷的地板 = 水合 CPU                      : ${ms(FO.hydrateCpuMs)}ms(JS 传输+解析只有 ${ms(jsReadyOffset)}ms)`);
console.log(`全量水合:石沉大海 / 排队重放 / 立刻响应      : ${r1(L['FULL-ONE'].lostPct)}% / ${r1(L['FULL-ONE'].queuedPct)}% / ${r1(L['FULL-ONE'].instantPct)}%`);
console.log(`全量水合:排队者平均等 / P95                : ${ms(L['FULL-ONE'].queuedAvg)}ms / ${ms(L['FULL-ONE'].p95)}ms`);
console.log(`平均覆盖率  全量 / 等齐分包 / 选择性 / 岛屿 / 可恢复: ${r1(meanCov(FO))}% / ${r1(meanCov(FS))}% / ${r1(meanCov(SEL))}% / ${r1(meanCov(ISL))}% / ${r1(meanCov(RES))}%`);
console.log(`TTI  全量 ${ms(FO.tti)} / 选择性 ${ms(SEL.tti)} / 岛屿 ${ms(ISL.tti)} / 可恢复 ${ms(RES.tti)}ms(岛屿与可恢复已落进首帧 ${ms(T.fcp)}ms 之内)`);
console.log(`岛屿 JS 下限(12.5% 交互)                  : ${r1(islandJs(0.125))}KB raw = ${r1(gz(islandJs(0.125)))}KB gzip(全量的 ${r1(gz(islandJs(0.125)) / PAGE.jsGzipKB * 100)}%)`);
console.log(`多框架运行时地板:3 框架 ${r1(gz(3 * RUNTIME_KB))}KB gzip · 5 框架 ${r1(gz(5 * RUNTIME_KB))}KB gzip ≈ 全量 JS ${r1(PAGE.jsGzipKB)}KB`);
console.log(`8× 规模(${r8x.comps} 组件):全量 TTI ${ms(r8x.fullTTI)}ms / 岛屿 ${ms(r8x.islTTI)}ms / 可恢复 ${ms(r8x.resTTI)}ms`);
console.log(`  ------ 8× 规模下岛屿的 JS 就绪(${ms(r8x.islJsOffset)}ms)重新超过 HTML 账(${ms(T.htmlAccount)}ms),它又变回"被 JS 压着"`);
console.log(`根级 mismatch 的代价                        : +${ms(rootFallbackMs)}ms/次(子树级的 ${r1(rootFallbackMs / subFallbackMs)}×);系统性不匹配的 SSR 路线 TTI ${ms(sysTTI)}ms > CSR ${ms(csrEquivalent)}ms`);

console.log('');
console.log(`断言:${assertions - failures.length}/${assertions} PASS`);
if (failures.length) {
  console.log('\n失败项:');
  for (const f of failures) console.log('  ✗ ' + f);
  process.exit(1);
}
console.log('全部断言通过 ✅');

实跑输出:

text 复制代码
========================================================================================================
hydration-ledger.mjs ------ 水合账本
页面:2400 组件 / 9000 节点 / 24 区域 / 交互组件 300(12.5%)
静态 HTML 命中边缘:TTFB 40ms · FCP 277.4ms · JS 230KB gzip · 链路 1.5KB/ms · CPU 4× 节流
========================================================================================================

模式                         JS(gz)   块     首块到达     全部到齐    水合CPU     TTI    50%覆盖    90%覆盖     平均覆盖率     受首帧限
--------------------------------------------------------------------------------------------------------
全量水合·单包                   230.0KB   1    409.2    409.2   1215.0  1624.2    713.1   1472.6     53.9%        否
全量水合·分包(等齐)               230.0KB   5    211.1    409.2   1215.0  1624.2    713.1   1472.6     53.9%        否
选择性水合(边到边水合)              230.0KB   5    211.1    409.2   1215.0  1426.1    514.8   1274.5     68.5%        否
岛屿(只水合交互组件)                69.0KB   8     93.0    154.9    151.9   277.4    277.4    277.4      100%        是
可恢复(不重放)                    9.9KB   4     53.2     61.5     39.0   277.4    277.4    277.4      100%        是

峡谷里的每一次点击(窗口 = FCP 277.4ms → 全量水合 TTI 1624.2ms,共 1346.8ms):
--------------------------------------------------------------------------------------------------------
模式                            石沉大海      排队重放      立刻响应       排队者平均等     P50     P95       最坏
全量水合·单包                       9.8%     36.4%     53.9%      417.6ms      30     827     1179
全量水合·分包(等齐)                     0%     46.1%     53.9%      434.9ms      30     889     1280
选择性水合(边到边水合)                    0%     31.5%     68.6%      379.5ms      30     691     1082
岛屿(只水合交互组件)                     0%        0%      100%          0ms      30      30       30
可恢复(不重放)                        0%        0%      100%          0ms      30      30       30

=== 页面规模扫描(组件数增长,DOM 与 HTML 不变)===
--------------------------------------------------------------------------------------------------------
规模            组件数      交互组件      全量水合CPU     全量TTI     岛屿CPU     岛屿TTI     可恢复CPU     可恢复TTI
1×           2400       300       1215ms  1624.2ms   151.9ms   429.3ms       39ms    316.4ms
2×           4800       600       2295ms  2704.2ms   286.9ms   564.3ms       63ms    340.4ms
4×           9600      1200       4455ms  4864.2ms   556.9ms   834.3ms      111ms    388.4ms
8×          19200      2400       8775ms  9184.2ms  1096.9ms  1506.1ms      207ms    484.4ms

=== 岛屿的下限(运行时地板)===
--------------------------------------------------------------------------------------------------------
交互组件占比            岛屿 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%                恰好等于全量水合

多框架的运行时地板(每个框架运行时 140KB raw / 46KB gzip):
--------------------------------------------------------------------------------------------------------
框架数            运行时地板(raw)      gzip     占全量 JS       再加 12.5% 交互组件
1                   140KB      46KB        20%                69KB
2                   280KB      92KB        40%               115KB
3                   420KB     138KB        60%               161KB
4                   560KB     184KB        80%               207KB
5                   700KB     230KB       100%               253KB
6                   840KB     276KB       120%               299KB

=== 水合不匹配的代价(基准 = 全量水合路线 TTI 1624.2ms)===
--------------------------------------------------------------------------------------------------------
根级回退(丢掉整份 HTML 重画)  : 660ms = 9000 节点 × 0.06 + 2400 组件 × 0.05
子树级回退(只重画那一棵)      : 28.5ms = 400 节点 × 0.06 + 90 组件 × 0.05

不匹配率               受影响用户 TTI·根级         受影响用户 TTI·子树级        页面级期望代价      10 万 PV/天受影响
0%                     2284.2ms              1652.7ms            0ms                 0
0.1%                   2284.2ms              1652.7ms          0.7ms               100
1%                     2284.2ms              1652.7ms          6.6ms              1000
5%                     2284.2ms              1652.7ms           33ms              5000
25%                    2284.2ms              1652.7ms          165ms             25000
100%                   2284.2ms              1652.7ms          660ms            100000


=== 关键结论 ===
基准峡谷(全量水合·单包)                  : 1346.8ms
峡谷的地板 = 水合 CPU                      : 1215ms(JS 传输+解析只有 369.2ms)
全量水合:石沉大海 / 排队重放 / 立刻响应      : 9.8% / 36.4% / 53.9%
全量水合:排队者平均等 / P95                : 417.6ms / 827ms
平均覆盖率  全量 / 等齐分包 / 选择性 / 岛屿 / 可恢复: 53.9% / 53.9% / 68.5% / 100% / 100%
TTI  全量 1624.2 / 选择性 1426.1 / 岛屿 277.4 / 可恢复 277.4ms(岛屿与可恢复已落进首帧 277.4ms 之内)
岛屿 JS 下限(12.5% 交互)                  : 210KB raw = 69KB gzip(全量的 30%)
多框架运行时地板:3 框架 138KB gzip · 5 框架 230KB gzip ≈ 全量 JS 230KB
8× 规模(19200 组件):全量 TTI 9184.2ms / 岛屿 1506.1ms / 可恢复 484.4ms
  ------ 8× 规模下岛屿的 JS 就绪(369.2ms)重新超过 HTML 账(237.4ms),它又变回"被 JS 压着"
根级 mismatch 的代价                        : +660ms/次(子树级的 23.2×);系统性不匹配的 SSR 路线 TTI 2284.2ms > CSR 1177.2ms

断言:148/148 PASS
全部断言通过 ✅

2.3 工具三:把"新鲜度"与"回源"记成两本账

freshness-ledger.mjs 是上线后用的尺子。它回答一个配置面板永远不会回答的问题:我写的那个秒数,在真实的流量分布下变成了多少分钟,以及为了让那部分分钟变小,源站要多付多少。

它同时记两本账,并且坚持两种加权都算:

  • 回源账:每个页面每天触发多少次再生成、占请求的比例、请求合并前后的差额;
  • 新鲜度账 :平均陈旧、最坏陈旧,并且给出页面加权 与流量加权两个口径。

关键设计是把"触发机制"参数化,于是三件事可以放在同一张表里比较:

触发机制 陈旧的公式 回源量由谁决定
SSR(每次请求) 0 请求数
时间驱动(revalidate = R,SWR) 密:R/2 / R;稀:R/2 + 1/(2λ) / R + 1/λ 流量(稀疏时退化为请求数)
发布驱动(on-demand) 传播延迟(实测 2s) 内容变化次数

最后一行是这本书最有价值的输出。把触发从"时间"换成"事件"之后,陈旧与流量解耦:热门页回源 24 次/天、最坏陈旧 2 秒(对比时间驱动的 1440 次/天、1 分钟);在 9933 个"净亏"页面上的回源也从"等于请求数"降到"等于变化次数"。

js 复制代码
#!/usr/bin/env node
'use strict';
/*
 * freshness-ledger.mjs ------ 内容新鲜度与回源账本(SSG / ISR 的取舍)
 *
 * 零依赖、确定性。回答四个问题:
 *   1. ISR 的陈旧窗口到底由什么决定?设置的那个秒数在低流量页面上意味着什么?
 *   2. 时间驱动再生成 vs 发布驱动再生成,源站负载与新鲜度各差多少?
 *   3. 回源风暴(同一时刻的并发请求各触发一次渲染)有多贵,SWR 与请求合并各救下多少?
 *   4. 全量预渲染的账:构建时间、键空间爆炸、以及 Vary: Cookie 的后果。
 *
 * 断言失败即 exit 1。
 */

// ============ 参数 ============
const CFG = {
  PAGES: 10000,              // 站点页面数
  REQUESTS_PER_DAY: 1_000_000,
  ZIPF_ALPHA: 0.9,           // 流量分布(假设值,不是测量值)
  RENDER_MS: 300,            // 一次再生成的源站渲染时间
  PROPAGATE_MS: 2000,        // on-demand revalidate 的传播延迟(边缘生效时间)
  PUBLISHES_PER_DAY: 24,     // 发布频率(含内容运营的自动发布)
  PAGES_PER_PUBLISH: 20,     // 每次发布影响的页面数
  BUILD_SEC_PER_PAGE: 1.8,   // 全量构建时每页成本
  BUILD_WORKERS: 8,
  BUILD_FIXED_SEC: 30,       // 构建的固定开销
  HTML_GZIP_KB: 26,          // 单页 HTML 的 gzip 大小(与 Day28 正文同一页面模型)
  EDGE_STORAGE_GB: 50,       // 边缘可承受的缓存对象总量
  COOKIE_ENTROPY: 1_000_000, // Vary: Cookie 下可能的键数量 ≈ 用户数
  DAY: 86400,
};

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 sec = (x) => (x >= 3600 ? `${r1(x / 3600)}h` : x >= 60 ? `${r1(x / 60)}min` : `${r1(x)}s`);
const lam = (x) => (x < 0.01 ? `1/${sec(1 / x)}` : r2(x).toFixed(2));

let assertions = 0;
const failures = [];
function ok(c, m) { assertions++; if (!c) failures.push(m); }
function near(a, b, tolPct, m) {
  const d = Math.abs(a - b) / Math.max(Math.abs(b), 1e-9) * 100;
  ok(d <= tolPct, `${m}(|${r2(a)} vs ${r2(b)}| = ${r2(d)}% 超出容差 ${tolPct}%)`);
}
function gt(a, b, m) { ok(a > b, `${m}(${r2(a)} 应 > ${r2(b)})`); }
function lt(a, b, m) { ok(a < b, `${m}(${r2(a)} 应 < ${r2(b)})`); }

// ============ Zipf 流量分布 ============
const weights = [];
for (let i = 1; i <= CFG.PAGES; i++) weights.push(Math.pow(i, -CFG.ZIPF_ALPHA));
const wsum = weights.reduce((a, b) => a + b, 0);
const lambdaOf = (idx) => CFG.REQUESTS_PER_DAY * (weights[idx - 1] / wsum) / CFG.DAY;   // req/s
const reqPerDayOf = (idx) => CFG.REQUESTS_PER_DAY * (weights[idx - 1] / wsum);

// ============ 每页的账 ============
/**
 * 时间驱动再生成(ISR 的 revalidate = R 秒)
 *   请求到达时若条目已过期:SWR ------ 先把旧副本发出去,后台再生成
 *                        非 SWR ------ 用户等这一次渲染(renderMs)
 *   合并请求(coalescing):同一时刻正在生成时,后来的请求不再各触发一次
 */
function timeBased(lambda, R, opts = {}) {
  const { swr = true, coalesce = true } = opts;
  const requests = lambda * CFG.DAY;
  const dense = lambda * R >= 1;                       // 请求间隔是否短于窗口
  // 过期次数:请求足够密时 ≈ 一天的秒数 / R;请求稀疏时每次请求都发现条目过期
  const expiries = dense ? Math.ceil(CFG.DAY / R) : requests;
  // 回源风暴:一次生成期间到达的请求,若不合并且各自触发一次
  const concurrent = Math.max(1, lambda * CFG.RENDER_MS / 1000);
  const regens = Math.min(requests, expiries * (coalesce ? 1 : concurrent));
  // 陈旧窗口:过期后要等"下一个请求"才会触发刷新
  const gap = dense ? 0 : 1 / lambda;
  const maxAge = R + gap + CFG.RENDER_MS / 1000;
  const avgAge = (R + gap) / 2;
  const waitingUsers = swr ? 0 : regens * concurrent;    // 非 SWR 时,等在这次渲染上的用户
  const avgWaitMs = swr ? 0 : CFG.RENDER_MS;
  return { requests, expiries, regens, maxAge, avgAge, waitingUsers, avgWaitMs,
           originRatio: regens / requests, dense, concurrent };
}

/** 发布驱动再生成(on-demand revalidate) */
function onDemand(lambda, perDay, propMs = CFG.PROPAGATE_MS) {
  const requests = lambda * CFG.DAY;
  const regens = perDay;                                  // 只在内容真的变了时生成
  const maxAge = propMs / 1000;                           // 陈旧只持续"传播延迟"这么久
  const avgAge = propMs / 2000;
  return { requests, regens, maxAge, avgAge, waitingUsers: 0, avgWaitMs: 0,
           originRatio: regens / requests, dense: true, concurrent: 1 };
}

/** SSR:每次请求都回源渲染 */
function ssrAlways(lambda, opts = {}) {
  const { coalesce = false } = opts;
  const requests = lambda * CFG.DAY;
  const concurrent = Math.max(1, lambda * CFG.RENDER_MS / 1000);
  const regens = coalesce ? Math.ceil(requests / concurrent) : requests;
  return { requests, expiries: requests, regens, maxAge: 0, avgAge: 0,
           waitingUsers: 0, avgWaitMs: 0, originRatio: 1, dense: true, concurrent };
}

/** SSG + 全量重建发布:请求全部命中静态文件,回源成本在构建期 */
function ssgBuild(lambda) {
  const requests = lambda * CFG.DAY;
  return { requests, expiries: 0, regens: 0, maxAge: 0, avgAge: 0,
           waitingUsers: 0, avgWaitMs: 0, originRatio: 0, dense: true, concurrent: 1 };
}

// ============ 输出 1:分级流量下的 ISR 账 ============
const R_DEFAULT = 60;
const TIERS = [
  { name: 'Top 1', idx: 1 },
  { name: 'Top 5', idx: 5 },
  { name: 'Top 10%', idx: Math.round(CFG.PAGES * 0.10) },
  { name: 'Top 25%', idx: Math.round(CFG.PAGES * 0.25) },
  { name: 'Top 50%', idx: Math.round(CFG.PAGES * 0.50) },
  { name: 'Top 75%', idx: Math.round(CFG.PAGES * 0.75) },
  { name: 'Top 90%', idx: Math.round(CFG.PAGES * 0.90) },
  { name: 'Top 99%', idx: Math.round(CFG.PAGES * 0.99) },
  { name: '最冷页', idx: CFG.PAGES },
];

console.log('='.repeat(112));
console.log('freshness-ledger.mjs ------ 内容新鲜度与回源账本');
console.log(`${CFG.PAGES} 个页面 · ${(CFG.REQUESTS_PER_DAY / 1e4).toFixed(0)} 万请求/天 · Zipf α=${CFG.ZIPF_ALPHA}(假设分布)· 再生成渲染 ${CFG.RENDER_MS}ms`);
console.log(`ISR 配置:revalidate = ${R_DEFAULT}s · 开启 stale-while-revalidate · 开启请求合并`);
console.log('='.repeat(112));
console.log('');
console.log('页面'.padEnd(10) + 'λ(请求/s)'.padStart(12) + '请求/天'.padStart(10) + '过期次数/天'.padStart(12) +
            '回源次数/天'.padStart(12) + '回源占请求'.padStart(11) + '平均陈旧'.padStart(10) +
            '最坏陈旧'.padStart(10) + '相对 R=60s'.padStart(12));
console.log('-'.repeat(112));
for (const t of TIERS) {
  const lambda = lambdaOf(t.idx);
  const r = timeBased(lambda, R_DEFAULT, { swr: true, coalesce: true });
  console.log(
    t.name.padEnd(10) +
    lam(lambda).padStart(12) +
    String(Math.round(r.requests)).padStart(10) +
    String(Math.round(r.expiries)).padStart(12) +
    String(Math.round(r.regens)).padStart(12) +
    (r1(r.originRatio * 100) + '%').padStart(11) +
    sec(r.avgAge).padStart(10) +
    sec(r.maxAge).padStart(10) +
    (r1(r.maxAge / R_DEFAULT) + '×').padStart(12));
}

// 断言:陈旧的解析式
{
  const hot = lambdaOf(1), cold = lambdaOf(CFG.PAGES);
  const rh = timeBased(hot, R_DEFAULT), rc = timeBased(cold, R_DEFAULT);
  near(rh.avgAge, R_DEFAULT / 2, 0.01, '热门页(请求密):平均陈旧 = R/2,与流量无关');
  near(rc.avgAge, R_DEFAULT / 2 + 1 / (2 * cold), 0.01, '冷页(请求稀):平均陈旧 = R/2 + 1/(2λ)');
  near(rc.maxAge, R_DEFAULT + 1 / cold + CFG.RENDER_MS / 1000, 0.01, '冷页:最坏陈旧 = R + 1/λ + 渲染时间');
  gt(rc.avgAge / rh.avgAge, 50, '最冷页的平均陈旧应是 Top1 的 50 倍以上');
  ok(rh.dense && !rc.dense, '热门页请求密(λ·R ≥ 1),冷页请求稀(λ·R < 1)');
  // 冷页:ISR 的回源成本等于 SSR
  near(rc.regens, rc.requests, 0.01, '冷页上 ISR 的回源次数 = 请求数(与 SSR 相同,没有任何节省)');
  gt(rh.requests / rh.regens, 10, '热门页上 ISR 把回源压到请求数的十分之一以下');
  // 配置窗口与实际陈旧的关系
  gt(rc.maxAge / R_DEFAULT, 60, '冷页的最坏陈旧可以是最初配置的 60 倍以上');
  gt(rc.avgAge / R_DEFAULT, 30, '冷页的平均陈旧可以是最初配置的 30 倍以上');
}

// ============ 输出 2:策略对比(一个热门页 + 一个中冷页)============
const CASES = [
  { name: '热门页(Top1)', lambda: lambdaOf(1), perDay: 24 },
  { name: '中冷页(Top75 附近)', lambda: lambdaOf(Math.round(CFG.PAGES * 0.75)), perDay: 6 },
  { name: '最冷页(Top100%)', lambda: lambdaOf(CFG.PAGES), perDay: 2 },
];

console.log('');
console.log('=== 策略对比(同一页面上:源站负载 vs 新鲜度)===');
console.log('-'.repeat(112));
for (const c of CASES) {
  console.log(`【${c.name}】λ=${lam(c.lambda)}/s · 请求 ${Math.round(c.lambda * CFG.DAY)}/天 · 该页每天内容变化 ${c.perDay} 次`);
  const rows = [
    ['SSR 每次回源', ssrAlways(c.lambda)],
    ['SSR + 合并', ssrAlways(c.lambda, { coalesce: true })],
    ['ISR R=60(无 SWR)', timeBased(c.lambda, 60, { swr: false, coalesce: true })],
    ['ISR R=60 + SWR', timeBased(c.lambda, 60, { swr: true, coalesce: true })],
    ['ISR R=60 + SWR(不合并)', timeBased(c.lambda, 60, { swr: true, coalesce: false })],
    ['ISR R=3600 + SWR', timeBased(c.lambda, 3600, { swr: true, coalesce: true })],
    ['ISR + 发布驱动(on-demand)', onDemand(c.lambda, c.perDay)],
    ['SSG + 全量重建(请求零回源)', ssgBuild(c.lambda)],
  ];
  console.log('  ' + '策略'.padEnd(26) + '回源次数/天'.padStart(12) + '回源占请求'.padStart(11) +
              '平均陈旧'.padStart(10) + '最坏陈旧'.padStart(10) + '等待的用户/天'.padStart(14));
  for (const [name, r] of rows) {
    console.log('  ' + name.padEnd(26) +
      String(Math.round(r.regens)).padStart(12) +
      (r1(r.originRatio * 100) + '%').padStart(11) +
      sec(r.avgAge).padStart(10) +
      sec(r.maxAge).padStart(10) +
      String(Math.round(r.waitingUsers)).padStart(14));
  }
  console.log('');
}

// 断言:SWR / 合并 / on-demand
{
  const coldCase = CASES[2];
  const noSwr = timeBased(coldCase.lambda, 60, { swr: false, coalesce: true });
  const swr = timeBased(coldCase.lambda, 60, { swr: true, coalesce: true });
  near(noSwr.regens, swr.regens, 0.01, 'SWR 不改变回源次数');
  near(swr.avgWaitMs, 0, 0.01, 'SWR 下用户不等待渲染');
  gt(noSwr.avgWaitMs, 0, '非 SWR 下用户要等这次渲染');

  const hotCase = CASES[0];
  // 平均流量下没有风暴;风暴属于"事件流量"
  const spikeLambda = 200;                      // 秒杀/大促时的瞬时到达率
  const noCoal = timeBased(spikeLambda, 60, { swr: true, coalesce: false });
  const coal = timeBased(spikeLambda, 60, { swr: true, coalesce: true });
  ok(noCoal.concurrent > 10, `事件流量下渲染窗口内会有大量并发请求(λ×渲染时间 = ${r1(noCoal.concurrent)})`);
  const avgConcurrent = lambdaOf(1) * CFG.RENDER_MS / 1000;   // 平均流量下的期望并发
  console.log(`  平均流量对照(Top1 λ=${lam(lambdaOf(1))}/s):渲染窗口内期望并发只有 ${r2(avgConcurrent)} ------ 风暴不存在`);
  ok(avgConcurrent < 1, '平均流量下的期望并发小于 1(说明风暴只属于事件流量)');
  gt(noCoal.regens, coal.regens * 10, '不合并请求时,回源次数应为合并后的 10 倍以上');
  near(noCoal.regens / coal.regens, noCoal.concurrent, 1, '回源风暴的倍数 = λ × 渲染时间');
  ok(timeBased(lambdaOf(1), 60, { swr: true, coalesce: false }).concurrent === 1,
     '平均流量下不合并也只有一个并发(风暴需要 λ × 渲染时间 > 1)');
  near(coal.regens, Math.ceil(CFG.DAY / 60), 0.01, '合并后回源次数回到 86400/R 的量级');
  const noSwrSpike = timeBased(spikeLambda, 60, { swr: false, coalesce: true });
  gt(noSwrSpike.regens, 0, '非 SWR 在风暴下回源次数不为零');
  near(noSwrSpike.waitingUsers, noSwrSpike.regens * noSwrSpike.concurrent, 0.01,
       '非 SWR:每次再生成都有一批用户卡在渲染上');
  ok(timeBased(spikeLambda, 60, { swr: true, coalesce: true }).waitingUsers === 0,
     'SWR 下没有任何用户等在这次渲染上');
  console.log(`  事件流量(λ=${spikeLambda}/s):合并前 ${Math.round(noCoal.regens)} 次回源/天,合并后 ${Math.round(coal.regens)} 次(×${r1(noCoal.concurrent)} 的差);非 SWR 时有 ${Math.round(noSwrSpike.waitingUsers)} 个用户卡在渲染上`);

  const od = onDemand(hotCase.lambda, hotCase.perDay);
  const tb = timeBased(hotCase.lambda, 60, { swr: true, coalesce: true });
  lt(od.regens, tb.regens, '发布驱动的回源次数应少于时间驱动(热门页)');
  lt(od.maxAge, tb.maxAge, '发布驱动的最坏陈旧应远小于时间驱动');
  near(od.regens, hotCase.perDay, 0.01, '发布驱动的回源次数 = 该页每天的变化次数');
  near(od.maxAge, CFG.PROPAGATE_MS / 1000, 0.01, '发布驱动的最坏陈旧 = 传播延迟');

  const coldOd = onDemand(coldCase.lambda, coldCase.perDay);
  const coldTb = timeBased(coldCase.lambda, 60, { swr: true, coalesce: true });
  lt(coldOd.regens, coldTb.regens, '冷页上发布驱动同样更省回源');
  gt(coldTb.maxAge / coldOd.maxAge, 100, '冷页上时间驱动的最坏陈似是发布驱动的 100 倍以上');
}

// ============ 输出 3:全站聚合(两种加权)============
console.log('=== 全站聚合:同一套策略,两种加权方式会给出完全不同的答案 ===');
console.log('-'.repeat(112));
const STRATS = [
  { name: 'SSR 每次回源', run: (l) => ssrAlways(l) },
  { name: 'ISR R=60 + SWR + 合并', run: (l) => timeBased(l, 60, { swr: true, coalesce: true }) },
  { name: 'ISR R=3600 + SWR + 合并', run: (l) => timeBased(l, 3600, { swr: true, coalesce: true }) },
  { name: 'ISR + 发布驱动', run: (l, i) => onDemand(l, idxPublishes(i)) },
  { name: 'SSG 全量重建', run: (l) => ssgBuild(l) },
];
function idxPublishes(idx) {
  // 越靠前的页面内容变化越频繁(首页/榜单类),冷页很少变
  const frac = 1 - (Math.log(idx) / Math.log(CFG.PAGES));
  return Math.max(1, Math.round(2 + 22 * frac));
}
console.log('策略'.padEnd(26) + '总回源/天'.padStart(12) + '回源/请求'.padStart(11) +
            '平均陈旧·页面加权'.padStart(18) + '平均陈旧·流量加权'.padStart(18) +
            'p95 陈旧·页面加权'.padStart(18) + '构建时间/天'.padStart(13));
const agg = [];
for (const s of STRATS) {
  let totalRegens = 0, agedPage = 0, agedTraffic = 0, totalReq = 0;
  const ages = [];
  for (let i = 1; i <= CFG.PAGES; i++) {
    const l = lambdaOf(i);
    const r = s.run(l, i);
    totalRegens += r.regens;
    const req = reqPerDayOf(i);
    totalReq += req;
    agedPage += r.avgAge;
    agedTraffic += r.avgAge * req;
    ages.push(r.avgAge);
  }
  ages.sort((a, b) => a - b);
  const buildSec = s.name.startsWith('SSG')
    ? (CFG.PAGES * CFG.BUILD_SEC_PER_PAGE / CFG.BUILD_WORKERS + CFG.BUILD_FIXED_SEC) * CFG.PUBLISHES_PER_DAY
    : 0;
  agg.push({
    name: s.name, totalRegens, ratio: totalRegens / totalReq,
    pageWeighted: agedPage / CFG.PAGES, trafficWeighted: agedTraffic / totalReq,
    p95: ages[Math.floor(CFG.PAGES * 0.95)], buildSec,
  });
  const a = agg[agg.length - 1];
  console.log(
    a.name.padEnd(26) +
    String(Math.round(a.totalRegens)).padStart(12) +
    (r2(a.ratio * 100) + '%').padStart(11) +
    sec(a.pageWeighted).padStart(18) +
    sec(a.trafficWeighted).padStart(18) +
    sec(a.p95).padStart(18) +
    (a.buildSec ? sec(a.buildSec) : '---').padStart(13));
}
console.log('');
{
  const isr = agg.find((a) => a.name.startsWith('ISR R=60'));
  const ssr = agg.find((a) => a.name.startsWith('SSR'));
  gt(ssr.totalRegens, isr.totalRegens, 'SSR 的全站回源次数必然高于 ISR');
  near(ssr.ratio, 1, 0.01, 'SSR 的回源/请求 = 100%');
  // ISR 的收益并不均匀:请求密的页面才有收益,请求稀的页面收益为零
  let zeroGainPages = 0, zeroGainTraffic = 0, saving = 0, headSaving = 0;
  const headCut = Math.round(CFG.PAGES * 0.05);
  for (let i = 1; i <= CFG.PAGES; i++) {
    const r = timeBased(lambdaOf(i), 60, { swr: true, coalesce: true });
    const req = reqPerDayOf(i);
    const sv = req - r.regens;
    if (sv <= 1e-9) { zeroGainPages++; zeroGainTraffic += req; }
    saving += sv;
    if (i <= headCut) headSaving += sv;
  }
  gt(zeroGainPages / CFG.PAGES, 0.5, `过半页面上 ISR 对回源零收益(实际 ${r1(zeroGainPages / CFG.PAGES * 100)}%)`);
  gt(zeroGainTraffic / CFG.REQUESTS_PER_DAY, 0.3, `零收益页面仍承担大量流量(${r1(zeroGainTraffic / CFG.REQUESTS_PER_DAY * 100)}%)`);
  ok(CFG.PAGES - zeroGainPages < CFG.PAGES * 0.05,
     `真正从 ISR 回源节省中获益的页面不到 5%(实际 ${CFG.PAGES - zeroGainPages} 个)`);
  // 零收益 ≠ 零代价:这些页面上 ISR 比 SSR 只多了陈旧
  let pureLoss = 0, pureLossStale = 0;
  for (let i = 1; i <= CFG.PAGES; i++) {
    const r = timeBased(lambdaOf(i), 60, { swr: true, coalesce: true });
    if (Math.abs(r.regens - r.requests) < 1e-6 && r.avgAge > 0) { pureLoss++; pureLossStale += r.avgAge; }
  }
  gt(pureLoss, CFG.PAGES * 0.5, '大量页面上 ISR 相对 SSR 是"净亏":回源一模一样,白添陈旧');
  console.log(`  "净亏"页面:${pureLoss} 个(回源与 SSR 完全相同,却平均白添 ${sec(pureLossStale / pureLoss)} 的内容陈旧)`);
  // 这些页面上把 R 调大只会更陈旧,不会更省回源
  const c60 = timeBased(lambdaOf(CFG.PAGES), 60, { swr: true, coalesce: true });
  const c3600 = timeBased(lambdaOf(CFG.PAGES), 3600, { swr: true, coalesce: true });
  near(c3600.regens, c60.regens, 0.01, '低流量页面:把 R 从 60s 调到 3600s,回源次数一次都没省');
  gt(c3600.avgAge / c60.avgAge, 1.5, '低流量页面:把 R 调大只换来更陈旧');
  gt(headSaving / saving, 0.5, `ISR 的回源节省应主要来自头部 5% 的页面(实际 ${r1(headSaving / saving * 100)}%)`);
  console.log(`ISR 回源节省的分布:头部 5% 页面贡献了 ${r1(headSaving / saving * 100)}% 的节省;${zeroGainPages} 个页面(${r1(zeroGainPages / CFG.PAGES * 100)}%)的节省为 0`);
  console.log(`真正从回源节省中获益的页面:${CFG.PAGES - zeroGainPages} 个(${r1((CFG.PAGES - zeroGainPages) / CFG.PAGES * 100)}%)`);
  console.log('  ------ 收益落在热门页(它们也最不陈旧),代价落在长尾页(它们最陈旧)。');
  gt(isr.pageWeighted / isr.trafficWeighted, 2.5,
     '页面上看到的平均陈旧与用户实际经历的平均陈旧应差 2.5 倍以上');
  gt(isr.p95, isr.trafficWeighted * 4, 'p95 陈旧(页面加权)远高于流量加权均值');
  // 陈旧的分布:多少页面超过 10 分钟
  let over10 = 0, over10Traffic = 0;
  for (let i = 1; i <= CFG.PAGES; i++) {
    const r = timeBased(lambdaOf(i), 60, { swr: true, coalesce: true });
    if (r.avgAge > 600) { over10++; over10Traffic += reqPerDayOf(i); }
  }
  ok(over10 / CFG.PAGES > 0.5, `超过一半的页面平均陈旧 > 10 分钟(实际 ${r1(over10 / CFG.PAGES * 100)}%)`);
  ok(over10Traffic / CFG.REQUESTS_PER_DAY > 0.05,
     `这些页面仍承担可观的流量(${r1(over10Traffic / CFG.REQUESTS_PER_DAY * 100)}%)`);
  console.log(`平均陈旧 > 10 分钟的页面:${over10} / ${CFG.PAGES}(${r1(over10 / CFG.PAGES * 100)}%),它们承担 ${r1(over10Traffic / CFG.REQUESTS_PER_DAY * 100)}% 的流量`);
  console.log('  ------ 这就是为什么"revalidate = 60"这句话在报表上从不暴露问题:它影响的页面几乎没人访问。');
}

// ============ 输出 4:构建账 ============
console.log('');
console.log('=== 构建账:为什么不"直接全都静态生成" ===');
console.log('-'.repeat(112));
const buildSec = (pages, workers = CFG.BUILD_WORKERS, perPage = CFG.BUILD_SEC_PER_PAGE) =>
  pages * perPage / workers + CFG.BUILD_FIXED_SEC;
console.log('页面数'.padEnd(10) + '全量构建'.padStart(12) + '每天 24 次发布'.padStart(16) +
            '增量构建(0.5% 变)'.padStart(20) + '增量+固定'.padStart(12));
const buildRows = [];
for (const pages of [1000, 10000, 100000, 1000000]) {
  const full = buildSec(pages);
  const incr = buildSec(pages * 0.005);
  buildRows.push({ pages, full, incr, daily: full * CFG.PUBLISHES_PER_DAY });
  console.log(
    String(pages).padStart(10) +
    sec(full).padStart(12) +
    sec(full * CFG.PUBLISHES_PER_DAY).padStart(16) +
    sec(pages * 0.005 * CFG.BUILD_SEC_PER_PAGE / CFG.BUILD_WORKERS).padStart(20) +
    sec(incr).padStart(12));
}
console.log('');
near(buildRows[1].full / buildRows[0].full, 10, 25, '全量构建时间随页面数近似线性');
near(buildRows[2].full / buildRows[1].full, 10, 25, '10 倍页面数 ≈ 10 倍构建时间');
for (let i = 1; i < buildRows.length; i++) gt(buildRows[i].full, buildRows[i - 1].full, '构建时间随页面数单调上升');
gt(buildRows[1].daily / CFG.DAY, 0.5, '1 万页 × 24 次发布/天,光是构建就吃掉半天以上(不可行)');
lt(buildRows[1].incr, buildRows[1].full * 0.2, '增量构建应把时间压到全量的 20% 以内');
ok(buildRows[3].full / buildRows[0].full > 800, `100 万页的全量构建相对 1 千页放大 800 倍以上(${r1(buildRows[3].full / buildRows[0].full)}×)`);
gt(buildRows[3].full / buildRows[0].full, buildRows[3].pages / buildRows[0].pages * 0.8,
   '放大倍数应接近页面数倍数(固定开销被摊薄)');

// ============ 输出 5:个性化的键空间 ============
console.log('');
console.log('=== 个性化:键空间爆炸 ===');
console.log('-'.repeat(112));
const DIMS = [
  { name: '语言/地区', n: 8 }, { name: '货币', n: 5 }, { name: 'A/B 分桶', n: 4 },
  { name: '登录状态', n: 2 }, { name: '会员等级', n: 4 },
];
let combos = 1;
for (const d of DIMS) { combos *= d.n; }
console.log(DIMS.map((d) => `${d.name}×${d.n}`).join(' × ') + ` = ${combos} 种组合`);
const objects = combos * CFG.PAGES;
const storageGB = objects * CFG.HTML_GZIP_KB / 1024 / 1024;
const prerenderSec = buildSec(objects);
console.log(`预渲染对象数      : ${combos} × ${CFG.PAGES} = ${(objects / 1e6).toFixed(1)}M`);
console.log(`边缘存储占用      : ${(objects / 1e6).toFixed(1)}M × ${CFG.HTML_GZIP_KB}KB = ${r1(storageGB)}GB(预算 ${CFG.EDGE_STORAGE_GB}GB)`);
console.log(`全量预渲染耗时    : ${sec(prerenderSec)}`);
console.log(`Vary: Cookie 时   : 键数量 ≈ 用户数 ${(CFG.COOKIE_ENTROPY / 1e6).toFixed(1)}M → 同一 URL 的命中率 ≈ ${r2(1 / CFG.COOKIE_ENTROPY * 100)}%`);
console.log('');
near(combos, 8 * 5 * 4 * 2 * 4, 0.01, '键空间 = 各维度之积');
gt(storageGB, CFG.EDGE_STORAGE_GB, '键空间爆炸后,边缘存储必然超预算');
gt(prerenderSec, 3600, '把组合全量预渲染要花一小时以上------不成立');
{ let n = 1; for (const d of DIMS) n *= d.n; ok(n === combos, '维度乘积自洽'); }
// Vary: Cookie 的后果:每个用户一个键
const cookieHitRate = 1 / CFG.COOKIE_ENTROPY;
lt(cookieHitRate, 1e-4, 'Vary: Cookie 下命中率趋近于 0');
console.log(`等价说法:${CFG.REQUESTS_PER_DAY / 1e6}M 请求/天,每个用户一个键 → 缓存几乎不命中`);

console.log('');
console.log('=== 关键结论 ===');
const top1 = lambdaOf(1), last = lambdaOf(CFG.PAGES);
console.log(`Top1 页 λ=${lam(top1)}/s:ISR R=60 的平均陈旧 ${sec(timeBased(top1, 60).avgAge)},最坏 ${sec(timeBased(top1, 60).maxAge)},回源压到请求的 ${r1(timeBased(top1, 60).originRatio * 100)}%`);
console.log(`最冷页 λ=${lam(last)}/s:ISR R=60 的平均陈旧 ${sec(timeBased(last, 60).avgAge)},最坏 ${sec(timeBased(last, 60).maxAge)},回源 ${r1(timeBased(last, 60).originRatio * 100)}%(= 请求数,等于 SSR)`);
console.log(`同一份"revalidate = 60"配置,在两个页面上的实际陈旧差 ${r1(timeBased(last, 60).avgAge / timeBased(top1, 60).avgAge)} 倍`);
const spike = timeBased(200, 60, { swr: true, coalesce: false });
const spikeCoal = timeBased(200, 60, { swr: true, coalesce: true });
console.log(`回源风暴(λ=200/s 的事件流量):合并前 ${Math.round(spike.regens)} 次回源/天,合并后 ${Math.round(spikeCoal.regens)} 次,差 ${r1(spike.concurrent)} 倍(= λ × 渲染时间)`);
console.log(`发布驱动:回源 ${Math.round(onDemand(top1, 24).regens)}/天,最坏陈旧 ${sec(onDemand(top1, 24).maxAge)}(对比时间驱动的 ${Math.round(timeBased(top1, 60).regens)}/天、${sec(timeBased(top1, 60).maxAge)})`);
console.log(`全站:SSR ${(agg[0].totalRegens / 1e6).toFixed(2)}M 回源/天 vs ISR R=60 ${Math.round(agg[1].totalRegens / 1e3)}K 回源/天(${r1(agg[0].totalRegens / agg[1].totalRegens)}×)`);
console.log(`陈旧的两个口径:页面加权 ${sec(agg[1].pageWeighted)} vs 流量加权 ${sec(agg[1].trafficWeighted)}`);

console.log('');
console.log(`断言:${assertions - failures.length}/${assertions} PASS`);
if (failures.length) {
  console.log('\n失败项:');
  for (const f of failures) console.log('  ✗ ' + f);
  process.exit(1);
}
console.log('全部断言通过 ✅');

实跑输出:

text 复制代码
================================================================================================================
freshness-ledger.mjs ------ 内容新鲜度与回源账本
10000 个页面 · 100 万请求/天 · Zipf α=0.9(假设分布)· 再生成渲染 300ms
ISR 配置:revalidate = 60s · 开启 stale-while-revalidate · 开启请求合并
================================================================================================================

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

=== 策略对比(同一页面上:源站负载 vs 新鲜度)===
----------------------------------------------------------------------------------------------------------------
【热门页(Top1)】λ=0.74/s · 请求 63739/天 · 该页每天内容变化 24 次
  策略                              回源次数/天      回源占请求      平均陈旧      最坏陈旧       等待的用户/天
  SSR 每次回源                         63739       100%        0s        0s             0
  SSR + 合并                         63740       100%        0s        0s             0
  ISR R=60(无 SWR)                   1440       2.3%       30s      1min          1440
  ISR R=60 + SWR                    1440       2.3%       30s      1min             0
  ISR R=60 + SWR(不合并)               1440       2.3%       30s      1min             0
  ISR R=3600 + SWR                    24         0%     30min        1h             0
  ISR + 发布驱动(on-demand)               24         0%        1s        2s             0
  SSG + 全量重建(请求零回源)                    0         0%        0s        0s             0

【中冷页(Top75 附近)】λ=1/1.2h/s · 请求 21/天 · 该页每天内容变化 6 次
  策略                              回源次数/天      回源占请求      平均陈旧      最坏陈旧       等待的用户/天
  SSR 每次回源                            21       100%        0s        0s             0
  SSR + 合并                            21       100%        0s        0s             0
  ISR R=60(无 SWR)                     21       100%   35.2min      1.2h            21
  ISR R=60 + SWR                      21       100%   35.2min      1.2h             0
  ISR R=60 + SWR(不合并)                 21       100%   35.2min      1.2h             0
  ISR R=3600 + SWR                    21       100%      1.1h      2.2h             0
  ISR + 发布驱动(on-demand)                6      28.9%        1s        2s             0
  SSG + 全量重建(请求零回源)                    0         0%        0s        0s             0

【最冷页(Top100%)】λ=1/1.5h/s · 请求 16/天 · 该页每天内容变化 2 次
  策略                              回源次数/天      回源占请求      平均陈旧      最坏陈旧       等待的用户/天
  SSR 每次回源                            16       100%        0s        0s             0
  SSR + 合并                            17       100%        0s        0s             0
  ISR R=60(无 SWR)                     16       100%   45.5min      1.5h            16
  ISR R=60 + SWR                      16       100%   45.5min      1.5h             0
  ISR R=60 + SWR(不合并)                 16       100%   45.5min      1.5h             0
  ISR R=3600 + SWR                    16       100%      1.2h      2.5h             0
  ISR + 发布驱动(on-demand)                2      12.5%        1s        2s             0
  SSG + 全量重建(请求零回源)                    0         0%        0s        0s             0

  平均流量对照(Top1 λ=0.74/s):渲染窗口内期望并发只有 0.22 ------ 风暴不存在
  事件流量(λ=200/s):合并前 86400 次回源/天,合并后 1440 次(×60 的差);非 SWR 时有 86400 个用户卡在渲染上
=== 全站聚合:同一套策略,两种加权方式会给出完全不同的答案 ===
----------------------------------------------------------------------------------------------------------------
策略                               总回源/天      回源/请求         平均陈旧·页面加权         平均陈旧·流量加权       p95 陈旧·页面加权       构建时间/天
SSR 每次回源                       1000000       100%                0s                0s                0s            ---
ISR R=60 + SWR + 合并             726282     72.63%           24.2min            7.7min           43.4min            ---
ISR R=3600 + SWR + 合并           223478     22.35%           43.6min           32.6min              1.2h            ---
ISR + 发布驱动                       43701      4.37%                1s                1s                1s            ---
SSG 全量重建                             0         0%                0s                0s                0s        15.2h

  "净亏"页面:9933 个(回源与 SSR 完全相同,却平均白添 24.3min 的内容陈旧)
ISR 回源节省的分布:头部 5% 页面贡献了 100% 的节省;9933 个页面(99.3%)的节省为 0
真正从回源节省中获益的页面:67 个(0.7%)
  ------ 收益落在热门页(它们也最不陈旧),代价落在长尾页(它们最陈旧)。
平均陈旧 > 10 分钟的页面:8223 / 10000(82.2%),它们承担 25.4% 的流量
  ------ 这就是为什么"revalidate = 60"这句话在报表上从不暴露问题:它影响的页面几乎没人访问。

=== 构建账:为什么不"直接全都静态生成" ===
----------------------------------------------------------------------------------------------------------------
页面数               全量构建       每天 24 次发布        增量构建(0.5% 变)       增量+固定
      1000      4.3min            1.7h                1.1s       31.1s
     10000       38min           15.2h               11.3s       41.3s
    100000        6.3h          150.2h              1.9min      2.4min
   1000000       62.5h         1500.2h             18.8min     19.3min


=== 个性化:键空间爆炸 ===
----------------------------------------------------------------------------------------------------------------
语言/地区×8 × 货币×5 × A/B 分桶×4 × 登录状态×2 × 会员等级×4 = 1280 种组合
预渲染对象数      : 1280 × 10000 = 12.8M
边缘存储占用      : 12.8M × 26KB = 317.4GB(预算 50GB)
全量预渲染耗时    : 800h
Vary: Cookie 时   : 键数量 ≈ 用户数 1.0M → 同一 URL 的命中率 ≈ 0%

等价说法:1M 请求/天,每个用户一个键 → 缓存几乎不命中

=== 关键结论 ===
Top1 页 λ=0.74/s:ISR R=60 的平均陈旧 30s,最坏 1min,回源压到请求的 2.3%
最冷页 λ=1/1.5h/s:ISR R=60 的平均陈旧 45.5min,最坏 1.5h,回源 100%(= 请求数,等于 SSR)
同一份"revalidate = 60"配置,在两个页面上的实际陈旧差 90.9 倍
回源风暴(λ=200/s 的事件流量):合并前 86400 次回源/天,合并后 1440 次,差 60 倍(= λ × 渲染时间)
发布驱动:回源 24/天,最坏陈旧 2s(对比时间驱动的 1440/天、1min)
全站:SSR 1.00M 回源/天 vs ISR R=60 726K 回源/天(1.4×)
陈旧的两个口径:页面加权 24.2min vs 流量加权 7.7min

断言:54/54 PASS
全部断言通过 ✅

2.4 改动清单:按四栏归位

把上面所有结论压成一张表。每一项改动都只动一栏,且必须写明它换了什么------这是本文唯一要求的记账纪律:

栏位 改动 实测效果 它换来了什么 / 换走了什么
TTFB(整条曲线左移) CSR → SSR TTFB +264ms,FCP −404.4ms,TTI +822.5ms 用"更晚能点"换"更早看见"
SSR → SSG/ISR 命中 TTFB/FCP/TTI 各 −264ms 不改变峡谷(Δ峡谷 = 0),代价是新鲜度与构建时间
流式 + Suspense TTFB −229ms,FCP −229ms,TTI −229ms 同样不改变峡谷;与选择性水合配套才有第二部分收益
HTML 账 减小 HTML(少节点、少嵌套) 解析 + 绘制 = 198ms(90.0 + 108.0) 这是"JS 很少时"的 TTI 地板,砍不动就等于天花板
JS 账(收窄峡谷) 岛屿化 峡谷 1346.8 → 151.9ms(8.9×) 用"部分区域不交互"换;受运行时地板与交互比例约束
RSC(服务端组件不运 JS) 峡谷 → 91.1ms(14.8×) 减掉的是服务端组件的代码,不是水合本身------客户端组件仍要完整水合
可恢复(resumability) 峡谷 → 39.0ms(34.5×) 用"序列化状态变大"换;急切 JS 启动近常数,但状态仍是 O(页面)
只做 code-split 只影响 153.3ms 中的一部分 不要指望它修 TTI:水合 1215.0ms 是传输的 7.9 倍
新鲜度 + 回源 时间驱动 → 发布驱动 回源 1440 → 24/天,最坏陈旧 1min → 2s 需要"内容变更 → 失效键"的映射,这是工程问题不是配置
开启请求合并 事件流量下回源 86400 → 1440/天(60×) 只在单实例有效,多实例要额外的共享失效或 CDN 层合并
开启 SWR 用户等待 86400 → 0 用户次/天 用"陈旧窗口"换 RTT 与源站压力

2.5 十二条纪律

  1. 先说清你在优化哪一栏。 TTFB、HTML 账、JS 账、新鲜度------四项各有各的价钱,混在一句"性能优化"里的改动无法评审。
  2. 把"空白期"和"峡谷"分开看。 只报 FCP 的团队,会在 SSR 上线后得到一张更好看的报表和一个更差的 INP。
  3. 算一遍你的峡谷。 峡谷 ≈ JS 传输 + JS 解析 + 水合 − HTML 账。如果水合占大头(本篇是 76.7%),先谈减少水合,再谈分层加载。
  4. 不要用 TTI 当验收指标。 TTI 早已不是 Lighthouse 的指标(Lighthouse 10 移除),也从来不是 Core Web Vitals;当前的交互指标是 INP(good ≤ 200ms) 。TTI 在这里只是分析用的构造量,它的价值在于能与 FCP 相减得到峡谷。
  5. 静态化只动 TTFB。 如果峡谷没变,而用户抱怨"点了没反应",那么 SSG/ISR 不解决问题,它只是把首屏提前了。
  6. 岛屿化之前先数交互比例。 交互占比 100% 时,岛屿 JS 恰好等于全量水合 JS(恒等式);占比越低收益越大,但上限是 HTML 账 − 自己的水合,到 0 就到底。
  7. 运行时地板要单独算。 每个框架一份运行时(140KB raw / 46KB gzip)。3 个框架的地板(138KB gzip)已经超过单框架岛屿的全部 JS(69KB);5 个框架等于全量水合的全部 JS。 "渐进式迁移"别变成"运行时叠加"。
  8. mismatch 要看方差,不看均值。 1% 的不匹配率只值 6.6ms 的页面级期望代价,却让 1000 个用户/10 万 PV 看到整页重画;系统性不匹配(在渲染里读时间/随机/locale)会让 SSR 路线比 CSR 还差。
  9. revalidate 不是秒表,是"下一个请求"。 请求间隔大于窗口时,陈旧由流量决定:同一份 R=60,热门页 30 秒、最冷页 45.5 分钟。先量你自己的流量分布,再写这个数字。
  10. 低流量页面把 R 调大是纯亏。 回源一次都省不下来,陈旧却从 45.5 分钟变 1.2 小时。对这类页面,答案不是"调 R",而是"发布驱动 + 长缓存"。
  11. 回源风暴要按事件流量算。 并发 ≈ λ × 渲染时间;平均流量下没有风暴,秒杀流量下是 60 倍。请求合并救源站、SWR 救用户,两者都要。
  12. 每天问自己那句话 ------本篇的版本是:"这次改动,我改的是 TTFB、HTML 账、JS 账,还是新鲜度?它把哪一项换成了哪一项?" 答得出,才是决策;答不出,就是风险。

三、应用场景

场景一:SSR 上线,LCP 变好了,但用户说"点了没反应"

这是本篇最典型的案例。报表上 FCP/LCP 都变好了(实测 FCP 541.4ms 甚至比 CSR 的 945.8ms 好 404.4ms),但用户投诉"点了没反应"。

诊断路径(按顺序,别跳步):

  1. 打开 Performance 面板录制"页面加载 + 一次点击",在 FCP 与 TTI 之间找 Hydration 长任务。如果它是一整块几百毫秒的主线程时间,问题不在网络。
  2. 用本节的账算一遍:峡谷 = JS 就绪 + 水合 − HTML 账。本篇是 369.2 + 1215.0 − 237.4 = 1346.8ms。
  3. 看三段的比例:传输 153.3ms : 解析 210.0ms : 水合 1215.0ms 。若水合占七成以上,那么"优化 bundle 体积"最多只能碰到前两段(363.3ms 中的一部分),这是"我做了 code-split 却没变快"的答案。
  4. 结论通常是"减少要水合的组件数",而不是"减少字节数":岛屿化(峡谷 → 151.9ms)、RSC(客户端组件才水合,峡谷 → 91.1ms)、或者可恢复(峡谷 → 39.0ms)。

注意别踩的坑: 如果团队用 TTI 作为验收指标,先确认它是否还出现在你们的工具链里------TTI 在 Lighthouse 10 里已被移除,现在的交互指标是 INP。这时正确的验收方式是在 4× 节流的真机上录 INP,而不是看实验室的 TTI 数字。

场景二:静态化上线了,性能报表"没什么变化"

这通常是一次正确的改动落在一张错误的报表上。SSG 的实测收益是 TTFB −264.0ms、FCP −264.0ms、TTI −264.0ms------前两项你们的 CDN 可能早就用边缘缓存拿到了(Day27 讲过,边缘命中本来就是 1 个 RTT),第三项 264ms 混在 1.6 秒的 TTI 里,相对变化只有 16%,很容易被当成噪声。

诊断 :把 FCP 与 TTI 分开报,并额外报一个"峡谷 = TTI − FCP"。SSG 上线前后,峡谷应当一动不动(Δ峡谷 = 0.0ms,脚本有断言)。如果峡谷没变而报表说"没变化",那你的报表只反映了 TTFB 那一栏------它已经接近下限(1 个 RTT)了,后面的 264ms 是有天花板的。

场景三:拆了 8 个岛屿,JS 反而变多了

"岛屿化"的收益函数是 运行时 + 交互组件 × 单价,而"运行时"在你引入第二个框架时就翻倍了。实测(hydration-ledger.mjs):

  • 单框架、12.5% 交互:69KB gzip(全量的 30%)
  • 3 个框架的运行时地板:138KB gzip ------ 已经超过单框架岛屿的全部 JS
  • 5 个框架:230KB gzip = 全量水合的全部 JS(100%)

诊断 :在构建产物里数"运行时的份数"(react-dom / vue / svelte 各自的核心),而不是数岛屿的个数。每多一个框架,就多欠 46KB gzip 的固定债,这笔债与你的交互比例无关。

场景四:revalidate = 60 上线半年,长尾页面陈旧到 1.5 小时

因为触发再生成的是"下一个请求",而长尾页面的下一个请求可能就是 1.5 小时之后。实测同一份 R=60 的配置:Top1 页平均陈旧 30s,最冷页 45.5 分钟 (最坏 1.5 小时),差 90.9 倍 ;并且在这些低流量页面上,ISR 的回源次数逐位等于请求数(与 SSR 完全相同),也就是省了 0 次。

诊断三步:

  1. 拉出请求分布,算出每个页面的 λ;找出 λ × R < 1 的那批(本篇是 9933/10000 个)。
  2. 在监控里给"内容陈旧"打点,分别按页面数与请求数加权(本篇两个口径是 24.2 分钟 vs 7.7 分钟)------如果你的看板只报流量加权,你会永远看不到问题。
  3. 对这批页面改用发布驱动 (内容变更时 invalidate 对应的键):回源从 1440 次/天降到 24 次/天,最坏陈旧从 1 分钟降到 2 秒。注意:别用"把 R 调大"来解决------在低流量页面上它一次回源都不省,只会把陈旧从 45.5 分钟变成 1.2 小时。

场景五:大促当天源站被打挂

ISR 的源站成本是渲染 ,所以在"过期瞬间"的并发上非常脆弱:一次再生成期间的并发请求数 ≈ λ × 渲染时间。

  • 平均流量下不会发生:Top1 页 λ=0.74/s,300ms 窗口内期望并发 0.22。
  • 事件流量下必然发生:λ=200/s 时并发 60 ,不合并时回源 86400 次/天 ,合并后 1440 次/天(差 60 倍)。
  • 若同时没开 SWR,86400 个用户次/天 会被卡在那 300ms 上。

诊断与修法: ①开启请求合并;②开启 SWR(把"等待"换成"陈旧",本场景没人在意 60 秒内的旧价格);③确认合并是进程内还是跨实例------默认实现是进程内的,多实例部署时每个实例仍会各跑一次再生成,需要用共享的失效通道或 CDN 层的请求合并把这件事补上;④对价格/库存这类"变了必须马上生效"的字段,改用发布驱动的主动失效,不依赖窗口。

场景六:每次部署后,全站"卡"半分钟

这不是缓存失效,是缓存失效的后果不同。 静态站点被清空后,源站做的事是"发一个文件"(便宜);ISR/SSR 被清空后,源站做的事是"渲染一个页面"(贵),而且是成百上千个页面同时开始渲染 。如果再加上"部署 + 全量重建",实测的构建账就在那里:1 万页全量构建 38 分钟,按每天 24 次发布算是 15.2 小时/天。

修法: ①把构建改成增量(0.5% 变更 → 11.3 秒,前提是有一份可靠的"哪些页面受影响"映射);②部署时不要一次性清空,而是按流量分批预热;③新版本上线时保留旧数据(stale)而不是清空,让第一批请求拿旧内容、后台再生成。

键数量等于用户数(本次测算 100 万),命中率趋近于 0。而且需要按 2026 年的现状更新一下认知:Vercel 的 CDN 自 2026 年 9 月 30 日起不再缓存 Vary 包含 Cookie 的响应 (响应照常返回,但不进缓存;诊断字段 x-vercel-cache: MISS 与 vary_key_denied:cookie)。

修法(按代价从低到高):

  1. 把 Cookie 从 Vary 里去掉,用明确枚举的请求头(如语言、币种)做键------前提是这些值真的只有少数几种;
  2. 把"个性化"从服务端挪到客户端(首屏渲染通用壳,个性化片段在客户端取),代价是首屏个性化内容闪一下;
  3. 用边缘中间件拼装(壳 + 片段),代价是延迟与复杂度;
  4. 如果个性化维度确实是乘积爆炸的(本篇 5 个维度 = 1280 种组合 × 1 万页 = 1280 万个对象 = 317.4GB ,预渲染 800 小时),那就不要试图预渲染它------这是键空间的算术结论,不是工程能力问题。

场景八:"我们用了 RSC,为什么还是要 1 秒才可交互"

因为 RSC 不消除水合 。它减掉的是"服务端组件的代码与执行"(客户端看不到那些组件,也拿不到它们依赖的重库),但:SSR 出来的 HTML 仍由 hydrateRoot 水合;Flight 载荷仍要下载与解析;每一个客户端组件仍要完整水合(含它的 props)。

诊断: 在产物里把"客户端组件"与"服务端组件"分开数,看客户端组件占全部组件的比例 ------那才是决定峡谷的量(本篇模型里 RSC 只水合 180 个客户端组件,峡谷 91.1ms)。另外两个附带事实值得记住:①RSC 载荷是文本 的行分隔格式(形如 0:{"name":"Alice"},用 $ 引用其他 chunk),不是二进制------别用它的大小去和 gzip 后的 JS 直接对比;②React 19.2 起,服务端渲染的 Suspense 边界会在约 300ms 的窗口内批量揭示,所以流式的"可见节奏"与你按组件数估的曲线不完全一致(我的覆盖率模型是逐区域的,没有模拟这个批量窗口)。


四、总结

  1. 渲染位置是一次坐标变换,不是一种加速。 实测同一份 JS:SSR 把 FCP 从 945.8ms 提前到 541.4ms(−404.4ms),同时把 TTI 从 1065.8ms 推到 1888.2ms(+822.5ms,+77.2%)。它买的是"更早看见",付的是"更晚能点"。
  2. 静态化只动 TTFB。 SSG 相对 SSR 的 ΔTTFB / ΔTTI / Δ峡谷 = 264.0 / 264.0 / 0.0ms ------因为 TTFB 是加数,整条曲线左移,形状一点没变。流式同样:Δ = 229.0 / 229.0 / 0.0ms。
  3. 三条恒等式锁住了整件事。 FCP = TTFB + HTML 账、TTI = TTFB + max(HTML 账, JS 就绪) + 水合、峡谷 = max(HTML 账, JS 就绪) + 水合 − HTML 账。代入本篇:369.2 + 1215.0 − 237.4 = 1346.8ms。81 组参数扫描下三条都成立,所以结论不是调参调出来的。
  4. 峡谷的大头是执行。 JS 账的三段比是 153.3 : 210.0 : 1215.0 ,水合占 76.7%,是传输的 7.9 倍。这就是"我做了 code-split 却没变快"的答案;而且 gzip 只省传输、不省解析(压缩减半时 parse 精确不变)。
  5. 峡谷里点击是真的会丢。 全量水合下 9.8% 的点击石沉大海 (根监听都没挂上),36.4% 排队重放、平均等 417.6ms、P95 827ms 。这段时间正是 INP 统计的东西------"LCP 好看、INP 难看"就是这个峡谷的直方图。
  6. JS 少到一定程度,地板会换成 HTML。 岛屿 151.9ms、RSC 91.1ms、可恢复 39.0ms 的峡谷恰好等于各自的水合成本 ------此时 TTI 被 HTML 账压着,继续砍 JS 的收益归零。所以岛屿化的上限是 HTML 账 − 自己的水合。
  7. 岛屿是"常数 + 比例",地板在框架数上。 交互占比 100% 时岛屿 JS 恰好等于 全量水合;而 3 个框架的运行时地板(138KB gzip )已超过单框架岛屿的全部 JS(69KB),5 个框架(230KB gzip)等于全量水合的全部 JS。"渐进式迁移"的最大风险是把运行时叠加当成岛屿化。
  8. mismatch 是方差问题,不是均值问题。 根级回退 +660ms 是子树级(+28.5ms)的 23.2 倍 ;1% 的不匹配率只值 6.6ms 的页面级期望代价,却让 10 万 PV 里的 1000 个用户 看到整页重画。系统性不匹配会让 SSR 路线(2284.2ms)比 CSR(1177.2ms)还差------服务器白渲染,客户端照样重画一遍。
  9. revalidate 不是秒表。 它的语义是"过期后等下一个请求",所以陈旧与流量成反比:同一份 R=60,Top1 页平均陈旧 30 秒 ,最冷页 45.5 分钟 (差 90.9 倍 )。在 9933/10000 个页面上,ISR 的回源次数逐位等于请求数(省 0 次),却白添平均 24.3 分钟 陈旧------净亏 。低流量页面上把 R 从 60s 调到 3600s,一次回源都不省,陈旧却涨到 1.2 小时。
  10. ISR 的收益与代价落在不同的人身上。 全站回源节省几乎全部来自头部页面(头部 5% 贡献 100% 的节省 ),而最陈旧的 82.2% 页面承担 25.4% 的流量。看板口径决定你看到什么:页面加权 24.2 分钟 vs 流量加权 7.7 分钟。
  11. 回源风暴属于事件流量。 并发 ≈ λ × 渲染时间:平均流量下是 0.22(不存在),λ=200/s 时是 60 。不合并 86400 次回源/天 vs 合并 1440 次(60 倍 );无 SWR 时 86400 个用户次/天 卡在渲染上。请求合并救源站(且默认只在进程内有效),SWR 救用户。
  12. 把触发从"时间"换成"事件",陈旧就与流量解耦。 发布驱动在热门页上用 R=3600 级别的回源量(24 次/天)拿到 2 秒 的最坏陈旧------这是本文给出的最终答案:时间驱动的窗口永远会输给流量分布,发布驱动不会。
  13. 三件工具 + 一条纪律。 设计期用 render-budget.mjs 算恒等式与临界点、评审期用 hydration-ledger.mjs 看覆盖率曲线与点击账、上线后用 freshness-ledger.mjs 记两种加权的新鲜度与回源;纪律是那句每天都要问的话------"这次改动,我改的是 TTFB、HTML 账、JS 账,还是新鲜度?它把哪一项换成了哪一项?"

下一篇 Day29 讲运行时性能 :峡谷里那 1215ms 的水合长任务,在浏览器里到底怎么切、怎么让------长任务拆分、调度优先级、以及 INP 的可测量化。那是把本篇的"JS 账"从"多少毫秒"推进到"这毫秒花在主线程的哪一段"的一篇。


运行环境

三份脚本都在 Node.js 16+ 下零依赖运行,各自独立可跑,断言失败即 exit 1:

bash 复制代码
node render-budget.mjs      # 115 条断言 + 81 组参数扫描 × 5 条保序结论
node hydration-ledger.mjs   # 148 条断言
node freshness-ledger.mjs   #  54 条断言

合计 317 条不变量断言,本文引用的所有数字均为上述脚本的实跑输出。

共用模型参数(三份脚本一致):链路 1.5KB/ms(≈12Mbps)、RTT 40ms、冷连接额外 100ms(DNS 20 + TCP 1RTT 40 + TLS1.3 1RTT 40)、客户端 CPU 4× 节流、gzip 比 230/700(≈0.329)。页面模型:2400 组件 / 9000 DOM 节点 / 交互组件 300(12.5%)/ HTML 180KB raw·59KB gzip / 全量水合 JS 700KB raw·230KB gzip / 框架运行时 140KB raw·46KB gzip / 单组件 JS 0.233KB。单位成本:HTML 解析 0.010ms/节点、绘制 0.012ms/节点、JS 解析 0.30ms/解压后 KB、JS 建 DOM 0.060ms/节点、水合 0.45ms/组件 + 0.015ms/节点、挂载 0.05ms/组件、服务端渲染 0.06ms/组件、可恢复固定启动 15ms + 0.08ms/交互组件、处理器执行 30ms。

关于单位成本的来源(重要,请勿跳过) :这些单价是按中端手机 + 4× 节流校准的量级常数,不是任何一份可引用的公开测量 。我专门检索过:web.dev、Chrome DevTools 文档、Vercel 与 CrUX 都没有 公布"每次水合多少毫秒 / 每个 DOM 节点多少毫秒"这类数字(CrUX 没有水合指标;4×/6× 节流是 Lighthouse 的实验室模拟设置,本身不是测量)。公开的只有定性且聚合 的结论,例如 Google 的《Rendering on the Web》指出服务端渲染 + 重水合 "on mobile, this can take seconds or even minutes" ,且当前版本的该文把危害重新定位为 "对 TBT 与 INP 有显著负面影响,即使它改善了 FCP" 。所以请把本文的毫秒数当成同一坐标系内的相对量 :它们的用途是回答"岛屿比全量水合少几倍""静态化改变了哪一栏""回源风暴放大多少倍",而不是"你的机器上会花 1215ms"。为对抗这一点,render-budget.mjs 做了 81 组参数扫描,把四条保序结论钉死在所有参数取值上。

各脚本的额外简化与已知的乐观之处:

  • render-budget.mjs:① 每个策略只模拟一次导航(不含客户端路由切换与预取);② JS 传输按"单连接顺序、无慢启动"计(Day27 的包级模型才含 cwnd),所以这里的传输时间是乐观下限;③ HTML 解析与 JS 执行被当成互斥的主线程段(真实浏览器有预解析器,HTML 解析可以与脚本并行,所以 CSR 的空白期可能被高估);④ JS_DOM_PER_NODE = 0.06ms 是把"创建 + 插入 + 样式计算"打包成一个常数;⑤ CSR 与 SSR 的 HTML/JS 字节被设为同一份(真实项目里 CSR 的 HTML 更小、SSR 的 JS 可能更大,因为要额外运序列化数据)。
  • hydration-ledger.mjs:① 24 个区域的水合成本按组件/节点线性摊分,没有模拟 React 的"按交互优先级打断与恢复" (真实选择性水合会让被点击的区域插队,效果比本模型更好);② 5 个分包按顺序串行下载,没有模拟 modulepreload 的并行下载(真实情况更好);③ "根监听挂上"的时刻被简化为"首块到达",而真实 React 会在根 chunk 执行后就开始捕获离散事件并排队重放;④ 覆盖率按区域二值化(区域内要么全可交互要么全不可交互),真实的粒度更细------这会高估 曲线的台阶感;⑤ 未模拟 React 19.2 的 Suspense 边界批量揭示(约 300ms 窗口);⑥ mismatch 的 660ms 只计"重画 + 重挂监听",未计视觉闪烁、状态丢失与用户重试,所以它是最保守的估计。
  • freshness-ledger.mjs:① 请求按固定间隔 到达(不是泊松过程),所以"台阶"的位置是确定性的------真实到达的随机性会让陈旧窗口上下浮动,但"陈旧 ∝ 1/λ"的方向不变;② 用 Zipf α=0.9 描述流量分布,这是一个假设,不是测量 ------真实分布必须从你自己的访问日志统计,α 变化会改变"多少页面属于净亏"的比例(但"低流量页面陈旧 ∝ 1/λ"与 α 无关);③ 内容变化被当成"按固定频率整体变化",不含字段级局部变化;④ 请求合并只建模为"二值"(完全合并 / 完全不合并),没有模拟进程内合并 + 跨实例重复生成的混合情形;⑤ 构建时间按 页数 × 1.8s / 8 worker + 30s 计,不含缓存命中与 IO 抖动;⑥ "失效率"只算边缘到源站的渲染次数,不含 CDN 回源带宽、日志与可观测性成本。

版本与语义约定(与"模型不确定性"分开标注------这几条是当前实现的事实,落地前应逐条核对目标环境):

  • revalidate 的语义是 SWR,不是阻塞 。Next.js 文档与源码(response-cache)一致:先返回旧副本,再后台再生成;并发请求复用同一个 Promise(getBatcher / revalidateBatcher),所以在单个实例内 是合并的,文档明确说 revalidation 事件默认是本地的 ,多实例需要共享失效通道。阻塞式再生成只在超过 cacheLife 的 expire 之后发生 ,而且这条路径直到 2026 年 4 月的运行时修复合入后才真正生效(IncrementalCache.get 此前只读 revalidate),并且在 Vercel 上仍取决于 Proxy 的实现------因此任何"过了 expire 会同步阻塞"的说法都必须带宿主与日期。 另外:"Pages Router 返回旧内容但 App Router 阻塞"这个流行说法没有依据,两者都是 SWR。本文的模型(过期后等下一个请求触发、SWR 下用户不等待)与上述行为一致。
  • 触发方式的现代化 :Next.js 15 起 fetch 与 Route Handler 的 GET 默认不再缓存 ;Next.js 16 的 Cache Components 下 revalidate / dynamic / dynamicParams / fetchCache 这些 route segment 配置被移除 ,旧模型在文档里已改名为 "Previous Model",PPR 不再是实验特性(cacheComponents: true 下成为默认行为),revalidateTag 也变更为需要 profile 的双参数 形式。所以本文讨论的"revalidate = R 的时间驱动 ISR"描述的是经典/上一代模型 ------它是理解新鲜度与回源权衡的正确心智模型,但新项目的落地形态应以 use cache / cacheLife / cacheTag + PPR 为准。本文 STREAM 那一行(静态壳先出、动态部分后续流式补齐)在语义上就对应 PPR。
  • mismatch 的两级回退在 React 18 与 19 中都存在 :错误码 422 是"从最近的 Suspense 边界改为客户端渲染",423 是"整个根改为客户端渲染"(错误发生在 Suspense 边界之外时)。React 19 只是把日志改成带 diff 的单条错误,并会为第三方脚本插入的内容强制一次客户端重渲染------"React 19 让 mismatch 变成按子树恢复"是错的,按子树恢复的前提始终是"错误在 Suspense 边界之内"。
  • RSC 不消除水合 :它减掉的是服务端组件的代码下载与执行 (客户端看不到这些组件与它们依赖的重库),但 SSR 出的 HTML 仍由 hydrateRoot 水合、Flight 载荷仍要下载与解析、客户端组件仍要完整水合(含 props)。另外 Flight 载荷是文本 的行分隔格式(0:{"name":"Alice"},用 $ 引用其他 chunk)------别把它当成二进制去和 gzip 后的 JS 直接比大小。
  • 可恢复(resumability) :Qwik 序列化的是监听器(on:click="./chunk.js#handler_symbol")、组件树/边界信息与 store↔组件订阅、以及应用状态(含循环引用、Date、Map/Set、Promise、QRL 闭包),并安装一个全局 Qwikloader。"启动 O(1)"只对急切执行的 JS 成立 :HTML 与序列化状态仍随页面规模线性增长,且它会在启动时预加载 若干 chunk(官方文档从未声称 O(1))。本文模型对可恢复的"启发式惰性恢复"成本是保守估计,但没有建模序列化状态的增长------这一点是明确的乐观之处。
  • 岛屿(Astro) :默认把 UI 组件渲染成纯 HTML+CSS 并剥离客户端 JS,交互需要显式 client:*(client:load 立即、client:idle 空闲、client:visible 进入视口)。注意"零 JS"针对的是UI 组件 ,不是整份文档(岛屿运行时与你手写的 <script> 仍会下发)。
  • 指标口径(2026) :Core Web Vitals 的 good 阈值是 LCP ≤ 2.5s、INP ≤ 200ms、CLS ≤ 0.1 (p75);FID 已于 2024-09-09 停止支持 ,由 INP 替代;FCP/TTFB 是辅助指标,从来不是 CWV ;TTI 从来不是 CWV,并且已在 Lighthouse 10 中被移除 。本文继续使用 TTI 作为分析用的构造量(它的价值是能与 FCP 相减得到"峡谷"),但落地验收请用 INP 与 TBT。
  • "Uncanny valley"(恐怖谷) 这个说法可以追溯到 Google 的《Rendering on the Web》(2019,Jason Miller 与 Addy Osmani),原文用它描述 SSR + 重水合的用户体验;但当前版本的该文已经删掉了这个词,并把危害重新表述为对 TBT 与 INP 的影响。本文沿用该词时会与 INP 一起说明,避免停留在 2023 年的表述。
  • Vary: Cookie :Vercel 的 CDN 自 2026-09-30 起不再缓存 Vary 含 Cookie 的响应(诊断字段 x-vercel-cache: MISS / vary_key_denied:cookie),修法是把它从 Vary 移除或使用 Cache-Control: private。一般化的说法是高基数请求头会让响应根本不进缓存,而不是"命中率降低"。
  • 上述版本性事实来自 NexT.js / React / Qwik / Astro / web.dev 的官方文档与源码,会随版本变化;本文的模型与断言不依赖这些行为,它们只影响"哪些配置能直接对应本文的哪一种策略"。

自测暴露并修复的 7 个真实问题(保留在此,因为它们都属于"模型写错会让结论反向"的类型,且现在每条都有对应断言守着):

  1. 把"静态化不改变峡谷"错写成"不改变 TTI" (render-budget.mjs):初版断言 TTI(SSG) ≈ TTI(SSR),但 TTFB 是加数,静态化把 TTFB 打低 264ms 之后,FCP 与 TTI 会各自 前移 264ms。这条错误断言会把真正的结论(峡谷一动不动 )掩盖成一个错误的结论(TTI 不变)。现在断言的是 ΔTTI = ΔTTFB 与 Δ峡谷 = 0。
  2. 服务端渲染成本估得过高 (render-budget.mjs):初版取 0.18ms/组件(2400 组件 = 432ms),算出"CSR 的空白期是 SSR 的 3 倍以上"并写进断言------实跑只有 1.75 倍。把服务端渲染降到 0.06ms/组件后才自洽,而结论也从"SSR 首屏碾压 CSR"修正为**"SSR 首屏小胜(1.75 倍)、可交互大败(TTI +77.2%)"**。
  3. TTI 一度早于 FCP (hydration-ledger.mjs):岛屿模式我直接把 TTI 设为"最后一个区域就绪",忘了可交互不可能早于首帧(看不到就点不到)。修正后反而得到一个更强的结论:岛屿与可恢复的 TTI 已经落进首帧之内(277.4ms)。
  4. 岛屿要水合的 DOM 节点数被切错 (hydration-ledger.mjs):按"区域交互组件占全站交互组件 的比例"切节点,求和后只剩一个区域的量(375 而不是 1125),水合成本低估 7%。改成按"交互组件占全站组件 的比例"切之后,两份脚本的岛屿水合成本一致(151.9ms)。
  5. 断言"SSR 的全站回源应是 ISR 的 5 倍以上"失败 (freshness-ledger.mjs):实测只有 1.4 倍 (1.00M vs 726K)。这不是模型错,是我的直觉错------99.3% 的页面上 ISR 退化成了 SSR。这条失败断言直接产出了难点七的核心结论。
  6. 断言"零收益页面承担的流量不到一半"失败 (freshness-ledger.mjs):实测 63% ,而且真正从回源节省中获益的页面只有 67 个。修正后的表述比原假设更强,也更值得写进文章。
  7. 把回源风暴写在"平均流量"上 (freshness-ledger.mjs):初版说热门页会发生风暴,实算并发只有 0.22 ,风暴不存在。改成事件流量(λ=200/s → 并发 60)之后才成立,而这也正是现实里的触发条件(秒杀、大促、热搜)。
相关推荐
Elcker4 小时前
YnuoAgent(依诺):一个 npm 包,跑起属于自己的 AI 研发平台
前端·人工智能·npm
计算机魔术师4 小时前
发现2天,修复2个月:AI挖穿PHP后,卡兹克讲透攻防时间差
前端
风骏时光牛马4 小时前
测试标题001
前端
IT_陈寒5 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记5 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端
计算机魔术师5 小时前
半年前说不缺钱,半年后150亿美元超募
前端
用户5708462574405 小时前
自动化发布器里最贵的 Bug:返回成功,但什么都没发生
前端
爱吃馒头不吃辣5 小时前
加一道题,改一天代码:题库即数据的降维打击
前端·架构
aiks_yuan5 小时前
Requestly规则使用技巧:前端联调、接口Mock与网络请求调试实战
前端