10分钟彻底搞懂前端页面性能监控
Category(分类): Browser Status: 暂缓了解
前言
前端页面性能是用户体验的重要组成部分。本文保留原文中基于阿里 UC 岳鹰全景监控平台 的思路:使用浏览器 Performance API 采集指标,再通过 navigator.sendBeacon() 等方式低侵入上报。
原文发布时主要介绍 Navigation Timing Level 1、performance.timing 和 unload 阶段上报。今天这些 API 仍有历史价值,但新项目应该优先使用 Navigation Timing Level 2、PerformanceObserver、Core Web Vitals 和 visibilitychange。另外,实验室工具(例如 Lighthouse)与真实用户监控(RUM)回答的是不同问题,不能互相替代。

为什么要监控页面性能?
页面打开慢、交互卡顿或布局不断跳动,都会直接影响用户完成任务的概率。移动端设备的 CPU、内存和网络差异尤其明显,同一个版本在开发者电脑上很快,在低端手机或弱网环境中却可能完全不同。
性能还会随着版本迭代逐渐衰减:依赖增多、第三方脚本增加、图片变大、接口变慢、组件渲染变复杂,都可能让用户体验悄悄变差。因此性能工作不能只做一次优化,而应该形成“采集—分析—改进—回归”的闭环:
- 持续采集真实用户数据;
- 按页面、版本、设备、网络、地区和运营商分组;
- 发现异常后定位到网络、资源、主线程或业务代码;
- 优化后通过实验室报告和真实用户数据验证;
- 对关键指标设置阈值、趋势告警和版本回归检测。
先区分两类性能数据
1. 真实用户监控(RUM)
RUM 在真实用户浏览器中采集数据,能反映真实设备、真实网络、缓存状态、用户交互和页面生命周期。Core Web Vitals 的合格判断通常以移动端和桌面端分别统计的第 75 百分位为参考。
RUM 适合回答:
- 用户实际看到内容需要多久?
- 哪些设备和网络上的 INP 最差?
- 某次发布后 LCP、CLS 或错误率是否变差?
- 性能变化是否影响转化、留存和业务成功率?
2. 实验室数据(Lab)
Lighthouse、Chrome DevTools Performance 面板和 CI 性能测试使用固定或模拟的设备、网络和脚本环境,适合在开发和发布前复现问题。它们便于定位阻塞资源、长任务和布局问题,但不等于真实用户的最终体验。
实验室报告受到浏览器版本、模拟网络、CPU 限制、缓存、第三方脚本和运行时机影响。不要只追逐一个分数,应该同时关注 RUM、CrUX、错误率和业务指标。
理解 Navigation Timing API
Level 1:历史接口
早期文章通常通过 window.performance.timing 获取从 navigationStart 到 loadEventEnd 的一组时间戳,也会使用 performance.navigation 判断重载、前进后退等导航类型。
这些接口在很多浏览器中仍可能存在,但已经属于旧 API。performance.timing 返回的是以 Unix 时间戳表示的低精度、固定字段对象,performance.navigation 也已经被新的导航条目替代。新代码不应再把它们作为主要采集方式。
Level 2:当前推荐方式
Navigation Timing Level 2 把文档导航表示成一个 PerformanceNavigationTiming 条目,可以通过下面的方式读取:
const navigation = performance.getEntriesByType('navigation')[0]
if (navigation) {
console.log(navigation.type) // navigate、reload 或 back_forward
console.log(navigation.activationStart) // 从 prerender 激活时通常大于 0
console.log(navigation.startTime) // 通常为 0,单位是毫秒
console.log(navigation.responseStart)
console.log(navigation.domContentLoadedEventEnd)
console.log(navigation.loadEventEnd)
}
Level 2 的时间通常是相对于当前文档导航开始点的高精度时间,不是 Unix 时间戳。不要把它与 performance.timing 的绝对时间戳直接相减。浏览器还可能因为跨域重定向、隐私保护或资源策略而降低部分时间的精度。


常用字段
| 字段 | 含义 |
|---|---|
redirectStart / redirectEnd | 重定向链的开始和结束 |
fetchStart | 浏览器开始获取当前文档的时间 |
domainLookupStart / domainLookupEnd | DNS 查询开始和结束 |
connectStart / connectEnd | 建立网络连接的开始和结束;复用连接时可能接近 0 |
secureConnectionStart | TLS 握手开始;非 HTTPS 或无法提供时可能为 0 |
requestStart | 浏览器开始请求文档的时间 |
responseStart | 收到响应首字节的时间 |
responseEnd | 当前文档响应接收完成的时间 |
domInteractive | 文档解析到可交互状态附近的时间,不等于首屏完成 |
domContentLoadedEventEnd | DOMContentLoaded 处理完成的时间 |
loadEventEnd | load 事件处理完成的时间 |
type | 标准值为 navigate、reload 或 back_forward |
activationStart | 文档从 prerender 状态激活的时间,普通导航通常为 0 |
transferSize / encodedBodySize | 网络传输和编码后的响应大小,受缓存和跨源策略影响 |
采集页面性能的关键指标
下面的函数使用 Level 2 条目计算常见的导航阶段耗时。它刻意使用 null 表示浏览器尚未提供或该阶段没有发生,而不是把缺失值当成 0。
function diff(end, start) {
if (typeof end !== 'number' || typeof start !== 'number') {
return null
}
if (end === 0 && start === 0) {
return null
}
return Math.max(0, end - start)
}
function getNavigationMetrics() {
const navigation = performance.getEntriesByType('navigation')[0]
if (!navigation) {
return null
}
return {
navigationType: navigation.type,
redirectTime: diff(navigation.redirectEnd, navigation.redirectStart),
dnsTime: diff(navigation.domainLookupEnd, navigation.domainLookupStart),
tcpTime: diff(navigation.connectEnd, navigation.connectStart),
tlsTime: navigation.secureConnectionStart > 0
? diff(navigation.connectEnd, navigation.secureConnectionStart)
: null,
requestTime: diff(navigation.responseStart, navigation.requestStart),
responseTime: diff(navigation.responseEnd, navigation.responseStart),
// 这是“导航开始到首字节”的页面级 TTFB 口径。
ttfb: diff(navigation.responseStart, navigation.startTime),
domInteractive: diff(navigation.domInteractive, navigation.startTime),
domContentLoaded: diff(
navigation.domContentLoadedEventEnd,
navigation.startTime
),
load: diff(navigation.loadEventEnd, navigation.startTime),
transferSize: navigation.transferSize,
encodedBodySize: navigation.encodedBodySize,
decodedBodySize: navigation.decodedBodySize
}
}
console.table(getNavigationMetrics())
这里的 ttfb 是从导航开始到收到响应首字节的页面级口径,可能包含重定向、连接建立、缓存判断和 Service Worker 等阶段。不同系统对 TTFB 的起止点可能有不同定义,采集平台必须把口径写清楚,不能把它简单等同于“后端代码执行时间”。
navigationStart 和 fetchStart 怎么选?
原文建议使用 fetchStart,因为它可以排除旧页面卸载等不属于当前文档网络请求的时间。这个思路在做网络阶段分析时仍然有价值,但要根据指标目的选择起点:
- 想描述用户从开始导航到看到结果的完整过程,使用导航条目的
startTime(通常为 0); - 想只分析浏览器开始获取当前文档后的网络过程,可以使用
fetchStart; - 想分析旧页面卸载、重定向和新文档请求的全链路,应单独记录各阶段,不要只给出一个“页面加载时间”。
Level 2 中没有可直接用于减法的 navigationStart 字段。若需要兼容旧浏览器,读取 performance.timing.navigationStart 时应使用整套 Level 1 字段,并且不要将两种时间坐标混用。
首字节时间(TTFB)
首字节时间表示浏览器从导航或请求开始,到收到响应第一个字节的时间。它可以帮助发现 DNS、连接、TLS、缓存、代理和服务端响应慢的问题,但不能单独说明后端哪一段代码慢,也不能代表用户已经看到内容。
应结合以下信息判断:
- 是否命中浏览器缓存、HTTP 缓存或 Service Worker;
- 是否发生重定向;
- DNS、TCP 和 TLS 是否耗时异常;
- 源站、网关、数据库或上游服务的服务端日志;
Server-Timing响应头提供的后端阶段数据。
白屏时间、FCP 和首屏时间
“白屏时间”没有统一的 Web 标准定义。原文使用 domLoading、domInteractive 近似白屏,这些字段描述的是文档解析状态,不代表屏幕已经绘制出可见内容:
domInteractive表示文档进入可交互阶段附近,不能直接当作白屏结束;- FCP(First Contentful Paint)表示浏览器首次绘制文本、图片、非白色 canvas 或其他内容的时间,更适合描述“开始有内容”;
- 首屏完成时间取决于页面结构和视觉目标,不能只用
DOMContentLoaded或load推断; - FMP(First Meaningful Paint)没有稳定统一的跨浏览器定义,不建议作为核心通用指标。
可以通过 Paint Timing API 获取 FP/FCP:
const paintObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-paint' || entry.name === 'first-contentful-paint') {
console.log(entry.name, entry.startTime)
}
}
})
paintObserver.observe({ type: 'paint', buffered: true })
FCP 仍然只是“出现第一块内容”,不一定等于用户真正看到主要内容。对于业务首屏,可以在应用完成关键渲染后自行打点:
performance.mark('app-mounted')
const appMounted = performance.getEntriesByName('app-mounted')[0]
console.log('应用挂载耗时:', appMounted?.startTime)

Core Web Vitals
当前最重要的用户体验指标集中在加载、交互和视觉稳定性:
| 指标 | 关注点 | 75 分位的良好目标 |
|---|---|---|
| LCP | 最大内容元素何时完成展示 | ≤ 2.5 秒 |
| INP | 用户交互到下一次绘制的响应延迟 | ≤ 200 毫秒 |
| CLS | 页面生命周期中的累积布局偏移 | ≤ 0.1 |
LCP、INP 和 CLS 是 Core Web Vitals,应优先使用真实用户数据评估。FCP、TTFB 和 TBT 是重要的辅助指标,其中 TBT 主要是实验室指标,用于诊断可能影响 INP 的长任务;它们不能取代 Core Web Vitals。
LCP 在页面隐藏或用户离开后可能停止更新,CLS 和 INP 也应按照官方库的生命周期规则采集。直接手写所有边界情况成本很高,生产环境更建议使用 web-vitals:
import { onCLS, onINP, onLCP } from 'web-vitals'
function sendMetric(metric) {
const body = JSON.stringify({
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
url: location.pathname
})
const sent = navigator.sendBeacon?.(
'/analytics/web-vitals',
new Blob([body], { type: 'application/json' })
)
if (!sent) {
fetch('/analytics/web-vitals', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true
}).catch(() => {})
}
}
onCLS(sendMetric)
onINP(sendMetric)
onLCP(sendMetric)
上报时不要直接携带完整查询字符串、用户输入、Cookie 或其他敏感信息。按照页面、版本、设备和网络做聚合,通常比保存每一个用户的完整 URL 更安全、更有分析价值。

Resource Timing 和其他诊断指标
Navigation Timing 只描述当前文档导航。要定位具体的 CSS、JavaScript、字体、图片、接口和第三方资源,可以使用 Resource Timing:
const resourceObserver = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log({
name: entry.name,
initiatorType: entry.initiatorType,
duration: entry.duration,
transferSize: entry.transferSize,
responseStatus: entry.responseStatus
})
}
})
resourceObserver.observe({ type: 'resource', buffered: true })
跨源资源如果没有合适的 Timing-Allow-Origin 响应头,浏览器会隐藏部分细节。采集资源时还应限制数量,避免把所有第三方 URL 和查询参数上传到监控平台。
常见的辅助指标包括:
- Long Tasks:主线程持续执行较长任务,可能阻塞输入和绘制;
- Event Timing:辅助分析交互延迟;
- Server-Timing:服务端通过响应头传回后端阶段耗时;
- Element Timing:对特定元素进行可选的渲染时间标记;
- 错误和资源失败:JavaScript 异常、Promise rejection、接口错误、图片失败和 CSP 报告;
- 页面生命周期:前后台切换、BFCache 恢复、冻结和恢复等。
SPA 盛行之后怎么监控?
Navigation Timing 只记录一次文档级导航。Vue、React 等单页应用在路由切换时通常不刷新文档,因此不会自动产生新的 Navigation Timing 条目。
应用应在路由开始和关键内容完成时打自定义标记:
function markRouteStart(routeName) {
performance.mark(`route-start:${routeName}`)
}
function measureRouteReady(routeName) {
const start = `route-start:${routeName}`
const end = `route-ready:${routeName}`
performance.mark(end)
performance.measure(`route-ready:${routeName}`, start, end)
}
// 在路由守卫开始时调用
markRouteStart('/products')
// 在关键内容渲染完成后调用
measureRouteReady('/products')
实际项目中要避免重复使用同一个 mark 名称,必要时加入请求 ID 或路由序号,并在采集后通过 performance.clearMarks()、performance.clearMeasures() 清理。还可以结合框架的 mounted、数据请求完成、图片加载完成和用户可交互状态定义“页面就绪”,不要把路由组件创建完成直接等同于用户已经看到完整页面。
浏览器正在推进软导航相关能力,但不同浏览器和框架的支持仍有差异。应用自己的路由打点在相当长时间内仍然是必要的。

数据上报方式
性能上报应该尽量不影响主流程。常见做法是:客户端采样和聚合少量字段,发送到独立的采集接口,再由服务端进入日志、时序数据库或数据仓库。
navigator.sendBeacon()
sendBeacon() 专门用于发送少量诊断和分析数据:
- 异步排队,不需要阻塞页面卸载;
- 返回
true只表示浏览器成功把数据加入传输队列,不代表服务端已经处理成功; - 请求方法通常是
POST; - 待发送数据总量大约限制在 64 KiB,超过时应拆分或使用其他方式;
- 对于页面离开时的采集,优先监听
visibilitychange,不要依赖unload或beforeunload。
function reportPageEnd(data) {
const body = JSON.stringify(data)
const blob = new Blob([body], { type: 'application/json' })
const queued = navigator.sendBeacon('/analytics/page-end', blob)
if (!queued) {
fetch('/analytics/page-end', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true
}).catch(() => {})
}
}
let pageEndReported = false
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && !pageEndReported) {
pageEndReported = true
reportPageEnd({
path: location.pathname,
metrics: getNavigationMetrics()
})
}
})
hidden 既可能表示用户离开页面,也可能只是切换到后台,因此这里把它当作“本次页面快照的最后一次上报时机”,并用标记保证每个文档只发送一次。需要区分后台停留和真正关闭时,应在服务端结合心跳、时间戳和会话状态判断。若页面需要兼容不支持 visibilitychange 的环境,可以把 pagehide 作为补充,但不要把 unload 当作可靠的发送时机。unload 还可能影响浏览器的 BFCache。
图片 GET 上报(历史降级方案)
原文使用 Image 标签发送 GET 请求,这种方式不需要处理传统 AJAX 跨域,但 URL 长度、编码、缓存和隐私问题都比较明显。它只能作为非常小的兼容性降级,不适合传输完整性能对象:
function reportByImage(data) {
const image = new Image()
const query = encodeURIComponent(JSON.stringify(data))
image.src = `/analytics/pixel.gif?data=${query}`
}
生产环境优先使用同源 sendBeacon 或 fetch({ keepalive: true }),服务端还应设置正确的 CORS、认证、限流和数据保留策略。
一个完整的性能监控闭环
一个低侵入、可维护的方案可以分成以下几层:
- 采集层:Navigation、Resource、Paint、Long Task、Web Vitals、错误和自定义 mark;
- 保护层:采样、节流、字段白名单、脱敏、大小限制和失败静默;
- 传输层:优先 Beacon,失败后使用
fetch keepalive,必要时才使用图片降级; - 服务端:校验字段、鉴权、限流、去重和存储;
- 分析层:按 p50/p75/p95、设备、网络、地区、版本和路由聚合;
- 告警层:设置绝对阈值、环比回归和版本发布对比;
- 优化层:回到资源、网络、服务端、主线程、布局和业务代码逐项修复。
指标不是越多越好。建议先从 LCP、INP、CLS、TTFB、FCP、资源失败率和 JavaScript 错误率开始,再根据业务问题增加指标。
参考资料
- MDN:PerformanceNavigationTiming
- W3C:Navigation Timing Level 2
- MDN:PerformanceObserver
- MDN:Navigator.sendBeacon()
- MDN:visibilitychange 事件
- web.dev:Web Vitals
- GoogleChrome:web-vitals
- Chrome Developers:Lighthouse