Performance Observer API 实战:前端性能监控的终极方案
“我们的页面性能怎么样?“——面对这个问题,很多团队的回答还停留在”感觉挺快的”或者”Lighthouse 跑了 90 分”的阶段。但真实用户的体验可能和你在办公室用 MacBook Pro + 千兆网络测出来的结果天差地别。
本文将深入讲解如何使用 Performance Observer API 构建一套完整的前端性能监控方案,覆盖 LCP、FID、CLS、INP 四大核心指标的采集原理与实现,并最终手写一个生产级的性能监控 SDK。
一、为什么需要真实用户监控(RUM)
1.1 Lab Data vs Field Data
- Lab Data(实验室数据):Lighthouse、WebPageTest 等工具在受控环境下测出的数据
- Field Data(现场数据):真实用户在各种设备、网络条件下的实际体验数据
你的 Lighthouse 分数:95你在印度用户的 Android 手机上:35这不是夸张。Google 的数据显示,不同设备间的性能差异可达 10 倍以上。只有采集真实用户数据,才能了解真实的性能状况。
1.2 Core Web Vitals
Google 定义的三大核心指标(加上 INP 替代 FID):
| 指标 | 全称 | 衡量维度 | 好 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 加载速度 | ≤2.5s | ≤4s | >4s |
| INP | Interaction to Next Paint | 交互响应 | ≤200ms | ≤500ms | >500ms |
| CLS | Cumulative Layout Shift | 视觉稳定性 | ≤0.1 | ≤0.25 | >0.25 |
二、PerformanceObserver 基础
PerformanceObserver 是浏览器提供的性能数据订阅 API,你可以注册对特定类型性能事件的监听。
2.1 基本用法
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.name, entry.entryType, entry.startTime, entry.duration); }});
// 监听资源加载observer.observe({ type: 'resource', buffered: true });buffered: true 是关键参数——它会返回在 Observer 创建之前已经发生的事件。如果不加这个参数,你可能会错过页面加载初期的性能数据。
2.2 支持的 entryType
// 查看当前浏览器支持哪些类型console.log(PerformanceObserver.supportedEntryTypes);// 常见输出:// ["element", "event", "first-input", "largest-contentful-paint",// "layout-shift", "longtask", "mark", "measure", "navigation",// "paint", "resource", "visibility-state"]2.3 与 performance.getEntries() 的区别
// 同步获取(可能遗漏正在进行的条目)const entries = performance.getEntriesByType('resource');
// 异步订阅(推荐,不会遗漏)const observer = new PerformanceObserver((list) => { // 每当有新的 resource 条目产生时触发});observer.observe({ type: 'resource', buffered: true });三、LCP(Largest Contentful Paint)采集
LCP 衡量的是视口内最大可见内容元素完成渲染的时间。“最大内容”通常是:
<img>元素<video>的封面图- 带有
background-image的块级元素 - 大段文本块
3.1 采集实现
function observeLCP(callback) { let lcpValue = 0; let lcpEntry = null;
const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); // LCP 会持续更新,直到用户交互 // 最后一个条目就是最终的 LCP const lastEntry = entries[entries.length - 1]; lcpValue = lastEntry.startTime; lcpEntry = lastEntry; });
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// LCP 在用户首次交互后确定 // 使用 visibilitychange 和用户输入事件来确定最终值 const reportLCP = () => { if (lcpEntry) { observer.disconnect(); callback({ value: lcpValue, element: lcpEntry.element?.tagName, url: lcpEntry.url, size: lcpEntry.size, loadTime: lcpEntry.loadTime, renderTime: lcpEntry.renderTime, }); } };
// 页面切到后台时上报 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { reportLCP(); } });
// 用户首次交互时上报 ['keydown', 'click', 'pointerdown'].forEach((type) => { document.addEventListener(type, reportLCP, { once: true, capture: true }); });
return observer;}
// 使用observeLCP((data) => { console.log('LCP:', data.value.toFixed(2), 'ms'); console.log('LCP 元素:', data.element); console.log('资源 URL:', data.url);});3.2 LCP 的注意事项
// LCP 可能被多次更新// 例如:先渲染文字(LCP=500ms) → 再渲染大图(LCP=1200ms)// 最终 LCP 取最后一个值
// LCP 不包括用户交互后渲染的内容// 一旦用户点击/滚动/按键,LCP 就"冻结"了
// renderTime vs loadTime// renderTime: 元素实际渲染到屏幕的时间// loadTime: 资源加载完成的时间(对图片有意义)// 跨域资源如果没有 Timing-Allow-Origin 头,renderTime 为 0四、FID(First Input Delay)采集
FID 衡量用户首次交互(点击、轻触、按键)到浏览器实际开始处理事件之间的延迟。注意:FID 已被 INP 取代,但仍值得了解。
function observeFID(callback) { const observer = new PerformanceObserver((list) => { const firstInput = list.getEntries()[0]; if (firstInput) { const fid = firstInput.processingStart - firstInput.startTime; callback({ value: fid, eventType: firstInput.name, startTime: firstInput.startTime, processingStart: firstInput.processingStart, processingEnd: firstInput.processingEnd, duration: firstInput.duration, }); observer.disconnect(); } });
observer.observe({ type: 'first-input', buffered: true }); return observer;}
// 使用observeFID((data) => { console.log(`FID: ${data.value.toFixed(2)}ms (${data.eventType})`);});FID 的局限性
FID 只测量第一次交互的延迟,如果第一次交互很快但后续交互很慢,FID 无法反映。这就是 INP 取代 FID 的原因。
五、CLS(Cumulative Layout Shift)采集
CLS 衡量页面整个生命周期中所有意外布局偏移的累计分数。
5.1 什么是布局偏移
用户正在阅读文章 → 突然上方加载了一个广告 → 文章内容被推下去了这种意外的视觉跳动就是布局偏移。注意,用户交互(如点击展开菜单)引起的布局变化不算CLS。
5.2 采集实现
CLS 的计算方式在 2021 年后改为最大会话窗口算法:
function observeCLS(callback) { let clsValue = 0; let sessionValue = 0; let sessionEntries = [];
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { // 忽略用户交互引起的偏移 if (!entry.hadRecentInput) { const firstSessionEntry = sessionEntries[0]; const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
// 如果与上一次偏移间隔 < 1秒,且整个会话 < 5秒,归入同一会话窗口 if ( sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000 ) { sessionValue += entry.value; sessionEntries.push(entry); } else { // 开始新的会话窗口 sessionValue = entry.value; sessionEntries = [entry]; }
// CLS = 所有会话窗口中最大的那个 if (sessionValue > clsValue) { clsValue = sessionValue; } } } });
observer.observe({ type: 'layout-shift', buffered: true });
// 页面隐藏时上报 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { observer.disconnect(); callback({ value: clsValue, entries: sessionEntries.map((e) => ({ value: e.value, startTime: e.startTime, sources: e.sources?.map((s) => ({ node: s.node?.tagName, previousRect: s.previousRect, currentRect: s.currentRect, })), })), }); } });
return observer;}
// 使用observeCLS((data) => { console.log('CLS:', data.value.toFixed(4)); data.entries.forEach((entry) => { console.log('偏移源:', entry.sources); });});5.3 常见 CLS 问题与修复
<!-- 问题 1:图片没有尺寸 → 加载后撑开空间 --><!-- ❌ --><img src="photo.jpg">
<!-- ✅ 始终设置宽高 --><img src="photo.jpg" width="800" height="600">
<!-- 或使用 CSS aspect-ratio --><style> .responsive-img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; }</style><img class="responsive-img" src="photo.jpg">/* 问题 2:Web 字体加载导致文字跳动 *//* ✅ 使用 font-display: swap 或 optional */@font-face { font-family: 'CustomFont'; src: url('font.woff2') format('woff2'); font-display: swap;}
/* 更好:使用 size-adjust 匹配 fallback 字体的度量 */@font-face { font-family: 'CustomFont'; src: url('font.woff2') format('woff2'); font-display: swap; size-adjust: 105%; ascent-override: 95%; descent-override: 22%; line-gap-override: 0%;}/* 问题 3:动态插入的内容 *//* ✅ 预留空间 */.ad-slot { min-height: 250px; /* 使用 contain-intrinsic-size 更精确 */ contain-intrinsic-size: 300px 250px;}六、INP(Interaction to Next Paint)采集
INP 衡量页面整个生命周期中所有交互的响应延迟,取第 98 百分位值(或近似值),全面反映页面的交互响应性。
6.1 INP 的组成
INP = Input Delay + Processing Time + Presentation Delay
Input Delay: 事件进入队列到开始处理的时间(主线程可能在忙)Processing Time: 事件处理函数的执行时间Presentation Delay: 处理完到下一帧渲染上屏的时间6.2 采集实现
function observeINP(callback) { // 收集所有交互的最大延迟 const interactionMap = new Map();
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { // 只关注有 interactionId 的事件(用户交互事件) if (!entry.interactionId) continue;
const existing = interactionMap.get(entry.interactionId); // 同一个交互可能触发多个事件(pointerdown + pointerup + click) // 取 duration 最大的那个 if (!existing || entry.duration > existing.duration) { interactionMap.set(entry.interactionId, { id: entry.interactionId, latency: entry.duration, name: entry.name, startTime: entry.startTime, processingStart: entry.processingStart, processingEnd: entry.processingEnd, inputDelay: entry.processingStart - entry.startTime, processingTime: entry.processingEnd - entry.processingStart, presentationDelay: entry.startTime + entry.duration - entry.processingEnd, target: entry.target?.tagName, }); } } });
// 使用 durationThreshold 过滤掉极短的事件 observer.observe({ type: 'event', buffered: true, durationThreshold: 16, // 只记录 > 16ms 的事件 });
function getINP() { const interactions = [...interactionMap.values()]; if (interactions.length === 0) return null;
// 按 latency 降序排列 interactions.sort((a, b) => b.latency - a.latency);
// 取第 98 百分位 // 简化算法:如果交互数 < 50,取最大值;否则忽略最差的 2% const index = Math.min( interactions.length - 1, Math.floor(interactions.length * 0.02) );
return interactions[index]; }
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { observer.disconnect(); const inp = getINP(); if (inp) { callback({ value: inp.latency, detail: inp, totalInteractions: interactionMap.size, }); } } });
return { observer, getINP };}
// 使用observeINP((data) => { console.log('INP:', data.value, 'ms'); console.log('最慢交互类型:', data.detail.name); console.log('Input Delay:', data.detail.inputDelay.toFixed(2), 'ms'); console.log('Processing Time:', data.detail.processingTime.toFixed(2), 'ms'); console.log('Presentation Delay:', data.detail.presentationDelay.toFixed(2), 'ms'); console.log('总交互数:', data.totalInteractions);});6.3 优化 INP 的策略
// 1. 减少 Input Delay —— 避免长任务阻塞主线程// ❌ 同步处理大量数据button.addEventListener('click', () => { processLargeDataset(data); // 阻塞 500ms updateUI();});
// ✅ 使用 scheduler.yield() 让出主线程button.addEventListener('click', async () => { // 先更新 UI 给用户反馈 showLoadingState();
// 让出主线程,让浏览器有机会渲染 await scheduler.yield();
// 再处理耗时操作 processLargeDataset(data);
await scheduler.yield();
updateUI();});
// 2. 减少 Processing Time —— 拆分长任务// ❌ 一个事件处理函数干了太多事input.addEventListener('input', (e) => { validateInput(e.target.value); // 10ms filterResults(e.target.value); // 50ms updateSuggestions(filtered); // 20ms logAnalytics(e.target.value); // 5ms // 总共 85ms});
// ✅ 拆分为高优先级和低优先级input.addEventListener('input', (e) => { const value = e.target.value; // 高优先级:用户立即需要看到的 validateInput(value);
// 低优先级:延迟处理 requestIdleCallback(() => { filterResults(value); updateSuggestions(filtered); });
// 更低优先级:分析日志 queueMicrotask(() => logAnalytics(value));});
// 3. 减少 Presentation Delay —— 减少渲染工作量// 使用 contain: content 限制重排范围// 使用虚拟列表减少 DOM 节点数// 避免在事件处理中触发强制同步布局七、长任务监控
长任务(Long Task)是指耗时超过 50ms 的主线程任务,它是导致 FID/INP 恶化的直接原因。
function observeLongTasks(callback) { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { callback({ duration: entry.duration, startTime: entry.startTime, // attribution 告诉你长任务来自哪里 attribution: entry.attribution?.map((attr) => ({ name: attr.name, entryType: attr.entryType, containerType: attr.containerType, containerSrc: attr.containerSrc, containerName: attr.containerName, })), }); } });
observer.observe({ type: 'longtask', buffered: true }); return observer;}
// 使用observeLongTasks((task) => { if (task.duration > 100) { console.warn( `⚠️ 检测到长任务: ${task.duration.toFixed(0)}ms`, task.attribution ); }});八、资源加载监控
function observeResources(callback) { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { // 过滤出关键信息 const resourceData = { name: entry.name, type: entry.initiatorType, // script, css, img, fetch, xmlhttprequest duration: entry.duration, transferSize: entry.transferSize, encodedBodySize: entry.encodedBodySize, decodedBodySize: entry.decodedBodySize, // 详细的时间分解 timing: { dns: entry.domainLookupEnd - entry.domainLookupStart, tcp: entry.connectEnd - entry.connectStart, tls: entry.secureConnectionStart ? entry.connectEnd - entry.secureConnectionStart : 0, ttfb: entry.responseStart - entry.requestStart, download: entry.responseEnd - entry.responseStart, }, // 缓存命中检测 cached: entry.transferSize === 0 && entry.decodedBodySize > 0, };
callback(resourceData); } });
observer.observe({ type: 'resource', buffered: true }); return observer;}
// 使用observeResources((resource) => { if (resource.duration > 1000) { console.warn(`慢资源: ${resource.name} (${resource.duration.toFixed(0)}ms)`); console.log(' DNS:', resource.timing.dns.toFixed(0), 'ms'); console.log(' TCP:', resource.timing.tcp.toFixed(0), 'ms'); console.log(' TTFB:', resource.timing.ttfb.toFixed(0), 'ms'); console.log(' 下载:', resource.timing.download.toFixed(0), 'ms'); }});九、自建性能监控 SDK
把上面所有的采集能力整合成一个生产级的 SDK:
class PerfMonitor { constructor(config = {}) { this.config = { endpoint: config.endpoint || '/api/perf', sampleRate: config.sampleRate || 1.0, // 采样率 appId: config.appId || 'default', debug: config.debug || false, ...config, };
this.metrics = {}; this.observers = []; this.buffer = []; this.flushTimer = null;
// 采样控制 this.shouldSample = Math.random() < this.config.sampleRate; if (!this.shouldSample) return;
this._init(); }
_init() { this._observeLCP(); this._observeCLS(); this._observeINP(); this._observeFID(); this._observeLongTasks(); this._observeNavigation();
// 页面卸载前发送数据 document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { this._flush(); } });
// 定期发送缓冲数据 this.flushTimer = setInterval(() => this._flush(), 30000); }
_observeLCP() { try { let lcpValue = 0; const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); const last = entries[entries.length - 1]; lcpValue = last.startTime; this.metrics.lcp = { value: lcpValue, element: last.element?.tagName, url: last.url, size: last.size, }; }); observer.observe({ type: 'largest-contentful-paint', buffered: true }); this.observers.push(observer); } catch (e) { this._debug('LCP not supported'); } }
_observeCLS() { try { let clsValue = 0; let sessionValue = 0; let sessionEntries = [];
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.hadRecentInput) continue;
const firstEntry = sessionEntries[0]; const lastEntry = sessionEntries[sessionEntries.length - 1];
if ( sessionValue && entry.startTime - lastEntry.startTime < 1000 && entry.startTime - firstEntry.startTime < 5000 ) { sessionValue += entry.value; sessionEntries.push(entry); } else { sessionValue = entry.value; sessionEntries = [entry]; }
if (sessionValue > clsValue) { clsValue = sessionValue; this.metrics.cls = { value: clsValue }; } } }); observer.observe({ type: 'layout-shift', buffered: true }); this.observers.push(observer); } catch (e) { this._debug('CLS not supported'); } }
_observeINP() { try { const interactionMap = new Map();
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!entry.interactionId) continue; const existing = interactionMap.get(entry.interactionId); if (!existing || entry.duration > existing.duration) { interactionMap.set(entry.interactionId, entry); } }
// 计算当前 INP const interactions = [...interactionMap.values()]; if (interactions.length > 0) { interactions.sort((a, b) => b.duration - a.duration); const idx = Math.min( interactions.length - 1, Math.floor(interactions.length * 0.02) ); this.metrics.inp = { value: interactions[idx].duration, interactionCount: interactions.length, }; } }); observer.observe({ type: 'event', buffered: true, durationThreshold: 16 }); this.observers.push(observer); } catch (e) { this._debug('INP not supported'); } }
_observeFID() { try { const observer = new PerformanceObserver((list) => { const entry = list.getEntries()[0]; if (entry) { this.metrics.fid = { value: entry.processingStart - entry.startTime, eventType: entry.name, }; observer.disconnect(); } }); observer.observe({ type: 'first-input', buffered: true }); this.observers.push(observer); } catch (e) { this._debug('FID not supported'); } }
_observeLongTasks() { try { let longTaskCount = 0; let totalBlockingTime = 0;
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { longTaskCount++; // TBT = 每个长任务超过 50ms 的部分之和 totalBlockingTime += Math.max(0, entry.duration - 50); } this.metrics.longTasks = { count: longTaskCount, tbt: totalBlockingTime, }; }); observer.observe({ type: 'longtask', buffered: true }); this.observers.push(observer); } catch (e) { this._debug('LongTask not supported'); } }
_observeNavigation() { try { const observer = new PerformanceObserver((list) => { const entry = list.getEntries()[0]; if (entry) { this.metrics.navigation = { type: entry.type, // navigate, reload, back_forward, prerender ttfb: entry.responseStart - entry.requestStart, domContentLoaded: entry.domContentLoadedEventEnd - entry.startTime, load: entry.loadEventEnd - entry.startTime, domInteractive: entry.domInteractive - entry.startTime, // 详细时序 dns: entry.domainLookupEnd - entry.domainLookupStart, tcp: entry.connectEnd - entry.connectStart, redirect: entry.redirectEnd - entry.redirectStart, // 传输 transferSize: entry.transferSize, }; } }); observer.observe({ type: 'navigation', buffered: true }); this.observers.push(observer); } catch (e) { this._debug('Navigation timing not supported'); } }
// 手动标记自定义指标 mark(name) { performance.mark(name); }
measure(name, startMark, endMark) { try { const measure = performance.measure(name, startMark, endMark); this._addToBuffer({ type: 'custom', name, value: measure.duration, timestamp: Date.now(), }); } catch (e) { this._debug('Measure failed:', e); } }
_addToBuffer(data) { this.buffer.push({ ...data, appId: this.config.appId, url: location.href, userAgent: navigator.userAgent, connection: navigator.connection ? { effectiveType: navigator.connection.effectiveType, rtt: navigator.connection.rtt, downlink: navigator.connection.downlink, } : null, deviceMemory: navigator.deviceMemory, hardwareConcurrency: navigator.hardwareConcurrency, timestamp: Date.now(), }); }
_flush() { // 收集最终指标 this._addToBuffer({ type: 'web-vitals', metrics: { ...this.metrics }, });
if (this.buffer.length === 0) return;
const payload = JSON.stringify(this.buffer); this.buffer = [];
// 使用 sendBeacon 确保页面关闭时也能发送 if (navigator.sendBeacon) { navigator.sendBeacon(this.config.endpoint, payload); } else { // 降级方案 fetch(this.config.endpoint, { method: 'POST', body: payload, keepalive: true, }).catch(() => {}); }
this._debug('Flushed metrics:', payload); }
_debug(...args) { if (this.config.debug) { console.log('[PerfMonitor]', ...args); } }
destroy() { this.observers.forEach((o) => o.disconnect()); clearInterval(this.flushTimer); this._flush(); }}
// 初始化const monitor = new PerfMonitor({ endpoint: 'https://analytics.example.com/perf', appId: 'my-app', sampleRate: 0.1, // 10% 采样 debug: false,});
// 自定义指标monitor.mark('app-init-start');// ... 初始化逻辑monitor.mark('app-init-end');monitor.measure('app-init', 'app-init-start', 'app-init-end');
export default PerfMonitor;十、数据上报策略
10.1 sendBeacon vs fetch
// sendBeacon 的优势:// 1. 页面卸载时也能发送(不会被浏览器取消)// 2. 异步且不阻塞页面关闭// 3. 有大小限制(通常 64KB)
// 降级策略function sendData(url, data) { const payload = JSON.stringify(data);
// 优先使用 sendBeacon if (navigator.sendBeacon) { const success = navigator.sendBeacon(url, payload); if (success) return; }
// 降级为 fetch + keepalive fetch(url, { method: 'POST', body: payload, keepalive: true, headers: { 'Content-Type': 'application/json' }, }).catch(() => { // 最终降级:同步 XMLHttpRequest(不推荐) // 只在极端情况下使用 });}10.2 采样与数据量控制
// 分级采样策略function getSampleRate(metric) { // 核心指标全量采集 if (['lcp', 'cls', 'inp'].includes(metric)) return 1.0; // 资源加载 10% 采样 if (metric === 'resource') return 0.1; // 长任务 50% 采样 if (metric === 'longtask') return 0.5; // 其他 5% 采样 return 0.05;}
// 数据压缩function compressPayload(data) { // 移除不必要的字段 const compressed = data.map((item) => ({ t: item.type, v: Math.round(item.value), ts: item.timestamp, // ... 只保留必要字段 })); return JSON.stringify(compressed);}十一、总结
Performance Observer API 是前端性能监控的基石。通过本文,你应该掌握了:
- 四大 Web Vitals 指标的采集原理和实现:LCP、FID、CLS、INP
- 长任务和资源加载的监控方法
- 生产级 SDK 的完整设计:采样、缓冲、上报、容错
- 数据上报的最佳实践:sendBeacon、keepalive、采样控制
实际项目中,你可以直接使用 Google 的 web-vitals 库作为采集层——它经过了大量的边界情况处理和浏览器兼容性测试。但理解底层原理,才能在出问题时快速定位和解决。
性能优化不是一次性的工作,而是一个持续的过程。有了监控数据,你才能知道优化的效果,才能发现回退,才能对性能有真正的掌控力。
“You can’t improve what you can’t measure.” —— 这句话在前端性能领域尤其适用。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!