前端性能监控实战:从埋点采集到告警闭环的完整实现
「又踩坑了,PerformanceObserver 拿不到 FCP/LCP」
今天上线前做性能兜底检查,发现监控系统里一大片页面的 FCP 和 LCP 数据是空的——不是 0,是 undefined。我第一反应是:「是不是 new PerformanceObserver 没注册成功?」结果 console 里打日志,observer 是正常 new 出来的,回调也进了,但 entry.type 只有 navigation 和 resource,压根没看到 largest-contentful-paint 或 first-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,欢迎评论区交流。

暂无评论