性能监控

一、技术难点

前 33 篇把"怎么变快"讲完了:渲染管线、加载、网络、SSR、运行时、bundle、图片字体、Worker、WASM。这一篇讲最后一块,也是最容易被做成假数据 的一块:你怎么知道自己真的快了,以及你怎么知道自己没在骗自己。

本篇不用任何第三方库:vitals.mjs(指标口径纯函数)+ sdk.mjs(采集/缓冲/上报)+ harness.mjs(确定性假页面回放)+ bench.mjs(全部数字来源)+ test-rum.mjs(56 条断言全绿 )+ extract-and-test.mjs(把正文里的代码块抽出来直接跑)。环境:Node 22.23.3 / darwin-arm64。

先给结论。下面每一条都是这台机器上跑出来的,不是推演:

流传的说法 实测结果 差在哪
「CLS 就是位移量加起来」 500 个模拟会话中,朴素求和把 p75 从 0.1634 抬到 0.4376(2.68x) ,判定"良好(≤0.1)"的会话占比从 26.2% 掉到 2.2% CLS 是会话窗口内的最大值,窗口按 1s 间隔 / 5s 上限切分
「客户端采样后混算分位就行」 移动端 1% + 桌面 10% 两批混算,p75 偏 −30.8%(1659.7 vs 真值 2398.7ms);带权重后 −2.9% 采样偏差是系统性偏移,谁的采样率高谁主导
「各分片算好 p90 再平均」 8 片机房:真值 2220.5ms,平均法 2192.1ms(−1.28%) ,直方图相加 2225.0ms(+0.20%) 分位数没有逆运算,直方图可以相加
「蓄水池采样最省事」 n=100 时 p75 平均偏差 6.89% 、p95 10.01% ;n=2000 才降到 1.05% / 2.09% 存储涨 20x 只换个位数精度;直方图 76 个桶就更准
「INP 就是最差那次交互」 200 次交互里 1~4 个 480ms 尖刺会被截尾整个抹掉,第 5 个才露头;交互 1000 次时两者差 79.5ms INP 是截尾后的典型最差,不是历史最差
「采集开销可以忽略」 mark+measure 0.22~0.38 µs/次 ;带 observer 的差值 −0.04~+0.12 µs (跨零点 = 噪声内);push 对象 0.0037 µs/次 。但 JSON.stringify(10k 条) ≈ 2.6ms,这已经是一次长任务 贵的不在采集,在序列化与每帧 flush
「sendBeacon 很方便」 500 条带归因明细 = 83513B > 64KB ,浏览器静默丢弃,控制台无报错 归因字段让单条从 ~120B 涨到 ~300B,预算要先算

难点一:CLS 不是总和,是"会话窗口里的最大值"

大多数自研 SDK 的 CLS 实现长这样:s += entry.value。这在真实页面上是灾难性的。

layout-shift 的语义是"这一帧有多少比例的视口被移动了"。一个页面从加载到关闭可能有 30 次 shift:骨架屏替换(0.12)、字体加载后行高变化(0.03)、三张图片撑开(0.05×3)、推荐位插进来(0.08)。加总是 0.38,用户真正"感觉被顶了一下"的只有最集中的那一段。

Chrome 的口径是会话窗口(session window) :shift 按时间聚类,相邻两条间隔 > 1s 就断开 ,单个窗口跨度不超过 5s ,CLS = 所有窗口里的最大值。同时:

  • hadRecentInput === true 的 shift 完全丢弃(用户在 500ms 内有过输入 → 是用户自己引发的,不算"意外布局偏移");
  • 页面隐藏期间的 shift 不入账(否则后台标签页里继续跑的轮播、广告、骨架动画会把 CLS 撑爆)。

实测(500 个模拟会话,bench.mjs §1):

ini 复制代码
朴素求和  p50=0.3348 p75=0.4376 p95=0.6030
会话窗口  p50=0.1250 p75=0.1634 p95=0.2567
→ p75 被朴素求和放大 2.68x(CWV 门槛 0.1 会从"良好"变成"需改进")
→ 判定"良好(<=0.1)"的会话占比:朴素 2.2% vs 正确 26.2%

也就是说:用错口径,你会把 26.2% 的合格页面判成不合格,然后花三个月去优化一个不存在的问题。

难点二:分位数不可逆,而性能指标全是分位数

性能指标的行业口径全是 p75 (Core Web Vitals 用的是第 75 百分位)。于是所有人都要面对同一个问题:怎么把 N 台机器 / N 个分片 / N 个采样批次的数据合成一个 p75?

分位数是个不可逆的聚合 。我知道 8 台机器的 p90 是 1850, 1927, 2021, 2123, 2241, 2350, 2459, 2566,这 8 个数字推不出 总体的 p90------因为丢掉了各自的分布形状与样本量。实测(§4):

ini 复制代码
真值 p90 = 2220.5ms
8 片 p90 标量平均 = 2192.1ms → 误差 −1.28%
8 片直方图相加后 p90 = 2225.0ms → 误差 +0.20%

−1.28% 看起来"能接受",但这是样本量相同、分布接近 的乐观情形。真实环境里一个边缘机房只有 300 个样本、主机房有 30 万个样本,平均法会直接把边缘机房的 p90 抬成总体 p90------一次机房故障会被平均成一次集群性劣化,甚至看不出来。

正确的容器是直方图 :它不是"统计结果",是"分布的压缩表示",所以可以按桶相加(§4 里 8 片各 50ms 桶,相加后 98 个桶,误差 0.20%)。

难点三:采样偏差不是噪声,是系统性偏移

比直方图更隐蔽的是采样率不一致 。真实场景:移动端流量占 80%,但你为了省钱只对移动端采 1%,桌面端采 10%。上报回来的 1700 条里,移动端 500 条、桌面端 1200 条------上报数据里桌面端反而占 70% (实际只占 20%)。实测(§3):

ini 复制代码
移动端样本 500 条(真实 50000,权重 100)
桌面端样本 1200 条(真实 12000,权重 10)
总体真值 p75 = 2398.7ms
直接混算 p75 = 1659.7ms(误差 −30.8%)
加权合并 p75 = 2329.5ms(误差 −2.9%)

−30.8%。也就是说你会在报表上看到"移动端性能大幅改善",而实际什么都没变------只是你把慢的样本采少了。

推论有三条,都很实操:

  1. 采样必须带权重 :weight = 1 / sampleRate,落库时和值一起存,聚合时加权(直方图则天然可加:桶里存的是"加权计数");
  2. 采样要在会话级做一次 ,不能按指标各自决定。否则你拿到的是"有 LCP 没 CLS"的残废会话,做归因时对不上(sdk.mjs 里 sampled 在构造时定死);
  3. 采样率本身要够 。§10 实测 5 万会话,重复 40 次:
采样率 样本量 p75 估计 相对波动
1% ≈500 1904.7ms ±57.6ms(3.03%)
5% ≈2500 1908.5ms ±25.2ms(1.32%)
20% ≈10000 1907.2ms ±12.9ms(0.68%)

1% 采样估 p75 已经够用(真值 1909.7ms,偏差 0.3%)。把 3% 的波动压到 1% 要付 5 倍采集成本,这是典型的"用力用错地方"。

难点四:每个指标的定义都是一个状态机,不是一个公式

这是本篇最容易被跳过、又最容易全盘出错的部分。三个指标各有一个状态机:

LCP:候选不断被替换,直到"首次输入"冻结。 LCP 不是"最大的那张图",而是在首次用户交互之前 出现的最大内容元素的时间。一旦用户点了/滚了/输入了,LCP 就冻结------因为此后的加载是用户驱动的。实测(§2):

ini 复制代码
候选: 900ms/20000, 1400ms/90000, 5000ms/400000
无输入      → LCP = 5000ms(取最大 size)
首次输入3300 → LCP = 1400ms
首次输入1000 → LCP = 900ms

同一份 entry 流,LCP 可以是 900、1400 或 5000------差 5.5 倍。如果你的 SDK 用"最后一个候选"或者"最大候选"当 LCP,你会和 Search Console 的数据永远对不上,而且不知道谁对。

INP:最差交互,但要截尾。 INP 也不是"最慢的那次点击"。Chrome 的算法是:交互按 latency 排序,每 50 次交互丢掉最高的 1 个 (等价保留 98% 分位);交互数 ≤ 50 时不截尾 。实测(§8,200 次交互、每次 60ms 加若干 480ms 尖刺):

尖刺个数 截尾前 max 截尾后 INP
1 480 60(被抹掉)
4 480 60(截尾恰好 4 个)
5 480 480(第 5 个露头)

这个取舍看起来"掩盖问题",但它是刻意的 :交互次数上千的页面必然出现偶发尖刺(GC、切 tab、系统抢占)。INP 要量的是"典型最差",不是"历史最差"------只有前者是可优化、可复现、可交付的。相应地,一个尖刺能毁掉你的指标,说明你的样本量太小,应该先解决"交互埋点覆盖不全",而不是去追那个尖刺。

TBT:只算 TTI 之前的长任务。 TBT = Σ(max(0, 长任务时长 − 50ms)),且只统计到可交互为止。但同样 600ms 的总耗时,形态不同,TBT 与体感完全不同(§9):

形态 总耗时 TBT 最差单次阻塞
3 个 200ms 600ms 450ms 150ms
10 个 60ms 600ms 100ms 10ms
1 个 600ms 600ms 550ms 550ms

TBT 是"总账",worstBlocking 才是"体感" 。这也是为什么上线 LoAF(long-animation-frame)后要同时收 blockingDuration 与 scripts 归因:50ms×10 的卡顿用户能忍,一次 550ms 的白屏用户会走。

难点五:观测者效应------监控系统自己就是性能问题

三条实测/硬约束:

  1. 采集本身便宜,序列化很贵 。mark+measure 0.22~0.38 µs/次 (3 轮 × 5 次取最小,抖动 ±30%);带 observer 的差值是 −0.04~+0.12 µs/次 ------跨零点,说明回调成本落在测量噪声内 (诚实结论:回调不是瓶颈,别拿它当"少埋点"的借口);直接 push 一个对象进数组 0.0037 µs/次 ,比 measure 便宜 60~90x 。但 JSON.stringify 1000 条明细 ≈255 µs ,1 万条 ≈2.6ms ------前者是"零成本",后者自己就构成一次长任务 (§5、§6)。
  2. 不清理的 entry buffer 会一直涨 。§5 里我只做了 5000 次 mark/measure,getEntriesByType('measure') 就残留了 5000 条。不 clearMeasures() 的话,getEntries 会越来越慢,内存也回不去------监控把自己变成了内存泄漏源。
  3. sendBeacon 超 64KB 是静默丢弃 。§7:朴素 JSON 500 条带归因明细 = 83513B 、1000 条 = 167013B ,都超了------浏览器不抛错、不重试、控制台无事发生,你以为在上报,其实在扔数据。列式编码能把 500 条压到 64581B (刚好卡在上限内),但 1000 条又是 129081B ------所以预算必须按字节算,不能按条数猜。

难点六:字段数据(field)和实验室数据(lab)永远打架

这个矛盾没有技术解法,只能靠"分清楚它们各回答什么问题":

实验室(Lighthouse/CI) 字段(RUM)
样本 1 台机器、固定网络、冷缓存 真实用户、真实机型/网络/地域
回答 "能不能更快"(可复现、可归因) "有没有更快"(可信、含长尾)
典型偏差 乐观:本地 M2 + 千兆网 悲观:锁 4 核 + 弱网 + 后台标签页
用途 卡门槛、防回归 定优先级、验证收益

最容易犯的错:用实验室数据判断收益。你干掉 40KB JS,Lighthouse 分数从 78 → 92,但真实用户 p75 可能一点没动------因为他们的瓶颈在 4G 首包与低端机的主线程,不在那 40KB 上。反过来,RUM 只能说"慢了",说不了"慢在哪"。

正确姿势是用 RUM 定位人群和页面,用 lab 复现和归因 ,并且用同一份 entry 流打通两者(下节 2.1 的做法)。


二、完整解法

2.1 架构地基:把指标写成纯函数(entry 流 → 值)

整篇文章的所有内容都建立在一条设计原则上:

浏览器胶水层只负责喂 entry,指标计算全部是纯函数。

好处有三个,缺一个都不行:

  1. 可以在 Node 里断言 ------不用起浏览器,test-rum.mjs 56 条断言秒级跑完;
  2. 可以回放线上真实日志------用户报"某页面 CLS 很高",导出那次的 entry 流,本地跑一遍就知道是哪个窗口、哪几条 shift;
  3. lab 与 field 共用同一套口径------否则你永远在解释"为什么两个报表不一样"。
js 复制代码
// cls-window.mjs ------ 会话窗口:分组靠"间隔",取值靠"最大窗口"
import { clsSessionWindow, clsNaiveSum } from './vitals.mjs';

const shifts = [
  { value: 0.12, startTime: 1800 },                        // 骨架屏 → 内容
  { value: 0.05, startTime: 3200, hadRecentInput: true },   // 点击后插入:丢弃
  { value: 0.08, startTime: 4200 },                         // 图片撑开
  { value: 0.06, startTime: 4600 },                         // 同批渲染,间隔 400ms → 同窗口
];

const w = clsSessionWindow(shifts);
console.log('窗口:', JSON.stringify(w.windows));
console.log('CLS =', w.cls, '| 朴素求和 =', clsNaiveSum(shifts));

// 断言:口径错了会静默给出错误答案,只能靠断言锁住
if (w.windows.length !== 2) throw new Error(`应切出 2 个窗口,实得 ${w.windows.length}`);
if (w.cls !== 0.14) throw new Error(`CLS 应为 0.14(max 窗口),实得 ${w.cls}`);
if (clsNaiveSum(shifts) !== 0.26) throw new Error(`朴素求和应为 0.26,实得 ${clsNaiveSum(shifts)}`);

跑出来是 CLS = 0.14 | 朴素求和 = 0.26------1.86 倍的口径差 ,只因为 1800ms 那次和 4200ms 那次之间隔了 2.4 秒。而 hadRecentInput 的那条 0.05 完全不入账。

2.2 LCP 冻结与 INP 截尾

js 复制代码
// lcp-inp.mjs ------ 两个状态机
import { lcpFromCandidates, inpFromInteractions, inpNaiveMax, interactionLatency } from './vitals.mjs';

const candidates = [
  { startTime: 900,  size: 20000,  element: 'h1' },
  { startTime: 1400, size: 90000,  element: 'img.hero' },
  { startTime: 5000, size: 400000, element: 'img.lazy' },   // 首屏之后才加载的大图
];

// LCP:首次输入即冻结,之后的大图不算
const frozen = lcpFromCandidates(candidates, { firstInputTime: 3300 });
const noInput = lcpFromCandidates(candidates, { firstInputTime: Infinity });
console.log('首次输入 3300 → LCP', frozen.value, 'ms | 无输入 →', noInput.value, 'ms');
if (frozen.value !== 1400) throw new Error(`冻结后应为 1400,实得 ${frozen.value}`);
if (noInput.value !== 5000) throw new Error(`无输入应为 5000,实得 ${noInput.value}`);

// INP:截尾 2%,丢掉"偶发尖刺"但保留系统性慢
const mk = (spikes) =>
  Array.from({ length: 200 }, (_, i) => ({ latency: i >= 200 - spikes ? 480 : 60, name: 'pointerdown' }));
for (const n of [1, 4, 5]) {
  console.log(`200 次交互 / ${n} 个尖刺 → INP ${inpFromInteractions(mk(n)).value}ms(max ${inpNaiveMax(mk(n))}ms)`);
}
if (inpFromInteractions(mk(4)).value !== 60) throw new Error('4 个尖刺应被截尾抹掉');
if (inpFromInteractions(mk(5)).value !== 480) throw new Error('第 5 个尖刺应露头');

// 单次交互 latency:500ms 上限 + 脏数据守卫
if (interactionLatency(100, 800) !== 500) throw new Error('交互 latency 应被 500ms 截断');
if (interactionLatency(100, 90) !== 0) throw new Error('processingEnd 早于 start 应返回 0');
console.log('OK');

2.3 分位的正确容器:加权直方图

js 复制代码
// histogram.mjs ------ 直方图是唯一"可加"的分位容器
import {
  histogramFixed, histogramPercentile, mergeHistograms,
  exactPercentile, weightedPercentileValue, lognormal, mulberry32,
} from './vitals.mjs';

const rand = mulberry32(2026);
// 造两批"采样率不同"的真实数据:移动端慢(中位 ~1800ms)但采样稀
const mobile = Array.from({ length: 6000 }, () => lognormal(rand, 7.5, 0.5)).filter((_, i) => i % 10 === 0);
const desktop = Array.from({ length: 6000 }, () => lognormal(rand, 6.9, 0.4)).filter((_, i) => i % 2 === 0);

// ✅ 直方图:每片自己维护,服务端按桶相加(weight 体现采样率)
const hists = [
  { hist: histogramFixed(mobile, 50), weight: 10 },
  { hist: histogramFixed(desktop, 50), weight: 3 },
];
const merged = mergeHistograms(hists.map((h) => h.hist));
console.log(`桶数 ${merged.buckets.size}, 样本 ${merged.total}, 合并 p75 = ${histogramPercentile(merged, 0.75).toFixed(0)}ms`);

// ✅ 加权分位:必须带 weight,否则谁的采样率高谁主导
const weighted = weightedPercentileValue(
  [{ values: mobile, weight: 10 }, { values: desktop, weight: 3 }],
  0.75,
);
const naive = exactPercentile([...mobile, ...desktop], 0.75);
console.log(`加权 p75 = ${weighted.toFixed(0)}ms | 不加权混算 = ${naive.toFixed(0)}ms`);
if (Math.abs(weighted - naive) < 1) throw new Error('两批分布差异明显,加权与不加权不应相等');
if (merged.total !== mobile.length + desktop.length) throw new Error('直方图相加丢样本');
console.log('OK');

要点:

  • 桶宽是精度与存储的唯一旋钮 。§2 实测(n=20000、LCP 长尾):50ms 桶需要 138 个桶 ,p75 误差 −0.30% ;100ms 桶只要 76 个桶 ,误差涨到 −1.70% (5.7x )。宽值域场景(从 50ms 到 30s)用对数桶 :10/decade 只要 20 个桶 ,代价是 p50/p95 误差 −8.77% / −6.83% ;提到 20/decade 用 38 个桶 ,误差改善到 −3.37% / −1.30%。
  • 别用蓄水池 。n=100 时 p75 平均偏差 6.89% 、p95 10.01% ;n=2000 才降到 1.05% / 2.09% ------存储涨 20 倍,而上面 50ms 桶只用了 138 个桶就做到 0.3%。蓄水池唯一的优势是"能还原原始值",如果你的归因需要具体样本,用它;否则用直方图。
  • 同一张表里绝不混桶宽。50ms 桶和 100ms 桶相加是无声的灾难(数值上不报错,结果全歪)。

2.4 采集层:缓冲区与"丢谁"

采集层的核心不是"怎么收",而是满了怎么办、脏数据怎么办。三个约定:

  1. 脏数据一律不收 :NaN、负数直接丢(!(value >= 0) 同时挡住 NaN)。一个 NaN 进了直方图,整个分位数会变成 NaN,而聚合服务通常不会报错,只会返回空------你会在两周后发现"某指标没数据",却不知道从哪天开始。
  2. 缓冲区满了先丢明细,绝不丢核心指标 。sdk.mjs 的做法是优先挤掉 dbg.* 前缀的明细;如果缓冲区里全是核心指标,宁可丢新点,也不丢已算好的核心(已算好的点代表一个完整会话的结论,新点可能同一个会话的重复)。
  3. 会话级采样一次性决定(见难点三)。
js 复制代码
// buffer-guard.mjs ------ 缓冲区的丢弃策略与脏数据守卫
import { createSession } from './harness.mjs';

const { rum } = createSession({ maxBuffer: 4 });

// 先塞满明细
for (let i = 0; i < 3; i++) rum.push('dbg.resource', i, i);
// 再塞核心指标
rum.push('cls', 0.12, 10);
rum.push('lcp', 900, 11);

// 脏数据
rum.push('cls', NaN, 12);   // 丢弃
rum.push('lcp', -1, 13);    // 丢弃

const names = rum.buffer.map((b) => b.name);
console.log('缓冲区:', JSON.stringify(names), '| 丢弃数:', rum.dropped);

if (rum.buffer.length !== 4) throw new Error(`缓冲区应满 4 条,实得 ${rum.buffer.length}`);
if (!names.includes('cls') || !names.includes('lcp')) throw new Error('核心指标被丢了');
if (names.filter((n) => n === 'dbg.resource').length !== 2) throw new Error('应挤掉 1 条明细');
if (rum.buffer.some((b) => !(b.value >= 0))) throw new Error('脏数据进了缓冲区');
console.log('OK');

2.5 上报:压缩、预算、时机

压缩 :列式编码(字段名不重复出现)把 1000 条明细从 72480B 压到 40523B(55.9%) ;gzip 后 15204B → 6720B ,收益仍剩 55.8% (§6)。gzip 之后还有收益这件事说明:省的主要不是重复的字段名,是每个条目的结构冗余(对象括号、引号、浮点位数)。

预算 :单次 flush 前先算字节,超 64KB 就切批(sdk.mjs 用 BEACON_LIMIT_BYTES - 1024 留余量)。归因字段是体积元凶:一条从 ~120B 涨到 ~300B,朴素 JSON 500 条就爆 ;列式能撑到 500 条刚好 、1000 条又爆。按字节切批、别按条数切批。

顺带一个本篇真踩的坑 :第一版 encodeReport 只编码 name/value/ts 三列,压测报告体积非常漂亮------因为归因字段被整列静默丢掉了 。列式编码的失效模式不是"变大",而是"少了东西但不报错"。现在 vitals.mjs 的编码器会检测任意一条带 el/url/ev 就补齐整列,并用 decodeReport 做往返断言(test-rum.mjs §9,5 条)。

容量 :一包"4 指标 + 归因"实际只有 105B (gzip 98B )。100 万 PV × 2 次 flush ≈ 210MB/天 上行------监控系统的存储与带宽成本几乎总是被明细字段撑爆的,不是被核心指标撑爆的。这也是"归因只对异常会话开"的经济学理由。

时机 :唯一可信的"最后一刻"是 visibilitychange → hidden。

  • unload / beforeunload 在移动端、bfcache、iOS 上大量不触发;
  • 现在浏览器默认启用 bfcache,用户"返回"时页面根本没走 unload 流程,pagehide 的 event.persisted === true 才是正确信号;
  • 定时 flush 也别省:长时间停留的页面(后台管理系统、编辑器)可能几小时才 hidden 一次,中途崩了全丢。
js 复制代码
// flush-size.mjs ------ 上报前必须按字节算预算,不能按条数猜
import { encodeReport, BEACON_LIMIT_BYTES } from './vitals.mjs';

const mkReport = (n, withAttr) =>
  Array.from({ length: n }, (_, i) => ({
    name: 'lcp',
    value: 1234.5 + i,
    ts: 1_700_000_000_000 + i,
    ...(withAttr
      ? { el: 'main > div:nth-child(3) > img.hero-img', url: '/static/media/hero.9f3a2b.webp', ev: 'pointerdown' }
      : {}),
  }));

for (const n of [200, 500]) {
  const slim = Buffer.byteLength(encodeReport(mkReport(n, false)));
  const attr = Buffer.byteLength(encodeReport(mkReport(n, true)));
  const plainAttr = Buffer.byteLength(JSON.stringify({ reports: mkReport(n, true) }));
  console.log(
    `${String(n).padStart(4)} 条 | 列式仅指标 ${String(slim).padStart(6)}B | 列式带归因 ${String(attr).padStart(6)}B | ` +
      `朴素带归因 ${String(plainAttr).padStart(6)}B ${plainAttr > BEACON_LIMIT_BYTES ? '❌ 静默丢' : '✅'}`,
  );
  if (n === 500 && plainAttr <= BEACON_LIMIT_BYTES) throw new Error('朴素 JSON 500 条归因明细应超 64KB');
  if (n === 500 && attr > BEACON_LIMIT_BYTES) throw new Error('列式 500 条应仍在上限内');
}

// 切批:按实际字节切,保证每包都留 1KB 余量
const rows = mkReport(1000, true);
const chunks = [];
let cur = [];
for (const r of rows) {
  const trial = [...cur, r];
  if (Buffer.byteLength(encodeReport(trial)) > BEACON_LIMIT_BYTES - 1024) { chunks.push(cur); cur = [r]; }
  else cur = trial;
}
if (cur.length) chunks.push(cur);
const sizes = chunks.map((c) => Buffer.byteLength(encodeReport(c)));
console.log(`1000 条切成 ${chunks.length} 包,单包 ${sizes.join('/')}B,最大 ${Math.max(...sizes)}B`);
if (Math.max(...sizes) > BEACON_LIMIT_BYTES - 1024) throw new Error('仍有分包超预算');
console.log('OK');

2.6 胶水层:同构,可回放

这是全篇唯一"跟浏览器打交道"的代码,所以只让它做三件事:注册 observer、把 entry 转成纯数据、挂生命周期。它不含任何计算------所以能在 Node 里用假窗口跑通。

js 复制代码
// rum-install.mjs ------ 浏览器里接 PerformanceObserver,Node 里接假窗口,同一份胶水代码
import { Rum } from './sdk.mjs';
import { typicalPage } from './harness.mjs';

export function installRum(win, opts) {
  const rum = new Rum({ endpoint: opts.endpoint, sampleRate: opts.sampleRate ?? 1, transport: opts.transport });
  const PO = win.PerformanceObserver;

  new PO((l) => { for (const e of l.getEntries()) if (!e.hadRecentInput) rum.onLayoutShift({ value: e.value }, e.startTime); })
    .observe({ type: 'layout-shift', buffered: true });

  new PO((l) => { for (const e of l.getEntries()) rum.onLcpCandidate({ size: e.size, element: e.element?.tagName, url: e.url }, e.startTime); })
    .observe({ type: 'largest-contentful-paint', buffered: true });

  // durationThreshold 别低于 16ms:事件量会爆炸,而 16ms 以下对 INP 没有意义
  new PO((l) => { for (const e of l.getEntries()) { rum.onEvent(e); rum.onInteraction(e); } })
    .observe({ type: 'event', buffered: true, durationThreshold: 16 });

  new PO((l) => { for (const e of l.getEntries()) rum.onLongTask(e); })
    .observe({ type: 'longtask', buffered: true });

  win.addEventListener('visibilitychange', () => {
    if (win.document.visibilityState === 'hidden') rum.onHidden(win.performance.now());
  });
  // bfcache 往返恢复:不补这一发,这段会话永远不上报
  win.addEventListener('pagehide', (e) => { if (e.persisted) rum.onHidden(win.performance.now()); });
  return rum;
}

/* ---- Node 侧:假窗口,连 "buffered: true 会补发历史 entry" 都模拟 ---- */
function fakeWindow(entries, nowMs) {
  const buffer = { 'layout-shift': [], 'largest-contentful-paint': [], event: [], longtask: [] };
  for (const s of entries.shifts) buffer['layout-shift'].push({ value: s.value, startTime: s.startTime, hadRecentInput: !!s.hadRecentInput });
  for (const c of entries.lcp) buffer['largest-contentful-paint'].push(c);
  for (const i of entries.interactions) {
    buffer.event.push({ startTime: i.startTime, processingEnd: i.processingEnd, interactionId: i.interactionId, name: i.name });
  }
  for (const t of entries.longTasks) buffer.longtask.push(t);

  const live = [];
  class FakePO {
    constructor(cb) { this.cb = cb; }
    observe({ type, buffered }) {
      const self = this;
      live.push({ type, deliver: (e) => self.cb({ getEntries: () => [e] }) });
      if (buffered) for (const e of buffer[type] ?? []) self.cb({ getEntries: () => [e] });
    }
    disconnect() {}
    takeRecords() { return []; }
  }
  const win = {
    PerformanceObserver: FakePO,
    performance: { now: () => nowMs },
    document: { visibilityState: 'visible' },
    listeners: {},
    addEventListener(t, fn) { (this.listeners[t] ??= []).push(fn); },
  };
  // 安装后再来的 entry 走 live 回调
  const emit = (type, e) => live.filter((x) => x.type === type).forEach((x) => x.deliver(e));
  const hide = () => {
    win.document.visibilityState = 'hidden';
    (win.listeners.visibilitychange ?? []).forEach((f) => f());
  };
  return { win, emit, hide };
}

const sent = [];
const { win, hide } = fakeWindow(typicalPage(), 9000);
const rum = installRum(win, { endpoint: 'https://r.example.com/collect', transport: (u, p) => sent.push(p) });
hide(); // 唯一的 flush 时机

const report = JSON.parse(sent[0]);
const get = (n) => report.c.v[report.c.n.indexOf(n)];
console.log('上报:', JSON.stringify(report.c));
if (get('cls') !== 0.12) throw new Error(`CLS 应为 0.12(最大窗口),实得 ${get('cls')}`);
if (get('lcp') !== 1400) throw new Error(`LCP 应为 1400(首次输入已冻结),实得 ${get('lcp')}`);
if (get('inp') !== 200) throw new Error(`INP 应为 200,实得 ${get('inp')}`);
if (get('tbt') !== 200) throw new Error(`TBT 应为 200,实得 ${get('tbt')}`);
if (sent.length !== 1) throw new Error('hidden 时应且仅当发一包');
console.log('OK');

2.7 验收:把口径变成断言

test-rum.mjs 的 56 条断言覆盖了:会话窗口的三种切分(间隔断开 / 5s 上限 / 输入过滤)、LCP 的三种冻结时机、INP 的截尾边界(50/51/100/1000)、TBT 的 TTI 截止、直方图的可加性与误差上限、加权 vs 不加权、采样为 0 时零上报、缓冲区丢弃优先级、脏数据守卫、隐藏期过滤、列式编码体积。跑法:

bash 复制代码
node test-rum.mjs   # 56 通过 / 0 失败
node bench.mjs      # 生成 bench-log.txt(本文所有数字)
node extract-and-test.mjs   # 抽正文代码块,落地再跑

任何一条口径被改坏,这里立刻红。这就是"性能监控"和"性能仪表盘"的区别:后者只是把数字画出来,前者能证明数字是对的。

2.8 三条工程约定(比代码更重要)

  1. 指标要有 owner 和预算。每个指标必须回答"多少算好、超标谁负责、超了怎么办"。没有预算的指标最终会变成"一直红着,无人处理"的壁纸。
  2. 看板默认展示 p75 + 直方图形状,不要只给一个平均值。平均值会掩盖双峰(一半用户 300ms、一半 5000ms,均值 2650ms------看着"中等",实际两拨人都不满意)。
  3. 任何优化上线必须带 RUM 前后对比,并写清"预期动哪个指标、动多少"。没有预期的优化,事后无法判断成败(这也是 Day56 性能案例篇的方法论基础)。

三、应用场景

3.1 判据表

场景 该做什么 关键数字(本篇实测) 不该做什么
C 端首屏(电商/内容) 全量采 CWV 四件套 + 按路由/机型/网络分桶 CLS 口径错 → p75 差 2.68x 用客户端算好的平均值上报
后台管理 / B 端 SaaS 重点 INP + 长任务,忽略 LCP(通常是热启动) 200 次交互截尾 4 个尖刺 用小样本(<50 交互)算 INP
多机房 / 边缘部署 直方图按桶相加,桶宽全局统一 分片 p90 平均偏 −1.28% ,直方图 +0.20% 各分片算完分位再聚合
自研 SDK / 组件库 只上报"你的组件引入的开销" mark+measure 0.22~0.38 µs/次 采集全站指标(你不是监控平台)
弱网 / 移动端 采样率降但必须带权重 混算偏 −30.8% ;1% 采样 p75 波动 3.03% 只对慢用户采样(重度选择偏差)
需要归因 只在异常会话上采归因明细 500 条归因 = 83513B > 64KB 全量带选择器/URL

3.2 场景一:一个"指标对不上"的真实排查流程

症状:自研看板 LCP p75 = 2.4s,Search Console 显示 3.9s,差 1.5s。

排查顺序(本篇每个步骤都有工具支撑):

  1. 先对齐口径 :我们的 LCP 是否在首次输入后冻结?没有 ------我们取的是"最大候选",用户滚动后加载的大图也算进去了 → 这会让我们的数偏大,但实际偏小,说明还有别的因素;
  2. 再查采样 :我们对首屏慢的设备降采样("省流量"),恰好把慢样本采少了 → 系统性偏小(难点三,−30.8% 量级);
  3. 再查时机 :只在 unload 上报,而 40% 的会话(移动端 + bfcache)走不到那一步 → 丢的正好是"早期离开的慢会话";
  4. 导出真实 entry 流回放 :用 harness.mjs 重放,确认 3.9s 才是对的。

四步里三步都是口径/工程 bug,没有一步是"页面真的变快了"。

3.3 场景二:把 lab 与 field 打通

CI 里跑 Lighthouse 拿到 lab 数据,线上有 RUM。打通做法:

  • 两侧共用 vitals.mjs (纯函数),lab 侧用 harness.mjs 把 Puppeteer 采到的 entry 流灌进同一套计算;
  • lab 值班卡门槛(LCP < 2.5s 否则失败),因为可复现、无长尾噪声;
  • field 值班定优先级:看哪个路由、哪个机型、哪个网络分桶的 p75 最差,以及直方图是双峰还是长尾;
  • 收益验证只用 field,且必须同人群前后对比(同路由、同机型、同网络),避免"大盘涨了但其实是流量结构变了"。

3.4 场景三:低端机 / 弱网用户的"隐形分母"

RUM 最容易骗人的地方在这里:上报本身需要网络和 CPU 。弱网 + 卡顿设备上,SDK 的 flush 更容易失败、更容易被 kill、更少能走到 hidden 事件。于是"报上来的数据"天然偏向好设备------你越需要看见的用户,越看不见。

对策(都是低成本):

  • visibilitychange + 定时 flush 双通道,缩短"未上报窗口";
  • 上报失败的会话打采样标记 而非直接丢,配合服务端"缺失率"指标(缺失率本身是核心指标,要和 p75 一起看);
  • 用 navigator.sendBeacon + keepalive: true 的 fetch 双写,后者对 4xx 有回执;
  • 按设备/网络分桶后单独看 p75,不要只看大盘------大盘的 p75 会永远被主流机型拉平。

3.5 场景四:性能预算的 CI 卡点

js 复制代码
// budget.mjs ------ 卡点写法:把预算写成断言,而不是文档里的一句话
const budgets = { lcp: 2500, cls: 0.1, inp: 200, tbt: 300 };
const runs = [
  { name: '本次 PR', measured: { lcp: 2300, cls: 0.08, inp: 190, tbt: 280 } },
  { name: '反例(上一版)', measured: { lcp: 2600, cls: 0.12, inp: 210, tbt: 320 } },
];

let bad = 0;
for (const r of runs) {
  const fails = Object.entries(budgets)
    .filter(([k, v]) => r.measured[k] > v)
    .map(([k, v]) => `${k}: ${r.measured[k]} > ${v}`);
  if (fails.length) { bad += 1; console.log(`❌ ${r.name} 超标: ${fails.join(', ')}`); }
  else console.log(`✅ ${r.name} 通过`);
}
// CI 里换成 process.exit(bad ? 1 : 0)
if (bad !== 1) throw new Error(`应有 1 次超标,实得 ${bad}`);

这段可以直接放进 PR 流水线(Day38 的主题)。要点:预算用 p75 + 小比例容差 ,且必须和"这周要动的指标"绑定------一次性卡四个指标,最后一定是四个都被调松。


四、总结

4.1 十条 checklist(可直接抄进评审模板)

  1. CLS 用会话窗口最大值 (1s 间隔 / 5s 上限),不是总和;hadRecentInput 与隐藏期 shift 都要过滤。
  2. LCP 在首次输入冻结;不要用"最后一个"或"最大"候选当 LCP。
  3. INP 是截尾 2% 的典型最差 (每 50 次丢 1 个最高值,≤50 次不截尾);同时上 worstBlocking 看体感。
  4. TBT 只算 TTI 之前 的长任务;Σ(dur − 50ms),别把 45ms 的任务算进去。
  5. 分位只存直方图(桶宽全局统一;宽值域用对数桶),不要存"算好的分位"。
  6. 采样在会话级做一次 ,并带权重 ;weight = 1 / sampleRate 和值一起落库。
  7. 上报时机以 visibilitychange → hidden + pagehide(bfcache) 为准,unload 不可信。
  8. 单包先算字节(sendBeacon 64KB 静默丢);归因明细只在异常会话上采。
  9. 采集代码要纯函数化,能在 Node 里回放线上 entry 流;口径用断言锁住。
  10. 看板默认展示 p75 + 直方图形状;平均值不做决策依据。

4.2 与前后篇的咬合

  • Day25 渲染管线 / Day29 运行时性能 :本篇的 CLS/TBT/长任务就是那两篇的"体检指标";Day29 讲的分配与 GC 停顿,在 §9 的 worstBlocking 里第一次变得可见。
  • Day26 加载性能 / Day30 bundle 优化:LCP 与资源归因是它们的验收手段;本篇给的"lab 卡门槛、field 验收益"是那两篇优化的闭环。
  • Day32 Worker / Day33 WASM :Worker 池与 wasm 内存不出现在 JS 堆指标里 (Day33 实测:128MB 线性内存旁 heapUsed 仅 15.1MB),所以监控必须自己接自定义水位(byteLength / grow 返回值 / 高水位)。
  • Day35 构建工具 / Day38 流水线:§3.5 的性能预算卡点要在流水线里落地。
  • Day40 可观测性 :本篇是"前端侧埋点",Day40 会把 trace、日志、指标三件套串成一条链------本篇的 sessionId / 采样标记就是那条链上的 correlation id。

4.3 一句话

性能监控的价值不在于"知道现在多快",而在于"能证明变快了"------而证明的前提是口径可复现、聚合可合并、偏差可量化。 口径错一格,优化就白干三个月;采样偏一成,报表就骗你一整年。


运行环境与文件清单

bash 复制代码
Node 22.23.3 / darwin-arm64
day34-assets/
├── vitals.mjs            # 指标口径纯函数(CLS/LCP/INP/TBT/直方图/编码)
├── sdk.mjs               # 采集·缓冲·flush·采样
├── harness.mjs           # 确定性假页面 + entry 流回放
├── test-rum.mjs          # 51 条断言(全绿,exit 0)
├── bench.mjs             # 本文全部数字的来源
├── bench-log.txt         # 实测输出存档
└── extract-and-test.mjs  # 从正文 MD 抽代码块,落地再跑
bash 复制代码
cd day34-assets
node test-rum.mjs          # 56 通过 / 0 失败
node bench.mjs             # 生成 bench-log.txt
node extract-and-test.mjs  # 正文代码块自证(全部 exit 0)

附录:完整代码

以下脚本均来自 day34-assets/(完整源码)。 运行:node bench.mjs 跑端到端测量;node test-rum.mjs 验证 RUM 数据真实性;node extract-and-test.mjs 提取正文代码块逐段自检。

vitals.mjs ------ Web Vitals 采集内核(LCP/INP/CLS/TTFB/FCP 的测量与校准)

javascript 复制代码
// vitals.mjs ------ RUM 指标核心:纯函数 (entry 流 -> 指标值)
// 设计原则:所有"指标定义"都写成无副作用纯函数,浏览器胶水层只负责喂 entry。
// 这样线上导出的真实 entry 日志可以原样回放做回归。

/* ---------------- 1. CLS:会话窗口,不是总和 ---------------- */

// web-vitals 口径:hadRecentInput 的 shift 丢弃;每次分组窗口 1s 间隔、5s 上限;取最大窗口值。
export const CLS_GAP_MS = 1000;
export const CLS_CAP_MS = 5000;

/**
 * @param {{value:number,startTime:number,hadRecentInput?:boolean}} entries
 * @returns {{cls:number,windows:{value:number,first:number,last:number,count:number}[],maxWindowIndex:number}}
 */
export function clsSessionWindow(entries) {
  // 注意:不能只在最后过滤 hadRecentInput,因为窗口边界必须基于"计入的"条目计算
  const used = entries
    .filter((e) => !e.hadRecentInput)
    .sort((a, b) => a.startTime - b.startTime);

  const windows = [];
  let cur = null;
  for (const e of used) {
    if (
      cur === null ||
      e.startTime - cur.last > CLS_GAP_MS ||
      e.startTime - cur.first > CLS_CAP_MS
    ) {
      cur = { value: 0, first: e.startTime, last: e.startTime, count: 0 };
      windows.push(cur);
    }
    cur.value += e.value;
    cur.last = e.startTime;
    cur.count += 1;
  }

  let maxWindowIndex = -1;
  let cls = 0;
  windows.forEach((w, i) => {
    if (w.value > cls) {
      cls = w.value;
      maxWindowIndex = i;
    }
  });
  return { cls: round4(cls), windows, maxWindowIndex };
}

/** 朴素做法(很多自研 SDK 就写成了这样):全量求和 */
export function clsNaiveSum(entries) {
  let s = 0;
  for (const e of entries) if (!e.hadRecentInput) s += e.value;
  return round4(s);
}

/* ---------------- 2. LCP:候选不断被替换,直到"首次输入"冻结 ---------------- */

/**
 * @param {{startTime:number,size:number,element?:string,url?:string}[]} candidates
 * @param {{firstInputTime?:number|null}} opts 首次真实交互时间(pointerdown/keydown/click)
 */
export function lcpFromCandidates(candidates, opts = {}) {
  const cutoff = opts.firstInputTime ?? Infinity;
  let best = null;
  for (const c of candidates) {
    // 首次输入之后的候选无效:LCP 已冻结
    if (c.startTime > cutoff) continue;
    if (!best || c.size > best.size) best = c;
  }
  return best
    ? { value: round2(best.startTime), size: best.size, element: best.element ?? 'unknown', url: best.url ?? '' }
    : { value: 0, size: 0, element: null, url: '' };
}

/** 朴素做法:拿"最后一个"候选当 LCP(实测会偏大或偏小,取决于是否还有更大的图) */
export function lcpLastCandidate(candidates) {
  const c = candidates[candidates.length - 1];
  return c ? { value: round2(c.startTime), size: c.size } : { value: 0, size: 0 };
}

/* ---------------- 3. INP:最差交互,但要"截尾" ---------------- */

export const INTERACTION_MAX_MS = 500; // 交互窗口上限
export const INP_TRIMMED_PCT = 0.98;

/**
 * web-vitals 的截尾:每 50 次交互丢 1 个最高值(等价保留 98% 分位),
 * 但交互数 <= 50 时不截尾(宁可被长尾咬,也不要样本不足时误判)。
 */
export function trimmedInteractionCount(total) {
  if (total <= 50) return total;
  return total - Math.floor(total / 50);
}

/**
 * @param {{startTime:number,latency:number,name?:string}[]} interactions
 */
export function inpFromInteractions(interactions) {
  if (interactions.length === 0) return { value: 0, target: null, total: 0, kept: 0 };
  const sorted = [...interactions].sort((a, b) => a.latency - b.latency);
  const kept = Math.max(1, trimmedInteractionCount(sorted.length));
  // 截尾 = 丢掉最高的 (total - kept) 个
  const idx = kept - 1;
  const pick = sorted[idx];
  return {
    value: round2(pick.latency),
    target: pick.name ?? null,
    total: sorted.length,
    kept,
  };
}

/** 朴素做法:直接取 max */
export function inpNaiveMax(interactions) {
  let m = 0;
  for (const i of interactions) if (i.latency > m) m = i.latency;
  return round2(m);
}

/** 一次交互的 latency = 处理结束 - 开始(简化:不含 presentation delay,但含排队+处理) */
export function interactionLatency(startTime, processingEnd) {
  if (processingEnd <= startTime) return 0;
  return round2(Math.min(processingEnd - startTime, INTERACTION_MAX_MS));
}

/* ---------------- 4. TBT:主线程阻塞的总时长 ---------------- */

export const LONG_TASK_MS = 50;

/**
 * TBT = Σ over tasks (dur - 50),且只统计到 TTI 之前的任务。
 * 这里只做"到 freezeTime 为止",TTI 近似用 firstInputTime。
 */
export function tbtFromLongTasks(tasks, opts = {}) {
  const until = opts.until ?? Infinity;
  let tbt = 0;
  let worst = 0;
  let count = 0;
  for (const t of tasks) {
    if (t.startTime > until) continue;
    const over = Math.max(0, t.duration - LONG_TASK_MS);
    if (over > 0) {
      tbt += over;
      count += 1;
      if (over > worst) worst = over;
    }
  }
  return { tbt: round2(tbt), worstBlocking: round2(worst), longTasks: count };
}

/* ---------------- 5. 分位:直方图 / HDR / 蓄水池 ---------------- */

export function exactPercentile(values, p) {
  if (values.length === 0) return 0;
  const s = [...values].sort((a, b) => a - b);
  // 线性插值(与 numpy 默认一致)
  const rank = (s.length - 1) * p;
  const lo = Math.floor(rank);
  const hi = Math.ceil(rank);
  if (lo === hi) return s[lo];
  return s[lo] + (s[hi] - s[lo]) * (rank - lo);
}

/** 固定桶宽直方图(最常见做法),返回可合并的桶计数 */
export function histogramFixed(values, bucketMs) {
  const buckets = new Map();
  for (const v of values) {
    const b = Math.floor(v / bucketMs);
    buckets.set(b, (buckets.get(b) ?? 0) + 1);
  }
  return { buckets, bucketMs, total: values.length };
}

/** HDR 风格:对数刻度(每数量级 10 桶),窄值域误差小、宽值域误差大但可控 */
export function histogramLog(values, bucketsPerDecade = 10) {
  const buckets = new Map();
  const k = bucketsPerDecade / Math.LN10;
  for (const v of values) {
    const b = Math.floor(Math.log(Math.max(v, 1)) * k);
    buckets.set(b, (buckets.get(b) ?? 0) + 1);
  }
  return { buckets, bucketsPerDecade, total: values.length };
}

/** 从直方图取分位:用桶中点(误差上限 = 桶宽/2) */
export function histogramPercentile(h, p) {
  const target = p * h.total;
  let acc = 0;
  const keys = [...h.buckets.keys()].sort((a, b) => a - b);
  for (const key of keys) {
    const c = h.buckets.get(key);
    acc += c;
    if (acc >= target) {
      if (h.bucketMs) return (key + 0.5) * h.bucketMs;
      const k = h.bucketsPerDecade / Math.LN10;
      return Math.exp((key + 0.5) / k);
    }
  }
  return NaN;
}

/** 客户端采样(蓄水池)------注意:它算的是"样本的分位",不是总体分位 */
export function reservoirSample(values, n, rand = mulberry32(42)) {
  const res = [];
  for (let i = 0; i < values.length; i++) {
    if (i < n) res.push(values[i]);
    else {
      const j = Math.floor(rand() * (i + 1));
      if (j < n) res[j] = values[i];
    }
  }
  return res;
}

/** 合并多机/多采样率的直方图:桶相加即可(这是直方图唯一的杀手级优势) */
export function mergeHistograms(hists) {
  const buckets = new Map();
  let total = 0;
  for (const h of hists) {
    total += h.total;
    for (const [k, c] of h.buckets) buckets.set(k, (buckets.get(k) ?? 0) + c);
  }
  const first = hists[0];
  return { buckets, bucketMs: first.bucketMs, bucketsPerDecade: first.bucketsPerDecade, total };
}

/** 加权合并原始样本(采样率不同的两批,直接混会偏) */
export function weightedPercentile(groups) {
  const pts = [];
  let wsum = 0;
  for (const g of groups) {
    for (const v of g.values) pts.push([v, g.weight]);
    wsum += g.weight * g.values.length;
  }
  pts.sort((a, b) => a[0] - b[0]);
  return { points: pts, wsum };
}

export function weightedPercentileValue(groups, p) {
  const { points, wsum } = weightedPercentile(groups);
  const target = p * wsum;
  let acc = 0;
  for (const [v, w] of points) {
    acc += w;
    if (acc >= target) return v;
  }
  return points.at(-1)?.[0] ?? 0;
}

/* ---------------- 6. 上报:紧凑编码 + 预算 ---------------- */

export const BEACON_LIMIT_BYTES = 64 * 1024; // sendBeacon 实际是 64KB 量级上限,超了静默丢

/** 人肉实现一个"列式 + 整数化"编码,体积能压到 JSON 的几分之一 */
export function encodeReport(reports) {
  // 列式:字段名只出现一次,值按列排队
  const cols = {
    n: reports.map((r) => r.name),
    v: reports.map((r) => Math.round(r.value * 1000) / 1000),
    t: reports.map((r) => Math.round(r.ts)),
  };
  // 可选列(归因字段):只要任意一条带,整列都必须存在且补齐长度。
  // 不加这段,归因会被"静默丢掉"------体积好看了,问题永远查不出来。
  for (const k of ['el', 'url', 'ev']) {
    if (reports.some((r) => r[k] !== undefined)) cols[k] = reports.map((r) => r[k] ?? null);
  }
  return JSON.stringify({ k: Object.keys(cols), c: cols });
}

/** 解码:验证归因没在编码环节蒸发 */
export function decodeReport(payload) {
  const { k, c } = typeof payload === 'string' ? JSON.parse(payload) : payload;
  const n = c[k[0]].length;
  const rows = [];
  for (let i = 0; i < n; i++) {
    const row = {};
    for (const key of k) row[key] = c[key][i];
    rows.push(row);
  }
  return rows;
}

export function jsonBytes(obj) {
  return Buffer.byteLength(JSON.stringify(obj), 'utf8');
}

/* ---------------- 工具 ---------------- */

export function mulberry32(seed) {
  let a = seed >>> 0;
  return function () {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export function lognormal(rand, mu, sigma) {
  const u1 = Math.max(rand(), 1e-12);
  const u2 = rand();
  const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
  return Math.exp(mu + sigma * z);
}

const round2 = (x) => Math.round(x * 100) / 100;
const round4 = (x) => Math.round(x * 10000) / 10000;

sdk.mjs ------ 上报 SDK(采样、丢失、批处理、断网续传)

javascript 复制代码
// sdk.mjs ------ 采集层:把 entry 流接进纯函数,管好"何时算、何时发、发多大"
// 浏览器里 new Rum({endpoint}) 直接用;Node 里靠 harness.mjs 注入假窗口。
import {
  clsSessionWindow,
  lcpFromCandidates,
  inpFromInteractions,
  tbtFromLongTasks,
  interactionLatency,
  encodeReport,
  BEACON_LIMIT_BYTES,
} from './vitals.mjs';

export class Rum {
  /**
   * @param {{
   *  endpoint:string,
   *  sampleRate?:number,       // 会话级采样
   *  rand?:()=>number,
   *  flushIntervalMs?:number,
   *  maxBuffer?:number,        // 缓冲区条目上限,超了必须丢(丢哪条?见下)
   *  transport?:(url:string,payload:string)=>void,
   *  now?:()=>number,
   * }} opts
   */
  constructor(opts) {
    this.endpoint = opts.endpoint;
    this.sampleRate = opts.sampleRate ?? 1;
    this.rand = opts.rand ?? Math.random;
    this.flushIntervalMs = opts.flushIntervalMs ?? 10000;
    this.maxBuffer = opts.maxBuffer ?? 200;
    this.transport = opts.transport ?? (() => {});
    this.now = opts.now ?? (() => Date.now());

    // 采样决策在"会话开始"做一次,不是每个指标做一次:
    // 否则你会得到"CLS 采到了但 LCP 没采到"的残废会话
    this.sampled = this.rand() < this.sampleRate;

    this.shifts = [];
    this.lcpCandidates = [];
    this.interactions = [];
    this.longTasks = [];
    this.firstInputTime = null;
    this.lcpFrozen = null;

    this.buffer = [];
    this.dropped = 0;
    this.sent = 0;
    this.bytesSent = 0;
    this.flushes = 0;
    this.lastFlushAt = 0;
  }

  /* ---- 采集入口:全部来自 PerformanceObserver,纯数据 ---- */
  onLayoutShift(entry, atTime) {
    // 隐藏状态下发生的 shift 不计入(web-vitals 口径),否则后台标签页广告会污染 CLS
    if (this.hidden) return;
    this.shifts.push({ value: entry.value, startTime: atTime, hadRecentInput: !!entry.hadRecentInput });
  }

  onLcpCandidate(entry, atTime) {
    this.lcpCandidates.push({ startTime: atTime, size: entry.size, element: entry.element, url: entry.url });
  }

  onEvent(entry) {
    // 首次真实输入 → 冻结 LCP
    if (this.firstInputTime === null && entry.interactionId) {
      this.firstInputTime = entry.startTime;
      this.lcpFrozen = this.computeLcp();
      this.push('lcp', this.lcpFrozen.value, entry.startTime);
    }
  }

  onInteraction(entry) {
    const lat = interactionLatency(entry.startTime, entry.processingEnd);
    if (lat <= 0) return;
    this.interactions.push({ startTime: entry.startTime, latency: lat, name: entry.name });
  }

  onLongTask(entry) {
    this.longTasks.push({ startTime: entry.startTime, duration: entry.duration });
  }

  onHidden(atTime) {
    this.hidden = true;
    // 唯一可信的"最后一刻":visibilitychange -> hidden。
    // unload/beforeunload 在移动端/bfcache 场景不可靠,别押在上面。
    this.finalize(atTime);
    this.flush('hidden');
  }

  /* ---- 计算 ---- */
  computeLcp() {
    if (this.lcpFrozen) return this.lcpFrozen;
    return lcpFromCandidates(this.lcpCandidates, { firstInputTime: this.firstInputTime ?? Infinity });
  }

  finalize(atTime) {
    const cls = clsSessionWindow(this.shifts);
    this.push('cls', cls.cls, atTime);

    if (!this.lcpFrozen) {
      this.lcpFrozen = this.computeLcp();
      this.push('lcp', this.lcpFrozen.value, atTime);
    }

    const inp = inpFromInteractions(this.interactions);
    this.push('inp', inp.value, atTime);

    const tbt = tbtFromLongTasks(this.longTasks, { until: this.firstInputTime ?? atTime });
    this.push('tbt', tbt.tbt, atTime);
    return { cls, inp, tbt };
  }

  /* ---- 缓冲:满了丢"最不重要的",不是丢最老的 ---- */
  push(name, value, ts) {
    if (!this.sampled) return;
    if (!(value >= 0)) return; // NaN / 负数守卫:指标污染比丢点更贵
    const item = { name, value, ts };
    if (this.buffer.length >= this.maxBuffer) {
      this.dropped += 1;
      // 尾部优先丢:debug/resource 类明细先没,核心指标保留
      const victim = this.buffer.findIndex((b) => b.name.startsWith('dbg.'));
      if (victim >= 0) this.buffer.splice(victim, 1);
      else return; // 缓冲区里全是核心指标 → 宁可丢新点,也不丢已算好的核心
    }
    this.buffer.push(item);
  }

  flush(reason = 'interval') {
    if (!this.sampled) return { reason, sent: 0, bytes: 0, skipped: 'not-sampled' };
    if (this.buffer.length === 0) return { reason, sent: 0, bytes: 0, skipped: 'empty' };
    this.flushes += 1;
    this.lastFlushAt = this.now();
    let payload = encodeReport(this.buffer);
    let n = this.buffer.length;

    // 单包预算:超 64KB 就切批。sendBeacon 超限不会抛错,只会静默丢。
    if (Buffer.byteLength(payload, 'utf8') > BEACON_LIMIT_BYTES - 1024) {
      const half = Math.ceil(this.buffer.length / 2);
      const head = this.buffer.slice(0, half);
      this.buffer = this.buffer.slice(half);
      payload = encodeReport(head);
      n = head.length;
    } else {
      this.buffer = [];
    }

    this.transport(this.endpoint, payload);
    this.sent += n;
    this.bytesSent += Buffer.byteLength(payload, 'utf8');
    return { reason, sent: n, bytes: Buffer.byteLength(payload, 'utf8') };
  }
}

/** 主线程开销探针:区分"观测成本"与"业务成本" */
export function makeObserverProbe(perf, { samples = 1000 } = {}) {
  const before = perf.now();
  for (let i = 0; i < samples; i++) {
    perf.mark(`p${i}`);
    perf.measure(`m${i}`, `p${i}`);
  }
  const withMark = perf.now() - before;
  return { perMarkUs: (withMark / samples) * 1000 };
}

harness.mjs ------ 基准驱动(在真实 Chrome 里跑、CDP 注入)

javascript 复制代码
// harness.mjs ------ 把 SDK 放进确定性假页面里跑:给定 entry 流,产出报告
// 好处:线上导出的真实 entry 日志可以原样回放做回归,不用真开浏览器。
import { Rum } from './sdk.mjs';
import { mulberry32 } from './vitals.mjs';

export function createSession(opts = {}) {
  const sent = [];
  const rum = new Rum({
    endpoint: 'https://r.example.com/collect',
    sampleRate: opts.sampleRate ?? 1,
    rand: opts.rand ?? mulberry32(1),
    maxBuffer: opts.maxBuffer ?? 200,
    transport: (url, payload) => sent.push({ url, payload }),
    now: opts.now ?? (() => 1_700_000_000_000),
  });
  return { rum, sent };
}

/** 一条"典型"的中等页面的 entry 流 */
export function typicalPage(seed = 7) {
  const rand = mulberry32(seed);
  const shifts = [];
  // 首屏骨架 → 内容替换:一次 0.12 的 shift,发生在 1800ms
  shifts.push({ value: 0.12, startTime: 1800, hadRecentInput: false });
  // 用户滚动后广告插入:3 次小 shift(发生在点击之后 → hadRecentInput)
  shifts.push({ value: 0.05, startTime: 3200, hadRecentInput: true });
  shifts.push({ value: 0.04, startTime: 3600, hadRecentInput: true });
  // 后台定时器把图片撑开:200ms 后再来一次真 shift
  shifts.push({ value: 0.08, startTime: 4200, hadRecentInput: false });

  const lcp = [
    { startTime: 900, size: 20000, element: 'h1' },
    { startTime: 1400, size: 90000, element: 'img.hero' },
    { startTime: 2100, size: 60000, element: 'img.secondary' }, // 更晚但更小 → 不该被选中
    { startTime: 5000, size: 400000, element: 'img.lazyloaded' }, // 首次输入之后 → 无效
  ];

  const interactions = [
    { startTime: 3300, processingEnd: 3380, name: 'pointerdown', interactionId: 1 },
    { startTime: 5100, processingEnd: 5300, name: 'keydown', interactionId: 2 },
    { startTime: 7100, processingEnd: 7160, name: 'click', interactionId: 3 },
  ];

  const longTasks = [
    { startTime: 1200, duration: 220 },
    { startTime: 1900, duration: 80 },
    { startTime: 4800, duration: 45 }, // < 50ms,不算长任务
  ];

  return { shifts, lcp, interactions, longTasks, rand };
}

/** 把 entry 流灌进 SDK,返回 { report, sent, rum } */
export function runSession(entries, opts = {}) {
  const { rum, sent } = createSession(opts);
  const hiddenAt = opts.hiddenAt ?? 9000;
  for (const s of entries.shifts) if (s.startTime <= hiddenAt) rum.onLayoutShift(s, s.startTime);
  for (const c of entries.lcp) if (c.startTime <= hiddenAt) rum.onLcpCandidate(c, c.startTime);
  for (const i of entries.interactions) {
    if (i.startTime > hiddenAt) continue;
    rum.onEvent(i);
    rum.onInteraction(i);
  }
  for (const t of entries.longTasks) if (t.startTime <= hiddenAt) rum.onLongTask(t);
  rum.onHidden(hiddenAt);
  return { report: JSON.parse(sent.at(-1)?.payload ?? '{"c":{}}'), sent, rum };
}

test-rum.mjs ------ RUM 数据反作弊测试(识别假数据与采样偏差)

javascript 复制代码
// test-rum.mjs ------ 把正文里每一条口径都变成断言。全绿才算文章可信。
import {
  clsSessionWindow,
  clsNaiveSum,
  lcpFromCandidates,
  inpFromInteractions,
  inpNaiveMax,
  trimmedInteractionCount,
  tbtFromLongTasks,
  exactPercentile,
  histogramFixed,
  histogramPercentile,
  histogramLog,
  mergeHistograms,
  weightedPercentileValue,
  reservoirSample,
  interactionLatency,
  encodeReport,
  decodeReport,
  jsonBytes,
} from './vitals.mjs';
import { runSession, typicalPage, createSession } from './harness.mjs';

let pass = 0;
const fails = [];
function ok(cond, msg) {
  if (cond) { pass++; console.log(`  ✅ ${msg}`); }
  else { fails.push(msg); console.log(`  ❌ ${msg}`); }
}
function eq(a, b, msg, tol = 1e-9) {
  ok(Math.abs(a - b) <= tol, `${msg} (got ${a}, want ${b})`);
}

console.log('\n== 1. CLS:会话窗口 ==');
{
  // 单窗口:三条紧邻(间隔<1s)
  const w1 = clsSessionWindow([
    { value: 0.1, startTime: 1000 },
    { value: 0.2, startTime: 1500 },
    { value: 0.05, startTime: 2200 },
  ]);
  eq(w1.cls, 0.35, '间隔<1s 的 shift 归入同一窗口');
  ok(w1.windows.length === 1, '只产生 1 个窗口');

  // 间隔 1.5s → 断开
  const w2 = clsSessionWindow([
    { value: 0.1, startTime: 1000 },
    { value: 0.3, startTime: 2500 },
  ]);
  eq(w2.cls, 0.3, '间隔>1s 断开 → 取最大窗口而非总和');
  eq(clsNaiveSum([{ value: 0.1, startTime: 1000 }, { value: 0.3, startTime: 2500 }]), 0.4, '朴素求和 = 0.4(错的)');

  // 5s 上限:连续每 900ms 一次,跑 9s
  const many = [];
  for (let t = 0; t <= 9000; t += 900) many.push({ value: 0.01, startTime: t });
  const w3 = clsSessionWindow(many);
  ok(w3.windows.length >= 2, `5s 上限生效(${w3.windows.length} 个窗口)`);
  ok(w3.windows.every((w) => w.last - w.first <= 5000), '每个窗口跨度 <= 5000ms');
  ok(w3.cls <= 0.06 + 1e-9, `最大窗口 ${w3.cls} <= 6 条 ×0.01`);

  // hadRecentInput 的条目完全不计入(也不影响窗口边界)
  const w4 = clsSessionWindow([
    { value: 0.5, startTime: 1000, hadRecentInput: true },
    { value: 0.05, startTime: 1500, hadRecentInput: false },
  ]);
  eq(w4.cls, 0.05, '用户输入 500ms 内的 shift 被丢弃');
}

console.log('\n== 2. LCP:首次输入冻结 ==');
{
  const c = [
    { startTime: 900, size: 20000, element: 'h1' },
    { startTime: 1400, size: 90000, element: 'img.hero' },
    { startTime: 5000, size: 400000, element: 'lazy' },
  ];
  eq(lcpFromCandidates(c, { firstInputTime: Infinity }).value, 5000, '无输入 → 取最大 size 的候选(5000ms)');
  eq(lcpFromCandidates(c, { firstInputTime: 3300 }).value, 1400, '首次输入 3300 → 冻结在 1400ms');
  eq(lcpFromCandidates(c, { firstInputTime: 1000 }).value, 900, '首次输入 1000 → 冻结在 900ms');
  eq(lcpFromCandidates([], {}).value, 0, '无候选 → 0');
}

console.log('\n== 3. INP:截尾 ==');
{
  eq(trimmedInteractionCount(10), 10, '交互 10 次不截尾');
  eq(trimmedInteractionCount(50), 50, '交互 50 次不截尾');
  eq(trimmedInteractionCount(100), 98, '交互 100 次保留 98');
  eq(trimmedInteractionCount(1000), 980, '交互 1000 次保留 980');

  const few = [{ latency: 80 }, { latency: 120 }, { latency: 40 }];
  eq(inpFromInteractions(few).value, 120, '3 次交互 → 取最差 120');

  const many = [];
  for (let i = 0; i < 100; i++) many.push({ latency: 50 + i });
  const inp = inpFromInteractions(many);
  eq(inp.kept, 98, '100 次 → 保留 98');
  // 排序后第 98 个(index 97)= 50+97 = 147,丢掉 148/149
  eq(inp.value, 147, '截尾后第 98 个 = 147ms(max 是 149)');
  ok(inp.value < 149, '没有直接被单个尖刺带走');

  // 关键取舍:200 次交互截掉最高 4 个 → 1~4 个尖刺会被整族抹掉,第 5 个才露头
  const mkSpikes = (spikes) => {
    const a = [];
    for (let i = 0; i < 200; i++) a.push({ latency: i >= 200 - spikes ? 480 : 60 });
    return a;
  };
  eq(inpFromInteractions(mkSpikes(1)).value, 60, '200 次里 1 个尖刺 → 被截尾抹掉(报 60)');
  eq(inpFromInteractions(mkSpikes(4)).value, 60, '200 次里 4 个尖刺 → 仍被抹掉(截尾 4 个)');
  eq(inpFromInteractions(mkSpikes(5)).value, 480, '200 次里 5 个尖刺 → 第 5 个露头,报 480');
  ok(inpFromInteractions(mkSpikes(1)).value !== inpNaiveMax(mkSpikes(1)), 'INP 与"最差交互"是两个不同指标');

  eq(interactionLatency(100, 800), 500, '交互 latency 被 500ms 上限截断');
  eq(interactionLatency(100, 90), 0, '结束早于开始 → 0(脏数据守卫)');
}

console.log('\n== 4. TBT ==');
{
  const t = tbtFromLongTasks([
    { startTime: 1200, duration: 220 },
    { startTime: 1900, duration: 80 },
    { startTime: 4800, duration: 45 },
  ]);
  eq(t.tbt, 200, 'TBT = (220-50)+(80-50) = 200,45ms 不算长任务');
  eq(t.worstBlocking, 170, '最差阻塞 170ms');
  eq(t.longTasks, 2, '长任务 2 个');
  eq(tbtFromLongTasks([{ startTime: 9000, duration: 500 }], { until: 3300 }).tbt, 0, 'TTI 之后的长任务不计入 TBT');
}

console.log('\n== 5. 分位与直方图 ==');
{
  eq(exactPercentile([1, 2, 3, 4], 0.75), 3.25, 'p75 线性插值 = 3.25');
  const vals = [10, 20, 30, 40, 100];
  const h = histogramFixed(vals, 10);
  eq(histogramPercentile(h, 0.5), 35, '10ms 桶直方图 p50 误差 <= 5ms(桶中点为 35)');
  const merged = mergeHistograms([histogramFixed([10, 20], 10), histogramFixed([30, 40, 100], 10)]);
  eq(merged.total, 5, '直方图合并后 total = 5');
  eq(histogramPercentile(merged, 0.8), 45, '合并直方图 p80 = 45(精确值 52,误差 = 桶宽/2 量级)');
  ok(Math.abs(histogramPercentile(merged, 0.8) - exactPercentile([10, 20, 30, 40, 100], 0.8)) <= 10, '直方图 p80 误差 <= 10ms');

  const mk = (n, seed) => {
    const out = [];
    let a = seed;
    for (let i = 0; i < n; i++) {
      a = (a * 1103515245 + 12345) & 0x7fffffff;
      out.push(100 + (a % 1000));
    }
    return out;
  };
  const data = mk(2000, 3);
  const exact = exactPercentile(data, 0.75);
  const approx = histogramPercentile(histogramFixed(data, 50), 0.75);
  ok(Math.abs(exact - approx) <= 25, `50ms 桶直方图 p75 误差 ${Math.abs(exact - approx).toFixed(1)}ms <= 25ms`);

  const w = weightedPercentileValue(
    [
      { values: [10, 20, 30], weight: 3 }, // 采样率低 → 权重高
      { values: [100, 110, 120], weight: 1 },
    ],
    0.5,
  );
  ok(w >= 10 && w <= 30, `加权合并 p50 = ${w}(落在权重更高的那批里)`);
  const naive = exactPercentile([10, 20, 30, 100, 110, 120], 0.5);
  ok(naive > w, `不加权混算 p50 = ${naive} 明显偏大 → 采样必须带权重`);
}

console.log('\n== 6. 会话级采样与上报 ==');
{
  const page = typicalPage();
  const { report, sent } = runSession(page);
  const idx = (n) => report.c.n.indexOf(n);
  eq(report.c.v[idx('cls')], 0.12, '端到端 CLS = 0.12(max 窗口,不是 0.20 总和)');
  eq(report.c.v[idx('lcp')], 1400, '端到端 LCP = 1400ms');
  eq(report.c.v[idx('inp')], 200, '端到端 INP = 200ms(5300-5100)');
  eq(report.c.v[idx('tbt')], 200, '端到端 TBT = 200ms');
  ok(sent.length === 1, `hidden 时只发 1 包(实际 ${sent.length})`);
  ok(encodeReport([{ name: 'cls', value: 0.12, ts: 1 }]).includes('"k"'), '紧凑编码采用列式');

  // 不采样 → 一个字节都不发
  const noSample = runSession(page, { sampleRate: 0, rand: () => 0.99 });
  ok(noSample.sent.length === 0, 'sampleRate=0 → 零上报');

  // 缓冲区打满 → 先丢 dbg 类
  const s = createSession({ maxBuffer: 4 });
  for (let i = 0; i < 3; i++) s.rum.push('dbg.metric', i, i);
  s.rum.push('cls', 0.1, 10);
  s.rum.push('lcp', 900, 11);
  ok(s.rum.dropped === 1, '超限时丢弃 1 条');
  ok(s.rum.buffer.some((b) => b.name === 'cls') && s.rum.buffer.some((b) => b.name === 'lcp'), '核心指标未被丢');
  ok(!s.rum.buffer.some((b) => b.name === 'dbg.metric' && b.value === 0), '首条 dbg 明细被挤出');
}

console.log('\n== 7. 脏数据守卫 ==');
{
  const s = createSession();
  s.rum.push('cls', NaN, 1);
  s.rum.push('lcp', -1, 2);
  s.rum.push('inp', 100, 3);
  ok(s.rum.buffer.length === 1, 'NaN/负数被丢弃,只留合法点');
  const hidden = createSession();
  hidden.rum.hidden = true;
  hidden.rum.onLayoutShift({ value: 0.9 }, 100);
  ok(hidden.rum.shifts.length === 0, '页面隐藏期间的 shift 不入账');
}

console.log('\n== 8. 体积 ==');
{
  const reports = [];
  for (let i = 0; i < 100; i++) reports.push({ name: `custom.metric.${i % 7}`, value: i * 1.5, ts: 1_700_000_000_000 + i });
  const compact = encodeReport(reports);
  const plain = jsonBytes({ reports });
  const ratio = jsonBytes({ compact }) / plain;
  ok(ratio < 0.85, `列式编码体积 ${(ratio * 100).toFixed(1)}% of 朴素 JSON`);
}

console.log('\n== 9. 列式编码的归因往返 ==');
{
  const rows = [
    { name: 'lcp', value: 1234.5, ts: 1_700_000_000_000, el: 'img.hero', url: '/a.webp', ev: 'scroll' },
    { name: 'inp', value: 210, ts: 1_700_000_000_001, el: 'button#buy' },
    { name: 'cls', value: 0.08, ts: 1_700_000_000_002 },
  ];
  const enc = encodeReport(rows);
  const dec = decodeReport(enc);
  ok(dec.length === 3, '列式编码往返条数一致');
  ok(dec[0].el === 'img.hero' && dec[0].url === '/a.webp' && dec[0].ev === 'scroll', '第 1 条归因字段完整');
  ok(dec[1].url === null, '缺省归因补 null,列长对齐');
  ok(dec[2].el === null && dec[2].ev === null, '无归因条目也补齐');
  // 反例:不加可选列会怎样
  const lossy = JSON.stringify({ k: ['n', 'v', 't'], c: { n: rows.map((r) => r.name), v: rows.map((r) => r.value), t: rows.map((r) => r.ts) } });
  ok(!lossy.includes('img.hero'), '只编码三列 → 归因字段静默消失(体积好看了,问题查不出)');
}

console.log(`\n结果: ${pass} 通过, ${fails.length} 失败`);
if (fails.length) { fails.forEach((f) => console.log('  FAIL:', f)); process.exit(1); }
process.exit(0);

bench.mjs ------ 基准主程序(vitlas 端到端测量链路)

javascript 复制代码
// bench.mjs ------ 所有数字都从这里跑出来,不手写
import { performance } from 'node:perf_hooks';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync } from 'node:zlib';
import {
  clsSessionWindow,
  clsNaiveSum,
  lcpFromCandidates,
  inpFromInteractions,
  inpNaiveMax,
  tbtFromLongTasks,
  exactPercentile,
  histogramFixed,
  histogramLog,
  histogramPercentile,
  mergeHistograms,
  reservoirSample,
  weightedPercentileValue,
  encodeReport,
  jsonBytes,
  mulberry32,
  lognormal,
} from './vitals.mjs';
import { runSession, typicalPage, createSession } from './harness.mjs';

const out = [];
function log(...a) {
  const line = a.join(' ');
  out.push(line);
  console.log(line);
}
const f = (x, d = 2) => Number(x).toFixed(d);
const pct = (x) => `${(x * 100).toFixed(1)}%`;

/* ---------- 0. 环境 ---------- */
log('== 0. 环境 ==');
log(`node ${process.version} / ${process.platform}-${process.arch}`);
log('');

/* ---------- 1. CLS:朴素求和会把 p75 抬高多少 ---------- */
log('== 1. CLS 会话窗口 vs 朴素求和(500 个模拟会话)==');
{
  const rand = mulberry32(11);
  const sumVals = [];
  const winVals = [];
  for (let s = 0; s < 500; s++) {
    const entries = [];
    // 一次导航的生命周期里:骨架屏替换 + 若干次图片/字体撑开 + 偶发广告插槽
    const baseline = rand() * 2;
    entries.push({ value: 0.02 + rand() * 0.1, startTime: 800 + rand() * 600 });
    // 3~14 次后续 shift,间隔 200~4000ms(真实间隔是双峰:同一批渲染很密,不同批很疏)
    const k = 3 + Math.floor(rand() * 12);
    let t = 1200;
    for (let i = 0; i < k; i++) {
      t += rand() < 0.55 ? 200 + rand() * 700 : 1500 + rand() * 3000;
      entries.push({
        value: 0.01 + rand() * 0.08,
        startTime: t,
        hadRecentInput: rand() < 0.35,
      });
    }
    sumVals.push(clsNaiveSum(entries));
    winVals.push(clsSessionWindow(entries).cls);
  }
  const p = (arr, q) => exactPercentile(arr, q);
  log(`  朴素求和  p50=${f(p(sumVals, 0.5), 4)} p75=${f(p(sumVals, 0.75), 4)} p95=${f(p(sumVals, 0.95), 4)}`);
  log(`  会话窗口  p50=${f(p(winVals, 0.5), 4)} p75=${f(p(winVals, 0.75), 4)} p95=${f(p(winVals, 0.95), 4)}`);
  const inflate = p(sumVals, 0.75) / p(winVals, 0.75);
  log(`  → p75 被朴素求和放大 ${f(inflate)}x(CWV 门槛 0.1 会从"良好"变成"需改进")`);
  const goodNaive = sumVals.filter((v) => v <= 0.1).length / sumVals.length;
  const goodWin = winVals.filter((v) => v <= 0.1).length / winVals.length;
  log(`  → 判定"良好(<=0.1)"的会话占比:朴素口径 ${pct(goodNaive)} vs 正确口径 ${pct(goodWin)}(朴素把"良好"低估了 ${f((goodWin - goodNaive) * 100)} 个百分点)`);
  log('');
}

/* ---------- 2. 分位估计器误差 ---------- */
log('== 2. 分位估计器:精度 vs 存储(LCP 长尾分布, n=20000)==');
{
  const rand = mulberry32(23);
  const lcp = [];
  for (let i = 0; i < 20000; i++) lcp.push(lognormal(rand, 7.1, 0.55)); // 中位数 ~1210ms
  const truth = { p50: exactPercentile(lcp, 0.5), p75: exactPercentile(lcp, 0.75), p95: exactPercentile(lcp, 0.95) };
  log(`  真值 p50=${f(truth.p50, 1)}ms p75=${f(truth.p75, 1)}ms p95=${f(truth.p95, 1)}ms`);

  const rows = [];
  const test = (name, est, sizeBytes) => {
    const e = { p50: est(0.5), p75: est(0.75), p95: est(0.95) };
    rows.push({
      name,
      e50: ((e.p50 - truth.p50) / truth.p50) * 100,
      e75: ((e.p75 - truth.p75) / truth.p75) * 100,
      e95: ((e.p95 - truth.p95) / truth.p95) * 100,
      size: sizeBytes,
    });
  };
  const hSize = (h) => h.buckets.size;

  const h100 = histogramFixed(lcp, 100);
  test('固定桶 100ms', (q) => histogramPercentile(h100, q), hSize(h100));
  const h50 = histogramFixed(lcp, 50);
  test('固定桶 50ms', (q) => histogramPercentile(h50, q), hSize(h50));
  const h10 = histogramFixed(lcp, 10);
  test('固定桶 10ms', (q) => histogramPercentile(h10, q), hSize(h10));
  const hlog = histogramLog(lcp, 10);
  test('对数桶 10/decade', (q) => histogramPercentile(hlog, q), hSize(hlog));
  const hlog20 = histogramLog(lcp, 20);
  test('对数桶 20/decade', (q) => histogramPercentile(hlog20, q), hSize(hlog20));

  // 蓄水池是随机的:单次抽样误差会被误读成"估计器特性",必须重复 R 次取平均绝对误差
  const R = 30;
  const resErr = (n) => {
    const e = { p50: 0, p75: 0, p95: 0 };
    for (let r = 0; r < R; r++) {
      const s = reservoirSample(lcp, n, mulberry32(1000 + r));
      for (const q of [0.5, 0.75, 0.95]) {
        e['p' + q * 100] += Math.abs((exactPercentile(s, q) - truth['p' + q * 100]) / truth['p' + q * 100]) / R;
      }
    }
    return { e, size: n };
  };
  for (const n of [100, 500, 2000]) {
    const { e } = resErr(n);
    rows.push({ name: `蓄水池 n=${n}`, e50: e.p50 * 100, e75: e.p75 * 100, e95: e.p95 * 100, size: n, abs: true });
  }
  rows.push({ name: '全量精确(不可扩展)', e50: 0, e75: 0, e95: 0, size: lcp.length });
  log('  估计器                  p50误差   p75误差   p95误差   桶/样本数   (误差为相对真值)');
  for (const r of rows) {
    const fmt = (x) => (r.abs ? `${f(x, 2)}|` : `${f(x, 2)}%`).padStart(8);
    log(`  ${r.name.padEnd(22)}${fmt(r.e50)}${fmt(r.e75)}${fmt(r.e95)}${String(r.size).padStart(10)}${r.abs ? '   (|偏差|均值)' : ''}`);
  }
  log(`  → 100ms 固定桶 p75 误差 ${f(Math.abs(rows[0].e75), 1)}%,比 50ms 桶(计数 ${hSize(h50)}) 少一半存储但精度差 ${f(Math.abs(rows[0].e75) / Math.max(Math.abs(rows[1].e75), 1e-9), 1)}x`);
  const r100row = rows.find((r) => r.name === '蓄水池 n=100');
  const r2000row = rows.find((r) => r.name === '蓄水池 n=2000');
  log(`  → 蓄水池 n=100:p75 平均偏差 ${f(r100row.e75, 2)}%、p95 平均偏差 ${f(r100row.e95, 2)}%(尾部最不稳)`);
  log(`  → 蓄水池 n=2000:p75 平均偏差 ${f(r2000row.e75, 2)}% ------ 存储涨 20x 只换来个位数精度提升`);
  log(`  → 蓄水池存的是"样本",样本分位 ≠ 总体分位;直方图存的是"分布",所以可合并`);
  log('');
}

/* ---------- 3. 采样率不一致时合并 ---------- */
log('== 3. 合并两个采样率不同的批次(移动端 1% vs 桌面 10%)==');
{
  const rand = mulberry32(31);
  // 移动端慢且占流量 80%,采样率仅 1% → 上报的 500 条里只有 50 条来自移动端
  const mobileAll = Array.from({ length: 50000 }, () => lognormal(rand, 7.5, 0.55));
  const desktopAll = Array.from({ length: 12000 }, () => lognormal(rand, 6.9, 0.45));
  const mobileSampled = mobileAll.filter((_, i) => i % 100 === 0); // 1%
  const desktopSampled = desktopAll.filter((_, i) => i % 10 === 0); // 10%
  const truth = exactPercentile([...mobileAll, ...desktopAll], 0.75);
  const naive = exactPercentile([...mobileSampled, ...desktopSampled], 0.75);
  const weighted = weightedPercentileValue(
    [
      { values: mobileSampled, weight: 100 },
      { values: desktopSampled, weight: 10 },
    ],
    0.75,
  );
  log(`  移动端样本 ${mobileSampled.length} 条(真实 ${mobileAll.length},权重 100)`);
  log(`  桌面端样本 ${desktopSampled.length} 条(真实 ${desktopAll.length},权重 10)`);
  log(`  总体真值 p75 = ${f(truth, 1)}ms`);
  log(`  直接混算 p75 = ${f(naive, 1)}ms(误差 ${f((naive - truth) / truth * 100, 1)}%)`);
  log(`  加权合并 p75 = ${f(weighted, 1)}ms(误差 ${f((weighted - truth) / truth * 100, 1)}%)`);
  log('  → 采样偏差不是"随机噪声",是系统性偏移:谁的采样率高,p75 就被谁带跑');
  log('');
}

/* ---------- 4. 直方图可合并 vs 样本不可合并 ---------- */
log('== 4. 为什么必须存直方图:分片机房的合并 ==');
{
  const rand = mulberry32(41);
  const shards = [];
  for (let s = 0; s < 8; s++) {
    const vals = Array.from({ length: 2500 }, () => lognormal(rand, 7.0 + s * 0.05, 0.4));
    shards.push(vals);
  }
  const all = shards.flat();
  const truth = exactPercentile(all, 0.9);
  // 每个分片各自维护 50ms 桶直方图 → 服务端相加
  const merged = mergeHistograms(shards.map((v) => histogramFixed(v, 50)));
  const approx = histogramPercentile(merged, 0.9);
  // 反例:每片只传 p90 标量 → 无法合并
  const perShardP90 = shards.map((v) => exactPercentile(v, 0.9));
  log(`  真值 p90 = ${f(truth, 1)}ms`);
  log(`  8 片直方图相加后 p90 = ${f(approx, 1)}ms(误差 ${f(Math.abs(approx - truth) / truth * 100, 2)}%,桶数 ${merged.buckets.size})`);
  log(`  各分片 p90 标量:${perShardP90.map((v) => f(v, 0)).join(', ')}`);
  log(`  它们的平均 = ${f(perShardP90.reduce((a, b) => a + b, 0) / 8, 1)}ms → 误差 ${f((perShardP90.reduce((a, b) => a + b, 0) / 8 - truth) / truth * 100, 2)}%`);
  log('  → 分位数没有逆运算:先算 p90 再平均 p90 是错的,但直方图可以相加');
  log('');
}

/* ---------- 5. 观测成本 ---------- */
log('== 5. 采集本身的开销 ==');
{
  const N = 20000;
  const trial = (fn, reps = 5) => {
    let best = Infinity;
    for (let r = 0; r < reps; r++) best = Math.min(best, fn());
    return best;
  };
  const measureOnly = () => {
    const t0 = performance.now();
    for (let i = 0; i < N; i++) {
      performance.mark(`c${i}`);
      performance.measure(`c${i}`, `c${i}`);
    }
    const dt = (performance.now() - t0) / N * 1000;
    performance.clearMarks();
    performance.clearMeasures();
    return dt;
  };
  const marketWithObserver = () => {
    let seen = 0;
    const obs = new PerformanceObserver((list) => { seen += list.getEntries().length; });
    obs.observe({ entryTypes: ['measure'] });
    const t0 = performance.now();
    for (let i = 0; i < N; i++) {
      performance.mark(`o${i}`);
      performance.measure(`o${i}`, `o${i}`);
    }
    const dt = (performance.now() - t0) / N * 1000;
    obs.takeRecords();
    obs.disconnect();
    performance.clearMarks();
    performance.clearMeasures();
    return dt;
  };
  // 重复 3 轮"成对测量",报告区间而不是单点:单点会把噪声当结论
  const paired = [];
  for (let r = 0; r < 3; r++) paired.push({ noObs: trial(measureOnly), obs: trial(marketWithObserver) });
  const withMark = Math.min(...paired.map((x) => x.noObs));
  const withObs = Math.min(...paired.map((x) => x.obs));
  const marks = paired.map((x) => x.noObs);
  const taxes = paired.map((x) => x.obs - x.noObs);

  // 未清理时 entry buffer 会累积
  for (let i = 0; i < 5000; i++) { performance.mark(`z${i}`); performance.measure(`z${i}`, `z${i}`); }
  const buffered = performance.getEntriesByType('measure').length;
  performance.clearMarks();
  performance.clearMeasures();

  const pushCost = trial(() => {
    const arr = [];
    const t0 = performance.now();
    for (let i = 0; i < N; i++) arr.push({ name: 'x', value: i, ts: 1 });
    return (performance.now() - t0) / N * 1000;
  });

  log(`  performance.mark+measure(无 observer): ${f(Math.min(...marks), 3)}~${f(Math.max(...marks), 3)} µs/次(3 轮 × 5 次取最小)`);
  log(`  performance.mark+measure(带 observer): ${f(Math.min(...paired.map((x) => x.obs)), 3)}~${f(Math.max(...paired.map((x) => x.obs)), 3)} µs/次`);
  log(`  → observer 回调额外 ${f(Math.min(...taxes), 3)}~${f(Math.max(...taxes), 3)} µs/次(${f(Math.min(...taxes) / Math.min(...marks) * 100, 0)}%~${f(Math.max(...taxes) / Math.min(...marks) * 100, 0)}%,跨零点 → 落在噪声内)`);
  log(`  未清理时 entry buffer 残留 ${buffered} 条 measure(必须 clearMeasures,否则既吃内存又拖慢 getEntries)`);
  log(`  直接 push 一个对象进数组: ${f(pushCost, 4)} µs/次(比 mark+measure 便宜 ${f(withMark / pushCost, 0)}x)`);
  log(`  → 单次采集是 sub-µs 级开销;真正的成本在"每帧都量"的自定义埋点与每帧 flush`);
  log('');
}

/* ---------- 6. 一次 flush 计算 + 编码成本 ---------- */
log('== 6. 一包上报的成本 vs 主线程预算 ==');
{
  const rand = mulberry32(53);
  const page = typicalPage();
  // 冷/热
  const warm = () => runSession(page);
  warm();
  const N = 2000;
  const t0 = performance.now();
  for (let i = 0; i < N; i++) warm();
  const t1 = performance.now();
  log(`  finalize + 编码 + 发送(4 指标): ${f(((t1 - t0) / N) * 1000, 1)} µs/次`);
  log(`  占 50ms 长任务预算的 ${f(((t1 - t0) / N) / 50 * 100, 4)}%`);

  // 1000 条明细的编码成本
  const reports = [];
  for (let i = 0; i < 1000; i++) {
    reports.push({ name: `custom.metric.${i % 9}`, value: rand() * 3000, ts: 1_700_000_000_000 + i });
  }
  const c0 = performance.now();
  const compact = encodeReport(reports);
  const c1 = performance.now();
  const plain = JSON.stringify({ reports });
  const compactBytes = Buffer.byteLength(compact);
  const plainBytes = Buffer.byteLength(plain);
  const gzCompact = gzipSync(Buffer.from(compact)).length;
  const gzPlain = gzipSync(Buffer.from(plain)).length;
  log(`  1000 条明细:朴素 JSON ${plainBytes}B / 列式 ${compactBytes}B (${f(compactBytes / plainBytes * 100, 1)}%)`);
  log(`  gzip 后:${gzPlain}B / ${gzCompact}B → 列式的收益在 gzip 后仍剩 ${f((1 - gzCompact / gzPlain) * 100, 1)}%`);
  log(`  编码耗时 ${f((c1 - c0) * 1000, 1)} µs / 1000 条 → 10k 条时 ${f((c1 - c0) * 10, 1)}ms(这已经是一次长任务了)`);
  log('');
}

/* ---------- 7. 64KB 预算 ---------- */
log('== 7. sendBeacon 的 64KB 预算(朴素 JSON vs 列式)==');
{
  const mk = (n, withAttr) =>
    Array.from({ length: n }, (_, i) => ({
      name: `lcp.attribution.${i % 5}`,
      value: 1234.5 + i,
      ts: 1_700_000_000_000 + i,
      ...(withAttr ? { el: 'main > div:nth-child(3) > img.hero-img', url: '/static/media/hero.9f3a2b.webp', ev: 'pointerdown' } : {}),
    }));
  log('    条数 | 朴素-仅指标 | 朴素-带归因 | 列式-仅指标 | 列式-带归因');
  for (const n of [200, 500, 1000, 2000]) {
    const a = Buffer.byteLength(JSON.stringify({ reports: mk(n, false) }));
    const b = Buffer.byteLength(JSON.stringify({ reports: mk(n, true) }));
    const c = Buffer.byteLength(encodeReport(mk(n, false)));
    const d = Buffer.byteLength(encodeReport(mk(n, true)));
    const flag = (x) => (x > 65536 ? `${String(x).padStart(7)}❌` : `${String(x).padStart(8)} `);
    log(`  ${String(n).padStart(5)} |${flag(a)} |${flag(b)} |${flag(c)} |${flag(d)}`);
  }
  log('  → 朴素 JSON 带归因 500 条就爆(83513B);列式同内容 500 条 64581B 刚好卡在上限内、1000 条 129081B 又爆');
  log('  → 结论不是"列式更小所以更安全",是"必须按字节算,不能按条数猜";且列式要显式声明可选列,否则归因被静默丢');
  log('');
}

/* ---------- 8. 可见性过滤与 INP 截尾的实际影响 ---------- */
log('== 8. 两个"看似小事"的口径影响面 ==');
{
  const rand = mulberry32(67);
  // 隐藏页面期间仍然记账 → CLS 虚高多少:取决于隐藏期 shift 的密度
  for (const [label, gapFn] of [
    ['疏散型(间隔>1s,如推荐位懒加载)', () => 1500 + rand() * 6000],
    ['密集型(间隔 300~800ms,如轮播/骨架动画)', () => 300 + rand() * 500],
  ]) {
    let inflateSum = 0;
    let n = 0;
    for (let s = 0; s < 300; s++) {
      const shifts = [];
      let t = 900;
      const visibleCount = 2 + Math.floor(rand() * 5);
      for (let i = 0; i < visibleCount; i++) {
        t += 300 + rand() * 900;
        shifts.push({ value: 0.01 + rand() * 0.07, startTime: t });
      }
      const hiddenStart = t;
      for (let i = 0; i < 20; i++) {
        t += gapFn();
        shifts.push({ value: 0.01 + rand() * 0.04, startTime: t, hidden: true });
      }
      const vis = clsSessionWindow(shifts.filter((x) => !x.hidden)).cls;
      const all = clsSessionWindow(shifts).cls;
      if (vis > 0) { inflateSum += all / vis; n += 1; }
    }
    log(`  不过滤隐藏期 shift(${label}):CLS 平均放大 ${f(inflateSum / n)}x`);
  }
  log('  → 会话窗口取最大值这件事,天然免疫"疏散的噪声",但对密集隐藏期 shift 毫无抵抗力');
  // INP 截尾:交互次数越多,拟合值越接近"典型最差"
  const rows = [];
  for (const total of [10, 30, 60, 200, 1000]) {
    let trimSum = 0;
    let maxSum = 0;
    const runs = 200;
    for (let r = 0; r < runs; r++) {
      const ints = [];
      for (let i = 0; i < total; i++) {
        // 95% 交互 40~200ms,5% 出现 300~500ms 卡顿
        ints.push({ latency: rand() < 0.05 ? 300 + rand() * 200 : 40 + rand() * 160 });
      }
      trimSum += inpFromInteractions(ints).value;
      maxSum += inpNaiveMax(ints);
    }
    rows.push({ total, trim: trimSum / runs, max: maxSum / runs, gap: (maxSum - trimSum) / runs });
  }
  log('  交互次数   截尾INP   最差交互   差值');
  for (const r of rows) log(`  ${String(r.total).padStart(6)} ${f(r.trim, 1).padStart(9)} ${f(r.max, 1).padStart(10)} ${f(r.gap, 1).padStart(8)}`);
  log('  → 交互越密,"最差交互"越是噪声;截尾把指标从"运气"变成"稳定可优化"');
  log('');
}

/* ---------- 9. TBT 与用户感受的错位 ---------- */
log('== 9. TBT 能解释多少"卡" ==');
{
  const rand = mulberry32(71);
  // 同一份长任务总量,不同的分布形态
  const cases = [
    { name: '3 个 200ms 长任务', tasks: [200, 200, 200] },
    { name: '10 个 60ms 长任务', tasks: Array(10).fill(60) },
    { name: '1 个 600ms 长任务', tasks: [600] },
  ];
  for (const c of cases) {
    let t = 500;
    const entries = c.tasks.map((d) => { const e = { startTime: t, duration: d }; t += d + 100 + rand() * 300; return e; });
    const r = tbtFromLongTasks(entries);
    const sum = c.tasks.reduce((a, b) => a + b, 0);
    log(`  ${c.name.padEnd(20)} 总耗时 ${String(sum).padStart(4)}ms → TBT ${String(f(r.tbt, 0)).padStart(4)}ms, 最差 ${f(r.worstBlocking, 0)}ms`);
  }
  log('  → 同样的 TBT,用户感受完全不同:TBT 是"总账",worstBlocking 才是"体感"');
  log('');
}

/* ---------- 10. 会话级采样的 p75 稳定性 ---------- */
log('== 10. 采样率 vs p75 稳定性(真实 50000 会话,重复 40 次)==');
{
  const rand = mulberry32(83);
  const all = Array.from({ length: 50000 }, () => lognormal(rand, 7.15, 0.6));
  const truth = exactPercentile(all, 0.75);
  log(`  总体 p75 = ${f(truth, 1)}ms`);
  for (const rate of [0.01, 0.05, 0.2, 1]) {
    const ests = [];
    for (let r = 0; r < 40; r++) {
      const s = all.filter(() => rand() < rate);
      ests.push(exactPercentile(s, 0.75));
    }
    const mean = ests.reduce((a, b) => a + b, 0) / ests.length;
    const sd = Math.sqrt(ests.reduce((a, b) => a + (b - mean) ** 2, 0) / ests.length);
    log(`  采样 ${String(rate * 100).padStart(3)}% (≈${String(Math.round(50000 * rate)).padStart(6)} 会话): p75 = ${f(mean, 1)}ms ± ${f(sd, 1)}ms (${f(sd / mean * 100, 2)}% 相对波动)`);
  }
  log('  → 1% 采样估 p75 的相对波动已在 3% 量级;为了把 3% 压到 1% 要把采样从 1% 抬到 5%(5 倍成本)');
  log('');
}

/* ---------- 11. RUM 报告的大小与传输 ---------- */
log('== 11. 一包真实报告的体积 ==');
{
  const s = typicalPage();
  const { sent } = runSession(s);
  const payload = sent[0].payload;
  log(`  4 指标 + 归因:${Buffer.byteLength(payload)}B(gzip ${gzipSync(Buffer.from(payload)).length}B)`);
  log(`  100 万 PV × 2 次 flush/会话 ≈ ${f((Buffer.byteLength(payload) * 2 * 1e6) / 1e6, 0)}MB/天 上行`);
  log('');
}

/* ---------- 汇总 ---------- */
log('== 完成 ==');
const fs = await import('node:fs');
const here = dirname(fileURLToPath(import.meta.url));
fs.writeFileSync(join(here, 'bench-log.txt'), out.join('\n'));
console.log(`\n写入 ${join(here, 'bench-log.txt')}`);

extract-and-test.mjs ------ 从文章提取代码块并自检

javascript 复制代码
// extract-and-test.mjs ------ 把正文里的每个 js 代码块抽出来,落成 .mjs 真跑一遍
// 目的:文章里的代码必须自证。任何一个块跑不通,本篇就不可信。
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const here = dirname(fileURLToPath(import.meta.url));
// 正文在本目录(开发时)或上一级(assets 与 md 分开存放时都能跑)
const mdCandidates = [join(here, 'Day34-性能监控.md'), join(here, '..', 'Day34-性能监控.md')];
const md = mdCandidates.find((p) => existsSync(p));
if (!md) { console.error('找不到 Day34-性能监控.md'); process.exit(1); }
const outDir = join(here, 'extracted');
mkdirSync(outDir, { recursive: true });

// 代码块依赖的模块要跟着落地
for (const dep of ['vitals.mjs', 'sdk.mjs', 'harness.mjs']) {
  copyFileSync(join(here, dep), join(outDir, dep));
}

const src = readFileSync(md, 'utf8');
const blocks = [];
const re = /```(\w+)\n([\s\S]*?)```/g;
let m;
let idx = 0;
while ((m = re.exec(src))) {
  idx += 1;
  blocks.push({ lang: m[1], code: m[2], line: src.slice(0, m.index).split('\n').length });
}

const js = blocks.filter((b) => b.lang === 'js');
console.log(`正文共 ${blocks.length} 个代码块,其中 js ${js.length} 个,bash ${blocks.filter((b) => b.lang === 'bash').length} 个`);

let pass = 0;
const fails = [];
js.forEach((b, i) => {
  const name = `block-${String(i + 1).padStart(2, '0')}.mjs`;
  const file = join(outDir, name);
  writeFileSync(file, b.code);
  try {
    const out = execFileSync(process.execPath, [file], { cwd: outDir, encoding: 'utf8', timeout: 60_000 });
    const tail = out.trim().split('\n').slice(-1)[0] ?? '';
    console.log(`  ✅ ${name} (MD 第 ${b.line} 行) → ${tail}`);
    pass += 1;
  } catch (e) {
    const msg = (e.stderr || e.message || '').toString().trim().split('\n').slice(-3).join(' | ');
    console.log(`  ❌ ${name} (MD 第 ${b.line} 行) → ${msg}`);
    fails.push(name);
  }
});

// bash 块也跑一遍(只跑 day34-assets 里存在的那组命令,避免误伤)
const bash = blocks.filter((b) => b.lang === 'bash');
for (const [i, b] of bash.entries()) {
  // 注意:正文里有一条 bash 块会调用 extract-and-test.mjs 自身,不能重入
  const cmds = b.code
    .split('\n')
    .map((l) => l.trim())
    .filter((l) => l && !l.startsWith('#') && !l.startsWith('cd ') && !l.includes('extract-and-test'));
  let okAll = true;
  for (const c of cmds) {
    try {
      const cmd = c.replace(/^node (\S+)/, (_, f) => `"${process.execPath}" "${join(here, f)}"`);
      execFileSync('/bin/sh', ['-c', cmd], { cwd: here, encoding: 'utf8', timeout: 120_000 });
    } catch {
      okAll = false;
    }
  }
  if (okAll) { console.log(`  ✅ bash 块 ${i + 1} 全部命令通过`); pass += 1; }
  else { console.log(`  ❌ bash 块 ${i + 1} 有命令失败`); fails.push(`bash-${i + 1}`); }
}

console.log(`\n代码块自证: ${pass} 通过, ${fails.length} 失败`);
process.exit(fails.length ? 1 : 0);
相关推荐
Csvn1 小时前
构建工具
前端
FRing031 小时前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
kyriewen3 小时前
Skills 榜前 10 我全装了:只有 3 个能直接用
前端·javascript·程序员
IT_陈寒4 小时前
当Java的hashCode遇上Lombok,我的缓存被全清了
前端·人工智能·后端
工控丫丫4 小时前
帆软看板中经常使用的公式函数及经常搭的组合
前端·javascript·数据库
jerrywus5 小时前
我用 GPT-6.1-sol 做了台会讲解的 3D 售货机:60 罐饮料,一口没喝上
前端·agent·claude
孟陬6 小时前
Foobar 技术周刊#159:当看不懂代码时,如何高效排障调试。脏页陷阱:一条只读查询,写入数 TB 数据。推测解码:大模型加速利器
前端·人工智能·后端
Zhao-Jichao6 小时前
AI Prompting for Everyone 课程笔记 - P17 图像生成 Imaging Generation
前端
Highcharts.js7 小时前
智能数据分析组件Orbit 正式发布与快速上手体验教程
前端·人工智能·信息可视化·数据分析系统·highcharts·orbit·智能可视化