ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Performance API实战:从指标计算到Web性能监控上报全攻略

Performance API实战:从指标计算到Web性能监控上报全攻略 如果你负责过一个规模稍微大点的 Web 项目大概率遇过这种场景用户反馈页面白屏很久、滚动掉帧、点击没反应你本地打开一切正常。监控面板上只有 PV、UV、接口成功率渲染性能、资源耗时全是空白排查问题基本靠猜。Performance API 就是浏览器内置的一整套性能检测接口它能告诉你页面从发起导航到最终可交互的完整过程里每个节点花了多少时间、每个资源加载多久、主线程被哪些长任务占住。这篇文章我把实际项目中用 Performance API 做 web 性能检测的经验完整拆一遍从基础对象到上报方案、从指标计算到常见坑点适合前端工程师、性能优化负责人以及正准备为自己的 Web API 项目搭建性能监控模块的团队参考。1. 为什么现在还要专门聊 Performance API1.1 传统性能检测的局限性很多人觉得性能检测很简单打开 Chrome DevTools 的 Performance 面板录一段就行。问题是 DevTools 只适合本地排查线上真实用户的环境千差万别不同网络、不同设备、不同浏览器内核、不同缓存状态甚至用户的低电量模式都会让页面表现完全不一样。想拿到真实用户的数据必须靠浏览器主动把性能指标暴露出来然后由页面代码采集上报这就是 Performance API 的基本定位。另一类常用方案是搞一个定时器在页面上挂一个全局变量记录performance.now()的差值来估算白屏时间。这种方式最大的缺陷是它只能拿到页面自己代码能感知到的部分拿不到 DNS 解析耗时、TCP 连接耗时、资源加载的真实耗时、浏览器内部事件的时间线更拿不到布局偏移、长任务这些源自浏览器内部的信号。而且拿Date.now()算时间还会受系统时间跳变影响精度只有毫秒很多事件间隙根本测不出来。1.2 Performance API 的核心思路和体系Performance API 的核心思路很简单浏览器作为所有资源和页面的实际加载方天然知道每一个环节的准确时间它把这些信息按统一标准暴露给开发者。你不需要自己埋点不需要侵入业务逻辑只需要读取、筛选、聚合就能拿到一套完整的时间线数据。整套体系里你需要关注四类东西performance.now()高精度时间戳用于计算相对时间。performance.timing/PerformanceNavigationTiming导航生命周期各个节点的时间比如 DNS、TCP、DOM 解析、页面完全加载。performance.getEntriesByType()/getEntries()Resource Timing、Paint Timing、Largest Contentful Paint、Layout Shift 等各类性能条目。PerformanceObserver异步监听性能事件适合 LCP、CLS、长任务这类“事后才出现”的指标。把这四类东西组合起来你就能覆盖 web 性能检测的绝大多数场景。我 2023 年在电商项目里落地性能监控时页面核心指标完全靠它们采集没有再引入任何额外埋点脚本。2. 核心对象拆解从 timing 到 entry 再到 observer2.1 performance.timing老牌 API 的用法和坑performance.timing是早期浏览器就提供的对象它描述的是页面从上一个文档卸载到当前文档加载完成的所有时间点。常用的几个节点字段含义navigationStart导航开始时间所有耗时计算的基准domainLookupStart / domainLookupEndDNS 查询起止时间connectStart / connectEndTCP 连接建立起止时间requestStart浏览器发起请求的时间responseStart收到响应第一个字节的时间domInteractiveDOM 解析完成DOMContentLoaded 之前domContentLoadedEventEndDOMContentLoaded 事件处理完成loadEventEnd页面 onload 事件完成用这些点可以推算经典指标DNS 查询耗时domainLookupEnd - domainLookupStartTCP 连接耗时connectEnd - connectStartTTFB首字节时间responseStart - navigationStartDOM 解析耗时domInteractive - responseEnd页面完全加载耗时loadEventEnd - navigationStart实际使用时必须注意 ES6 的陷阱performance.timing的取值必须在页面 load 事件之后读才稳定。如果过早读取domContentLoadedEventEnd、loadEventEnd可能还是 0。我在项目里通常把所有 timing 采集放在window.onload之后再包一层requestIdleCallback或者setTimeout(..., 0)避免阻塞首屏渲染。另外从 Chrome 120 左右开始performance.timing已经被标记为 deprecated虽然现在还不会删但新代码建议直接使用performance.getEntriesByType(navigation)返回的PerformanceNavigationTiming。它把timing的字段归一化成了毫秒级时间戳而不是 Date 时间戳并且多了transferSize、encodedBodySize等传输体积字段排查带宽问题很好用。2.2 getEntriesByType资源时序和绘制时序performance.getEntriesByType(type)是另一个高频入口常见的 type 有resource所有加载的资源条目包括脚本、样式、图片、请求等。paint首次绘制First Paint和首次内容绘制First Contentful Paint。largest-contentful-paintLCP 条目。layout-shiftCLS 条目。navigation当前文档的导航条目。longtask超过 50ms 的长任务只能通过 observer 拿不能直接 getEntries。资源条目里的关键字段是initiatorType、duration、transferSize、nextHopProtocol我排查图片加载慢的时候经常这么用const resources performance.getEntriesByType(resource); resources.forEach((item) { console.log(item.name, item.initiatorType, item.duration, item.transferSize); });initiatorType能区分是script、css、img、fetch还是xmlhttprequest可以快速定位哪类资源拖慢了整体加载。transferSize是实际传输大小结合duration能算出每个资源的平均加载速度。如果某个资源duration特别长但transferSize很小基本可以判定是连接建立慢而不是文件体积大。这里有个容易忽略的点getEntriesByType只能拿到调用时已经完成或正在进行的资源条目不可能主动推送新任务。如果你想监听后续脚本加载的动态资源必须用PerformanceObserver订阅resource事件否则会漏数据。2.3 PerformanceObserver现代方案的核心PerformanceObserver是一个订阅者模式的 API本质上是让浏览器在性能条目出现时主动通知你。它的好处在于不需要轮询、不会漏掉动态加载的资源、能拿到 LCP、CLS、LongTask 这类在页面生命周期后期才产生的指标。基础用法const observer new PerformanceObserver((list) { const entries list.getEntries(); for (const entry of entries) { // 处理性能条目 } }); observer.observe({ type: longtask, buffered: true });buffered: true的意思是如果 observer 注册得比较晚先从性能缓冲区里把已经存好的历史条目补发一次。对于单页应用非常有用因为路由切换往往是异步的有些性能事件可能发生在 observer 注册之前buffered: true能避免这种遗漏。需要特别记住的是longtask类型不能通过getEntriesByType(longtask)获取必须依赖 observer因为长任务出现的频率不高且是异步事件轮询方式既浪费性能又容易错过。3. 实操搭一个真实可用的性能检测脚本3.1 你要采集哪些指标怎么算实际项目里指标不是越多越好多了会拉低性能检测脚本自身的性能也容易让数据报表失去重点。我建议一个 Web API 项目的首发版本先采集这些核心指标指标名计算方式解释TTFBnavigationEntry.responseStart - navigationEntry.startTime服务器响应速度的最直接指标FCPpaintentries 中name first-contentful-paint的startTime首次有内容绘制LCPlargest-contentful-paint条目的startTime最大内容绘制首屏体验关键CLSlayout-shift条目中value累加页面布局稳定性INP从 interaction 条目计算需要额外观察Event Timing用户交互延迟长任务次数longtask条目数量主线程阻塞情况JS 总阻塞时间所有longtask的duration减去 50ms 后累加反映脚本对交互的影响TTFB 和 FCP 的获取比较直接const navEntries performance.getEntriesByType(navigation) || []; const nav navEntries[0]; const ttfb nav ? nav.responseStart - nav.startTime : 0; const paintEntries performance.getEntriesByType(paint); const fcpEntry paintEntries.find((item) item.name first-contentful-paint); const fcp fcpEntry ? fcpEntry.startTime : 0;LCP 和 CLS 比较特殊它们不是固定值可能在页面加载过程中不断更新。LCP 是有史以来最快的加载时间如果页面首屏是图片图片加载完成前 LCP 可能先报告一个文本节点图片加载完后再报告新的值。所以 LCP 必须用PerformanceObserver监听并在每次收到条目时取最新的startTime。let lcpValue 0; const lcpObserver new PerformanceObserver((list) { const entries list.getEntries(); const lastEntry entries[entries.length - 1]; lcpValue lastEntry.startTime; }); lcpObserver.observe({ type: largest-contentful-paint, buffered: true });CLS 的累计方式更特殊它记录的是所有意外布局偏移的分值累加。监听layout-shift时如果偏移发生在用户交互的 500ms 内hadRecentInput为true这类偏移通常被认为是用户操作的自然结果不算 CLS 值。let clsValue 0; const clsObserver new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) { clsValue entry.value; } } }); clsObserver.observe({ type: layout-shift, buffered: true });3.2 上报策略时机、采样和合并很多团队把性能上报搞得太简单在每条指标出现时直接发一个 POST。这么做的结果是一个页面可能发出七八个请求不仅浪费带宽还会在页面加载关键时期抢资源影响指标本身的准确性。我的经验是统一做一个缓冲队列在页面空闲时批量上报。具体做法用一个数组缓存所有指标对象。每收到一个指标先 push 进数组不立即发送。使用requestIdleCallback监听浏览器空闲时段一次性取出队列中的数据并发送。如果requestIdleCallback不可用降级到页面的load事件后setTimeout发送。这个方案能保证上报本身对性能影响极小。代码大概长这样const queue []; function pushMetric(metric) { queue.push({ ...metric, time: performance.now(), }); if (queue.length 20) flush(); } function flush() { if (!queue.length) return; const data queue.splice(0, queue.length); const payload JSON.stringify(data); if (navigator.sendBeacon) { navigator.sendBeacon(/api/performance, payload); } else { fetch(/api/performance, { method: POST, body: payload, keepalive: true, }); } } window.addEventListener(load, () { requestIdleCallback(flush, { timeout: 2000 }); });这里优先用navigator.sendBeacon因为它即使页面正在卸载也能把数据发出去不会因为页面关闭而丢失。fetch的keepalive也有类似效果但sendBeacon在移动端和卸载场景下更可靠。采集频率也要控制不能每个用户都上报全量数据。性能监控数据量级通常很大建议在客户端做采样比如只有 10% 的流量会上报完整数据。采样率太低会导致异常样本抓不到太高会增加成本可以根据项目体量在 1% 到 10% 之间调。3.3 在项目里落地时的初始化细节把性能检测脚本集成进项目时一个关键原则是脚本本身必须非阻塞、低优先级、可降级。我通常用两种方式加载如果是原生 JS放在head里但用defer或者动态注入不参与首屏渲染的关键路径。如果是 Webpack/Vite 项目把性能检测模块用动态import()切分在requestIdleCallback里加载。还要注意Performance API 在不同浏览器、不同版本中的兼容性差异很大。建议在代码里做能力检测而不是上来就执行。一个完整的保护壳function isSupported() { return window.PerformanceObserver performance.getEntriesByType; } if (isSupported()) { initPerformanceMonitor(); }对于不支持largest-contentful-paint的老浏览器可以降级用performance.timing里的domContentLoadedEventEnd之类的字段做近似计算。虽然不精确但至少不会让监控出现空白。4. 从采集到上报数据格式和监控系统对接4.1 上报数据的结构设计上报数据的格式要兼顾前后端处理效率和指标可扩展性。我推荐一个扁平的结构每个指标一条记录而不是把所有指标塞进一个大数据对象。原因很简单后期筛选、告警、图表统计都更方便而且避免某个指标异常导致整包数据作废。{ appId: webapi-demo, env: production, page: /index.html, ua: Mozilla/5.0 ..., metric: lcp, value: 2345.67, navigationType: reload, timestamp: 1714579200000 }navigationType很重要它可以区分是首次加载、刷新、还是通过前进后退恢复页面。首次加载和从 bfcache 恢复的页面性能数据完全不是一个口径如果不区分开统计平均值时会被严重干扰。PerformanceNavigationTiming里的type字段能直接拿到这个值。传输体积字段也别漏transferSize、encodedBodySize这类数据能帮助你判断是否被 gzip、Service Worker 拦截、CDN 缓存命中。我做过一次排查发现某个页面 FCP 突然变慢最后定位是 CDN 缓存 key 配置错误导致部分用户回源transferSize明显偏大。这种问题如果只看时间指标根本找不到原因。4.2 跨域资源和时间戳的隐私限制Performance API 不是所有资源数据都能拿到。如果你在页面上加载了跨域 CDN 脚本比如cdn.bootcdn.net或者第三方统计脚本浏览器默认会屏蔽这些资源的详细 timing 信息duration、transferSize都会变成 0只保留加载发起时间。要拿到跨域资源的完整时序必须在资源标签上加crossoriginanonymous属性script srchttps://cdn.example.com/app.js crossoriginanonymous/script同时服务器响应头也要带上Timing-Allow-Origin: *或者具体的域名比如Timing-Allow-Origin: https://my-site.com。这两个条件缺一个资源时序都会被隐藏。我第一次接入第三方播放器 SDK 时忘了加这个响应头结果 SDK 加载时长一直是 0排查半天才发现是跨域限制。还有个容易被忽略的隐私限制performance.now()在浏览器里做了精度保护通常会被取整到 0.1ms 或者 1ms 级别。这是为了防止利用高精度时间做侧信道攻击属于浏览器设计的行为不是你代码的 bug做时间差值计算时不要因为精度奇怪而怀疑系统。4.3 与现有监控平台的对接方案如果你团队还没有性能监控平台最简单的方案是先上报到日志服务或者自建一套轻量的数据接口。核心接口就一个POST /api/performance Content-Type: application/json [ { metric: ttfb, value: 123, ... }, ...]后端收到后存进时序数据库或者普通的业务数据库再用数据可视化平台做报表。如果不想从头搭可以直接对接开源的 Grafana、Prometheus 体系或者使用商业 APM。无论哪种方案数据结构尽量遵循通用标准比如 W3C 定义的 PerformanceEntry 字段命名方便以后的迁移和扩展。如果是在公司内部发布一个 Web API 项目可以把性能检测做成一个独立的 npm 包通过一行代码接入各个直接业务方。这样做的好处是监控能力只维护一份数据口径统一。我实践下来性能监控最容易出的问题就是各团队自己实现一套TTFB 的计算方式还不一样最后报表根本没法横向比较。独立 SDK 能够有效避免这种混乱。5. 进阶长任务、内存和真实瓶颈定位5.1 长任务监听主线程卡顿的元凶很多页面表面上加载速度还行但用户滚动时一卡一卡的。这种问题 FCP、LCP 根本反应不出来因为首屏内容早就画完了问题出在交互阶段的主线程阻塞。这个时候longtask就是我们最好的侦察兵。浏览器规定任何 UI 渲染任务执行时间超过 50ms就会被记为一次长任务。50ms 这个阈值的来历是浏览器每秒渲染 60 帧每帧约 16.7ms100ms 以内用户会觉得交互是“瞬时的”所以 W3C 工作组讨论后把 50ms 作为一个折中阈值超过之后用户大概率能感知到卡顿。监听长任务const longTaskObserver new PerformanceObserver((list) { for (const entry of list.getEntries()) { report({ metric: longtask, value: entry.duration, startTime: entry.startTime, attribution: entry.attribution || [], }); } }); longTaskObserver.observe({ type: longtask });entry.attribution里会告诉你这个长任务和哪个容器、哪个脚本有关但注意这是一个相对较新的字段兼容性一般。实战里我更倾向于在收到长任务后通过错误监控的堆栈信息辅助定位或者直接约定一旦上线后长任务上报数量暴增优先查最近一次发布的新脚本。5.2 performance.memory只有 WebKit 有的内存指标performance.memory是 Chrome 系浏览器特有的非标准接口返回jsHeapSizeLimit、usedJSHeapSize、totalJSHeapSize三个值单位是字节。它看起来很方便但有几个致命限制只在 Chromium/WebKit 里存在Firefox、Safari 部分版本没有。usedJSHeapSize的精度被严重做了处理存在较大的四舍五入不能作为精确值。数值是实时采样的单次读取参考意义有限必须多次采样观察趋势。我在 SPA 项目里用它的方式是每 5 秒采样一次维护一个滑动窗口计算内存增长速率。如果组件切换后内存以很快的速度持续增长大概率是事件监听泄漏、DOM 引用未释放或者闭包持有大对象。这个方向能快速发现问题但不要直接拿它做告警阈值精度不够。5.3 结合 DevTools 做精确归因线上 Performance API 能告诉你是哪个环节慢但很难告诉你为什么慢。真正的归因还是得靠本地复现。我的工作流是线上性能监控报警定位到具体指标比如 LCP 从 1.8s 涨到 3.2s。打开 Chrome DevTools切到 Performance 面板点击录制。同时用 Network 面板观察资源加载瀑布流看哪个资源的等待时间明显异常。用 Performance API 在控制台手动执行分析脚本按耗时给资源排序。performance.getEntriesByType(resource) .sort((a, b) b.duration - a.duration) .slice(0, 10) .forEach((r) { console.log(r.name, r.duration, r.initiatorType); });DevTools 的 Performance 面板能显示主线程的火焰图Performance API 能提供精确的数字两者搭配几乎能解决绝大多数性能问题。我踩过的一个坑是DevTools 录制时如果开了 CPU 节流页面整体性能会被人为拉低得到的数值不能直接和线上数据对比。排查时应该先不节流跑一次完整数据再结合线上 Performance API 的数值交叉确认避免被本地环境误导。6. 常见问题与排查技巧实录6.1 数据缺失或为 0 的排查思路遇到性能数据为 0不要急着怀疑脚本按照优先级排查现象可能原因解决方法paint条目为空页面运行在非浏览器环境或浏览器不支持 Paint Timing检查是否在 WebView 或 SSR 渲染环境做能力检测降级跨域资源字段为 0未加crossorigin或服务器未返回Timing-Allow-Origin资源加crossorigin配置响应头navigation条目不存在在 iframe 或某些隐私模式下降级用performance.timingLCP 始终不变图片懒加载太猛LCP 元素还没加载完毕加上fetchpriorityhigh优化加载策略同时确认首屏 LCP 候选元素CLS 为 0但页面明显抖动hadRecentInput判断把关键偏移排除了不要全量过滤hadRecentInput保留独立字段分析有一种情况特别隐蔽浏览器处于省电模式或后台 tab 时会主动降低性能导致 Performance API 上报的数值异常偏大。这类数据混进报表会污染平均值建议上报时带上document.visibilityState后端统计时把非visible状态的数据单独过滤掉或者在前端直接丢弃。6.2 PerformanceObserver 回调里的性能陷阱PerformanceObserver回调本身是在浏览器主线程执行的如果回调里做了复杂计算、同步请求、大数据量拼接反而会阻塞主线程制造新的卡顿。这个坑很多教程不会提但我实际测下来影响很大。我的做法是回调里只做最轻量的处理——提取字段、push 进队列不在回调里做格式化、加密、上报。所有重活都放到requestIdleCallback或者微任务里去干。另外不要给每个页面创建太多 observer一个指标一个 observer 看起来清晰但每个 observer 都有内部缓冲和事件分发成本。我通常只建三个 observer一个负责 paint、navigation、resource一个负责 layout-shift一个负责 longtask。如果你需要同时监听多种类型可以直接在observe里指定多个 typeobserver.observe({ type: resource, buffered: true }); observer.observe({ type: navigation, buffered: true }); observer.observe({ type: paint, buffered: true });这样只要一个回调就能处理多种事件性能开销更小。6.3 关于阈值、平均值和百分位的实测建议性能监控上线后最容易犯的错是拿平均值当健康指标。平均值对异常值极不敏感1% 的用户体验差到 10 秒99% 的用户都很快平均数据依然好看。我强烈建议后端统计时以 P50、P75、P90、P95 分位数为主。以 LCP 为例Google 给的官方参考阈值是 2.5s 以内优秀、4s 以内合格、超过 4s 算差。但这是面向全球互联网的基准国内复杂网络环境下未必完全适用我更倾向于结合自己的业务场景定先用监控数据跑一周统计出 P75 基线再根据基线下调或上调告警线。另外发布 Web API 项目时要特别注意版本关联。性能数据必须和前端发布版本关联上否则新版上线后性能恶化都无法快速对应是哪个提交引入的。可以在上报数据里加一个buildId或者version字段从构建环境变量里读取注入成本很低但收益很大。最后再分享一个小技巧所有性能数据采集脚本一定要自己在本地先跑一遍真实场景测试不要只打开空白页面验证。我建议随便找一个你线上的真实页面在控制台执行一段快速脚本把paint、navigation、resource都打印出来先肉眼确认数值是否正常再决定要不要全量接入。很多坑只有在真实页面上才会暴露比如跨域、懒加载、iframe 嵌套贸然全量上线后才发现数据不对那就成了给监控自身排错额外浪费时间。Performance API 是一套很安静的技术用好了它能帮你提前发现那些用户已经骂了很久、但你看不见的问题。
返回列表