先说一个几乎每个人都遇到过的场景。
你在本地跑了一次 Lighthouse,LCP 从 3.2s 降到 2.6s,涨了将近 20%。你信心满满地发了版。一周后线上监控的 p75 一动不动,甚至微微上涨。
问题不在于优化没做对,而在于**「一次测量」根本不能证明任何事**。FCP/LCP 是高方差指标,受设备、网络、缓存、CPU 调度影响。想稳定地判断一次优化是否真的带来提升,需要一套方法论。这篇文章把它讲清楚。
为什么单次数据不可信
FCP/LCP 的分布是严重右偏的:大多数请求集中在中位数附近,但有一条很长的尾巴。这意味着两件事。
第一,平均值会被长尾拖高。假设 100 次 LCP 里有 95 次在 2.0s 左右,5 次因为 GC 或后台任务跑到 8s,平均下来直接到 2.3s。你看到的「提升」,可能只是另一组里离群点少了几次。
第二,噪声量级很大。同一份代码、同一台机器、同一个页面,连续跑 5 次 Lighthouse,LCP 波动 ±15% 很常见。一次 A/B 的差值如果只有 10%,它完全落在噪声里。
所以判断优化的第一原则是:不看单个数字,看分布和统计显著性。
先分清 Lab 和 Field
这两种数据解决的是完全不同的问题,混用是常见的错误来源。
| Lab 实验室 | Field 真实用户 | |
|---|---|---|
| 数据来源 | Lighthouse、WebPageTest、CDP tracing | web-vitals 上报、CrUX |
| 特点 | 可控、可复现、单次噪声大 | 真实但混杂设备/网络差异 |
| 回答的问题 | 这次改动有没有引入回归? | 用户整体体验有没有变好? |
| 权威性 | 解释原因 | 判定结论 |
一句话:Lab 用来定位和防回归,Field 用来下结论。 你的优化最终有没有用,看 Field。
Lab 侧:把噪声压下去
Lab 的价值在于可控。用不好,就只剩噪声。
1. 用脚本跑,不要手点 Performance 面板
手动测 100 次既费时又引入操作误差。用 CDP tracing 或 Lighthouse CI 自动跑。Lighthouse CI 内置多次运行取中位数:
# 同一 URL 跑 5 次,自动取中位运行,避免单次抖动
lhci collect --url=https://example.com --numberOfRuns=5
或者用 Playwright 直接采 LCP:
import { chromium } from 'playwright';
async function measureLCP(url, runs) {
const browser = await chromium.launch();
const samples = [];
for (let i = 0; i < runs; i++) {
// 每次用全新 context,保证冷缓存一致
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(url);
const lcp = await page.evaluate(() =>
new Promise((resolve) => {
let v = 0;
new PerformanceObserver((l) => {
for (const e of l.getEntries()) v = e.startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
addEventListener('load', () => setTimeout(() => resolve(v), 0));
})
);
samples.push(lcp);
await context.close();
}
await browser.close();
return samples;
}
2. 报中位数 / p75,不是平均值
100 个样本里,你要看的是 p50、p75、p95,以及最小值/最大值。只要中位数和均值差距明显,就说明有离群点,均值不能信。
function percentile(arr, p) {
const s = [...arr].sort((a, b) => a - b);
const idx = Math.floor((p / 100) * (s.length - 1));
return s[idx];
}
// 关注 p50 和 p75 的变化,而不是 mean
3. A/B 交错运行,不要「先测完 A 再测完 B」
这是最容易踩的坑。即使环境「固定」,机器状态也会随时间漂移:CPU 升温降频、后台自动更新、内存碎片、浏览器内存增长。如果你先跑完 100 次 A,再跑 100 次 B,漂移就系统性地算在 B 头上,结论直接失真。
正确做法是交错采样:
// 交错执行,让漂移均匀分到 A、B 两组
const A = [], B = [];
for (let i = 0; i < runs; i++) {
A.push(await measure(controlUrl));
B.push(await measure(experimentUrl));
}
// 还可以随机化每轮的先后顺序,进一步抵消顺序效应
4. 冷缓存和热缓存必须一致
同一浏览器连测 100 次,缓存、DNS、连接复用、ServiceWorker 会越来越热。第 1 次和第 100 次测的根本不是同一个东西。这是系统性差异,不是噪声。
先明确你测哪种场景:
- 冷启动:每次清缓存或用无痕 context,必要时重启浏览器。
- 热缓存(回访):保留缓存,但丢弃前几次预热。
两种结论不能放在一起比。上面的脚本用 browser.newContext() 保证每次冷启动一致,就是这个目的。
5. 丢 warmup,做显著性检验
每组丢掉前 3–5 次预热数据,然后判断两组中位数差异是否显著。LCP 右偏、不服从正态,用非参数检验或 bootstrap:
// Bootstrap 估计两组中位数差值的置信区间
function bootstrapMedianDiff(a, b, iters = 10000) {
const diffs = [];
const median = (arr) => {
const s = [...arr].sort((x, y) => x - y);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
};
const sample = (arr) =>
Array.from({ length: arr.length }, () => arr[Math.floor(Math.random() * arr.length)]);
for (let i = 0; i < iters; i++) {
diffs.push(median(sample(a)) - median(sample(b)));
}
diffs.sort((x, y) => x - y);
return {
lower: diffs[Math.floor(iters * 0.025)],
upper: diffs[Math.floor(iters * 0.975)],
};
}
// 如果置信区间跨过 0,说明差异在噪声范围内,不能下结论
只要区间跨 0,就说这次「提升」不可信。 这比盯着数字大小靠谱得多。
Field 侧:这才是结论来源
Lab 只能告诉你「在这个受控场景里有效」,回答不了「用户整体是否变好」。要下结论必须看真实数据。
1. 采样用 web-vitals
import { onFCP, onLCP } from 'web-vitals';
function report(metric) {
const body = {
name: metric.name,
value: metric.value, // 毫秒
id: metric.id,
// 带上分层维度,后面才有意义
device: detectDeviceTier(),
network: navigator.connection?.effectiveType,
path: location.pathname,
};
navigator.sendBeacon('/api/vitals', JSON.stringify(body));
}
onFCP(report);
onLCP(report);
采集时一定要带上设备档位、网络类型、页面类型这些维度,否则后面无法分层分析。
2. 看 p75,不是平均值
Google 的 Core Web Vitals 阈值本身就是基于 p75 的。CrUX 报的也是 p75。均值会被极端值污染,p75 代表「大多数真实用户的体验」。
3. 分层看,别只看总体
总体 p75 可能没动,但「安卓低端机 + 4G」这一层明显改善了——只是被其他层稀释了。反过来也可能被某一层拖累而掩盖。按设备、网络、地区、页面类型分组,才能真正看到优化打在哪里。
4. 用 A/B 或灰度验证因果
想知道「是不是这次优化带来的提升」,最可靠的是受控实验:
- 按 userId/session 稳定分流,实验组和对照组同时在线;
- 这样能抵消节假日、版本发布、季节等外因;
- 再对两组的 p75 做 bootstrap 或 Mann-Whitney U 检验。
如果没法做 A/B,只能做发布前后对比,那必须控制变量:同一周几、同一流量来源、同一时间窗口。即便如此,可信度也远低于 A/B。
5. CrUX 是免费的起手式
如果有足够真实流量,直接接 CrUX(含 CrUX History API),拿现成的 p75 趋势曲线。它省去了自建 RUM 的成本,适合作为 baseline,再逐步自建更细的采集。
常见陷阱清单
- 只看平均值 → 被长尾骗。
- 单次 Lighthouse 分数对比 → 几乎无意义,噪声常达 ±15%。
- 先测完 A 再测完 B → 把机器漂移当成优化效果。
- 冷热缓存混着比 → 系统差异当成性能差异。
- 前后对比不控制变量 → 把外部因素算成优化。
- 忽略分层 → 局部改善被整体掩盖。
- 样本量不足就宣布胜利 → 假阳性。
落地顺序
- RUM 打底:
web-vitals上报,带设备/网络/页面维度,聚合出各维度 p50/p75/p95。 - A/B 验证:优化走灰度,两组同时在线,用 bootstrap 检验 p75 差异的置信区间。
- Lab 防回归:Lighthouse CI 进流水线做性能预算门禁,交错多次运行,超阈值才报警。
- 监控变化点:对 p75 趋势做变化点检测,区分真实回退和偶发波动。
结论
判断一次优化是否真的带来提升,本质是一个统计问题,不是一个测量问题。
- Lab:脚本化、A/B 交错、冷热一致、丢 warmup;用中位数/p75 + 置信区间防回归。
- Field:采样带维度、看 p75、分层分析、A/B 控因果、统计检验;这才是判定结论的依据。
只要记住一句话——单次数字不构成证据,分布加显著性才是。