创见博客
一次优化到底有没有用?FCP/LCP 评估的最佳实践
七崽爱吃小饼干2026/09/30阅读 0

先说一个几乎每个人都遇到过的场景。

你在本地跑了一次 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 tracingweb-vitals 上报、CrUX
特点可控、可复现、单次噪声大真实但混杂设备/网络差异
回答的问题这次改动有没有引入回归?用户整体体验有没有变好?
权威性解释原因判定结论

一句话:Lab 用来定位和防回归,Field 用来下结论。 你的优化最终有没有用,看 Field。

Lab 侧:把噪声压下去

Lab 的价值在于可控。用不好,就只剩噪声。

1. 用脚本跑,不要手点 Performance 面板

手动测 100 次既费时又引入操作误差。用 CDP tracing 或 Lighthouse CI 自动跑。Lighthouse CI 内置多次运行取中位数:

bash
# 同一 URL 跑 5 次,自动取中位运行,避免单次抖动
lhci collect --url=https://example.com --numberOfRuns=5

或者用 Playwright 直接采 LCP:

js
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,以及最小值/最大值。只要中位数和均值差距明显,就说明有离群点,均值不能信。

js
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 头上,结论直接失真。

正确做法是交错采样:

js
// 交错执行,让漂移均匀分到 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:

js
// 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

js
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 → 把机器漂移当成优化效果。
  • 冷热缓存混着比 → 系统差异当成性能差异。
  • 前后对比不控制变量 → 把外部因素算成优化。
  • 忽略分层 → 局部改善被整体掩盖。
  • 样本量不足就宣布胜利 → 假阳性。

落地顺序

  1. RUM 打底:web-vitals 上报,带设备/网络/页面维度,聚合出各维度 p50/p75/p95。
  2. A/B 验证:优化走灰度,两组同时在线,用 bootstrap 检验 p75 差异的置信区间。
  3. Lab 防回归:Lighthouse CI 进流水线做性能预算门禁,交错多次运行,超阈值才报警。
  4. 监控变化点:对 p75 趋势做变化点检测,区分真实回退和偶发波动。

结论

判断一次优化是否真的带来提升,本质是一个统计问题,不是一个测量问题。

  • Lab:脚本化、A/B 交错、冷热一致、丢 warmup;用中位数/p75 + 置信区间防回归。
  • Field:采样带维度、看 p75、分层分析、A/B 控因果、统计检验;这才是判定结论的依据。

只要记住一句话——单次数字不构成证据,分布加显著性才是。

评论
0/100