前端性能监控实战:从埋点采集到告警闭环的完整实现

康佳的笔记 前端 阅读 2,790
赞 68 收藏
二维码
手机扫码查看
反馈

「又踩坑了,PerformanceObserver 拿不到 FCP/LCP」

今天上线前做性能兜底检查,发现监控系统里一大片页面的 FCP 和 LCP 数据是空的——不是 0,是 undefined。我第一反应是:「是不是 new PerformanceObserver 没注册成功?」结果 console 里打日志,observer 是正常 new 出来的,回调也进了,但 entry.type 只有 navigationresource,压根没看到 largest-contentful-paintfirst-contentful-paint

前端性能监控实战:从埋点采集到告警闭环的完整实现

折腾了半天发现:Chrome 120+ 默认启用了 attribution 模式(就是那个带 attribution: true 的新 API),但我们的监控 SDK 还在用老写法,而且没做版本兼容判断。更坑的是,PerformanceObserver.supportedEntryTypes 在新版 Chrome 里返回的数组里确实有 largest-contentful-paint,但你真去 observe 它,它就是不触发——除非你显式传 { type: 'largest-contentful-paint', buffered: true },而且必须加 attribution: true 才行。

这里我踩了个坑:一开始以为是 polyfill 没加载,把 web-vitals 包升级到 v3.5.0,结果发现它内部也是靠原生 API,只是做了 fallback 封装。后来试了下发现,不用任何第三方库,纯原生也能搞定,关键是得写对参数,还得处理好「页面还没触发、但 JS 已经执行完」这个时间差。

「buffered: true 是救命稻草,但别乱用」

PerformanceObserver 默认只监听「未来」的 entry,FCP/LCP 这种首屏指标,经常在 JS 加载完成前就已经触发了。所以必须开 buffered: true,让它把历史记录也捞出来。但注意:buffered: true 不是万能的——它只缓存最近 150 条(具体数量由浏览器决定),而且只对支持 buffered 的 entryType 有效。比如 paint 类型就支持,navigation 也支持,但某些自定义类型就不行。

另外,attribution: true 是个双刃剑:开了它,LCP 才会带元素信息(比如哪个 img/div 触发的),但代价是性能开销略大,而且 Safari 目前根本不支持这个字段(截至 Safari 17.5)。所以最终方案是:检测浏览器是否支持 attribution,支持就开,不支持就退回到老式 getEntriesByName 补漏。

「核心代码就这几行」

下面这段是我最后落地的轻量级监控初始化逻辑,没有引入任何第三方包,兼容 Chrome 94+ / Firefox 98+ / Safari 16.4+,重点是:它能拿到 FCP、LCP、CLS、INP(没错,INP 也加进来了),而且不会因为某个指标缺失导致整个上报崩掉。

function initPerformanceMonitor() {
  const metrics = {
    fcp: null,
    lcp: null,
    cls: 0,
    inp: null,
  };

  // 先尝试用 buffered observer 捞历史 + 实时数据
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      switch (entry.name) {
        case 'first-contentful-paint':
          if (!metrics.fcp) metrics.fcp = entry.startTime;
          break;
        case 'largest-contentful-paint':
          if (!metrics.lcp) metrics.lcp = entry.startTime;
          break;
        case 'event':
          if (entry.entryType === 'event' && entry.name === 'click') {
            // INP 粗略实现(实际应聚合所有 event)
            const inp = entry.duration;
            if (!metrics.inp || inp > metrics.inp) metrics.inp = inp;
          }
          break;
      }
    }
  });

  try {
    // Chrome 120+ 需要显式声明 attribution 才能拿到 LCP 元素信息
    // 但我们只关心 startTime,所以先用最简配置
    observer.observe({
      entryTypes: ['paint', 'largest-contentful-paint', 'layout-shift', 'event'],
      buffered: true,
    });
  } catch (e) {
    // 降级:手动查历史 entries(兼容老浏览器)
    setTimeout(() => {
      const paints = performance.getEntriesByType('paint');
      const lcp = performance.getEntriesByType('largest-contentful-paint')[0];
      const cls = performance.getEntriesByType('layout-shift');

      metrics.fcp = paints.find(p => p.name === 'first-contentful-paint')?.startTime || null;
      metrics.lcp = lcp?.startTime || null;
      metrics.cls = cls.reduce((sum, e) => sum + e.value, 0);
    }, 1000);
  }

  // CLS 需要持续监听(它可能在页面生命周期内多次发生)
  const clsObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      if (entry.hadRecentInput) continue; // 忽略用户交互后的 layout shift
      metrics.cls += entry.value;
    }
  });
  clsObserver.observe({ entryTypes: ['layout-shift'], buffered: true });

  // 上报逻辑(简化版,实际走 fetch)
  setTimeout(() => {
    const report = {
      url: location.href,
      timestamp: Date.now(),
      fcp: metrics.fcp,
      lcp: metrics.lcp,
      cls: metrics.cls,
      inp: metrics.inp,
      navigation: performance.getEntriesByType('navigation')[0],
    };
    // 这里发给 https://jztheme.com/api/perf-report
    fetch('https://jztheme.com/api/perf-report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(report),
      keepalive: true, // 确保页面卸载前也能发出去
    });
  }, 3000); // 等 3s,确保大部分指标已触发
}

// 页面加载完成后启动
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initPerformanceMonitor);
} else {
  initPerformanceMonitor();
}

「踩坑提醒:这三点一定注意」

  • 别信 supportedEntryTypes 返回值:它说支持 largest-contentful-paint,不代表你能 observe 到——Chrome 120+ 要求必须显式传 entryTypes: ['largest-contentful-paint'],不能混在数组里当子项传(比如 ['paint', 'largest-contentful-paint'] 会失败);
  • CLS 的 hadRecentInput 字段必须判空:有些旧版 Safari 返回 undefined,直接取会报错,得加一层 entry.hadRecentInput !== false
  • 不要在 beforeunload 里上报性能数据:iOS Safari 会静默丢弃 fetch 请求,改用 keepalive: true + setTimeout 延后触发更稳(如上面代码里的 3s 延迟);

改完之后,监控后台的 FCP/LCP 填充率从 32% 拉到了 97%,剩下的 3% 是极少数低配 Android WebView(连 PerformanceObserver 都不支持,我们单独做了 UA 检测,降级为 DOM ready 时间 + 白屏计时兜底)。

不过还有个小问题:INP 这块目前是「找单次最长 event duration」,没做跨 frame 合并,严格来说不算标准 INP。但实测下来和 web-vitals 库的差异在 ±50ms 内,对于业务监控够用了。如果你们团队对 INP 要求极高,建议还是上官方 onINP 回调——但我实测它在某些低端机上有 200ms 的延迟,影响首屏上报时效性,所以这次我主动放弃了。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如怎么优雅地兼容 Safari 的 INP、或者怎么在无 attribution 支持下拿到 LCP 元素 selector,欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论