前端性能监控实战:从埋点采集到告警闭环的完整方案
我的写法,亲测靠谱
性能监控这事,我干了快五年,从最早手动打点 console.time,到后来用 PerformanceObserver 监听 paint、load、navigation,再到现在搭一整套轻量上报 pipeline——踩的坑比写的代码还多。今天不讲理论,只说我在移动端项目里真正落地、压测过、线上跑了半年没崩的写法。
核心就三点:采集要轻、上报要稳、分析要快。下面直接上我目前在用的初始化代码:
// perf-monitor.js
const REPORT_URL = 'https://jztheme.com/api/perf-report';
function initPerfMonitor() {
// 1. 先记下页面启动时间(比 performance.timing 更准)
const startTime = performance.now();
// 2. 监听关键指标(注意:不是所有浏览器都支持,得兜底)
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'paint' && entry.name === 'first-contentful-paint') {
reportMetric('FCP', Math.round(entry.startTime));
}
if (entry.entryType === 'navigation') {
reportMetric('TTFB', Math.round(entry.fetchStart - entry.connectStart));
}
if (entry.entryType === 'largest-contentful-paint') {
reportMetric('LCP', Math.round(entry.startTime));
}
}
});
observer.observe({ entryTypes: ['paint', 'navigation', 'largest-contentful-paint'] });
// 3. 手动补一个 FID(因为 LCP / CLS 是自动上报,FID 必须监听)
let firstInputTime = 0;
const handleFirstInput = (e) => {
if (!firstInputTime && e.timeStamp > 0) {
firstInputTime = Math.round(e.timeStamp);
reportMetric('FID', firstInputTime);
window.removeEventListener('pointerdown', handleFirstInput);
window.removeEventListener('keydown', handleFirstInput);
}
};
window.addEventListener('pointerdown', handleFirstInput, { once: true, capture: true });
window.addEventListener('keydown', handleFirstInput, { once: true, capture: true });
// 4. 页面卸载前强制上报(别信 visibilitychange,它经常不触发)
window.addEventListener('beforeunload', () => {
reportMetric('unload', Math.round(performance.now() - startTime));
});
}
function reportMetric(name, value) {
// 关键:用 navigator.sendBeacon 而不是 fetch,否则 unload 时大概率丢数据
// 并且加个简单防抖:同一指标 5s 内只报一次
const key = reported_${name};
if (performance.now() - (window[key] || 0) < 5000) return;
window[key] = performance.now();
const data = new URLSearchParams({
name,
value: value.toString(),
url: encodeURIComponent(location.href),
ua: navigator.userAgent.slice(0, 200),
ts: Date.now().toString()
});
// sendBeacon 不支持自定义 header,所以用 FormData 也不行,老老实实用 URLSearchParams
if (navigator.sendBeacon) {
navigator.sendBeacon(REPORT_URL, data);
} else {
// 降级方案:fetch + keepalive(iOS 15+ 支持,但 Android 低版本不行)
fetch(REPORT_URL, {
method: 'POST',
body: data,
keepalive: true,
credentials: 'same-origin'
}).catch(() => {});
}
}
// 启动
if ('performance' in window && 'PerformanceObserver' in window) {
initPerfMonitor();
}
为什么这么写?我挨个说:
- 不用 window.performance.timing —— 它在 SPA 跳转后就失效了,而且 navigationStart 在某些安卓 WebView 里不准,我试过差出 800ms;直接用 performance.now() 做相对时间基准更稳。
- sendBeacon 是底线 —— 曾经用 fetch + beforeunload 上报,结果 iOS Safari 里 70% 的 FCP 数据丢了;换成 sendBeacon 后,上报成功率从 53% 拉到 98%+。
- FID 必须手动监听 pointerdown/keydown —— PerformanceObserver 对 FID 的支持太拉胯,Chrome 90+ 才开始有 entryType=’event’,但很多低端机还是靠自己抓。
- 防抖不是可选,是必须 —— LCP 在页面滚动或动态加载时会反复触发,不加限制,一个页面能发 20+ 条 LCP 上报,后端直接炸。
这几种错误写法,别再踩坑了
以下都是我亲手写过、被 QA 打回来、被 PM 质疑“为啥监控数据和实际感觉不一致”的反面案例:
❌ 错误写法1:用 setTimeout 模拟首屏渲染时间
有人为了兼容旧浏览器,写了个 setTimeout(() => report(‘FP’, Date.now()), 1000)。我第一次见的时候差点把键盘砸了——你猜用户手速快一点、手指划一下,首屏还没出来就被你上报了?这种数据完全没法分析。FP/FCP/LCP 都得依赖原生 Performance API,没有捷径。
❌ 错误写法2:在 React useEffect 里初始化 PerformanceObserver
这问题很隐蔽:组件挂载时才开监听,那页面 onload 前的 FP、TTFB 就全漏了。性能监控必须在 JS 执行最开头就启动,最好 inline script 放 head 里,而不是等框架加载完。
❌ 错误写法3:上报所有 PerformanceEntry
曾经有同事把 observer.observe({ entryTypes: [‘resource’, ‘navigation’, ‘paint’] }) 后的所有 entry 全部上报,结果单页产生 400+ 条请求,CDN 日志直接爆满。资源类指标(比如某个图片加载耗时)除非你专门做资源优化,否则根本不需要全量上报,反而污染核心指标看板。
❌ 错误写法4:用 localStorage 存未上报数据,下次打开再发
听起来很稳妥?错。实测发现:iOS Safari 在 background 状态下,localStorage 读写可能失败;Android 某些定制 ROM 会清空 localStorage;更惨的是,用户关掉页面 3 秒内又重开,你存的数据还没发出去,新 session 又覆盖了。最后我们删掉了整段缓存逻辑,接受小部分丢失,换来整体链路稳定。
实际项目中的坑
这些不是文档里写的,是我跟运维、测试、产品一起骂着街解决的:
- 安卓 WebView 里 PerformanceObserver 不触发 —— 不是 bug,是厂商阉割了。我们最后加了个降级:如果 2s 内没收到任何 paint 事件,就 fallback 到 DOMContentLoaded 时间戳 + 估算首屏元素 offsetTop 来粗略上报 FCP。
- 上报域名被某些企业防火墙拦截 —— 我们用的 jztheme.com/api/perf-report,结果某银行客户内部网络直接 403。解决方案:允许配置上报 endpoint,同时默认走主站同域接口(/api/perf-report),避免跨域策略干扰。
- CLS(累计布局偏移)在横竖屏切换时暴涨 —— 原来是 viewport meta 写错了, 缺了 initial-scale=1,导致 iOS Safari 横屏时重排两次。这个根本不是 JS 层能监控的,得前端+测试共建 checklist。
最后说一句实在的:这套方案上线后,我们把首屏耗时高(>3s)的机型分布跑出来,发现 62% 集中在 vivo Y系列和 OPPO A系列的 Android 10 设备上,于是针对性开了个「低端机降级模式」:关闭图片懒加载、禁用 WebP、用 base64 替代小图标字体。改完后这部分用户的 FCP 从 3.2s 降到 1.7s —— 监控的价值不在报表多好看,而在能让你精准地知道:该砍哪一刀。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 sourcemap 做 JS 错误堆栈映射、用 Performance.mark 做业务埋点联动,后续会继续分享这类博客。有更好的方案欢迎评论区交流。

暂无评论