技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTBR

10分钟彻底搞懂前端页面性能监控

所属馆藏
Browser
文件格式
Markdown
原始路径
Browser/01-10分钟彻底搞懂前端页面性能监控
本文目录10 个章节
  1. 前言
  2. 为什么要监控页面性能?
  3. 先区分两类性能数据
  4. 理解 Navigation Timing API
  5. 采集页面性能的关键指标
  6. Resource Timing 和其他诊断指标
  7. SPA 盛行之后怎么监控?
  8. 数据上报方式
  9. 一个完整的性能监控闭环
  10. 参考资料

10分钟彻底搞懂前端页面性能监控

Category(分类): Browser Status: 暂缓了解

前言

前端页面性能是用户体验的重要组成部分。本文保留原文中基于阿里 UC 岳鹰全景监控平台 的思路:使用浏览器 Performance API 采集指标,再通过 navigator.sendBeacon() 等方式低侵入上报。

原文发布时主要介绍 Navigation Timing Level 1、performance.timingunload 阶段上报。今天这些 API 仍有历史价值,但新项目应该优先使用 Navigation Timing Level 2、PerformanceObserver、Core Web Vitals 和 visibilitychange。另外,实验室工具(例如 Lighthouse)与真实用户监控(RUM)回答的是不同问题,不能互相替代。

前端性能监控示意图

为什么要监控页面性能?

页面打开慢、交互卡顿或布局不断跳动,都会直接影响用户完成任务的概率。移动端设备的 CPU、内存和网络差异尤其明显,同一个版本在开发者电脑上很快,在低端手机或弱网环境中却可能完全不同。

性能还会随着版本迭代逐渐衰减:依赖增多、第三方脚本增加、图片变大、接口变慢、组件渲染变复杂,都可能让用户体验悄悄变差。因此性能工作不能只做一次优化,而应该形成“采集—分析—改进—回归”的闭环:

  1. 持续采集真实用户数据;
  2. 按页面、版本、设备、网络、地区和运营商分组;
  3. 发现异常后定位到网络、资源、主线程或业务代码;
  4. 优化后通过实验室报告和真实用户数据验证;
  5. 对关键指标设置阈值、趋势告警和版本回归检测。

先区分两类性能数据

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 获取从 navigationStartloadEventEnd 的一组时间戳,也会使用 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 的绝对时间戳直接相减。浏览器还可能因为跨域重定向、隐私保护或资源策略而降低部分时间的精度。

Navigation Timing Level 1 处理模型

Navigation Timing Level 2 处理模型

常用字段

字段含义
redirectStart / redirectEnd重定向链的开始和结束
fetchStart浏览器开始获取当前文档的时间
domainLookupStart / domainLookupEndDNS 查询开始和结束
connectStart / connectEnd建立网络连接的开始和结束;复用连接时可能接近 0
secureConnectionStartTLS 握手开始;非 HTTPS 或无法提供时可能为 0
requestStart浏览器开始请求文档的时间
responseStart收到响应首字节的时间
responseEnd当前文档响应接收完成的时间
domInteractive文档解析到可交互状态附近的时间,不等于首屏完成
domContentLoadedEventEndDOMContentLoaded 处理完成的时间
loadEventEndload 事件处理完成的时间
type标准值为 navigatereloadback_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 的起止点可能有不同定义,采集平台必须把口径写清楚,不能把它简单等同于“后端代码执行时间”。

原文建议使用 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 标准定义。原文使用 domLoadingdomInteractive 近似白屏,这些字段描述的是文档解析状态,不代表屏幕已经绘制出可见内容:

  • domInteractive 表示文档进入可交互阶段附近,不能直接当作白屏结束;
  • FCP(First Contentful Paint)表示浏览器首次绘制文本、图片、非白色 canvas 或其他内容的时间,更适合描述“开始有内容”;
  • 首屏完成时间取决于页面结构和视觉目标,不能只用 DOMContentLoadedload 推断;
  • 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)

Navigation Timing 指标示意图

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、数据请求完成、图片加载完成和用户可交互状态定义“页面就绪”,不要把路由组件创建完成直接等同于用户已经看到完整页面。

浏览器正在推进软导航相关能力,但不同浏览器和框架的支持仍有差异。应用自己的路由打点在相当长时间内仍然是必要的。

SPA 页面性能监控示意图

数据上报方式

性能上报应该尽量不影响主流程。常见做法是:客户端采样和聚合少量字段,发送到独立的采集接口,再由服务端进入日志、时序数据库或数据仓库。

sendBeacon() 专门用于发送少量诊断和分析数据:

  • 异步排队,不需要阻塞页面卸载;
  • 返回 true 只表示浏览器成功把数据加入传输队列,不代表服务端已经处理成功;
  • 请求方法通常是 POST
  • 待发送数据总量大约限制在 64 KiB,超过时应拆分或使用其他方式;
  • 对于页面离开时的采集,优先监听 visibilitychange,不要依赖 unloadbeforeunload
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}`
}

生产环境优先使用同源 sendBeaconfetch({ keepalive: true }),服务端还应设置正确的 CORS、认证、限流和数据保留策略。

一个完整的性能监控闭环

一个低侵入、可维护的方案可以分成以下几层:

  1. 采集层:Navigation、Resource、Paint、Long Task、Web Vitals、错误和自定义 mark;
  2. 保护层:采样、节流、字段白名单、脱敏、大小限制和失败静默;
  3. 传输层:优先 Beacon,失败后使用 fetch keepalive,必要时才使用图片降级;
  4. 服务端:校验字段、鉴权、限流、去重和存储;
  5. 分析层:按 p50/p75/p95、设备、网络、地区、版本和路由聚合;
  6. 告警层:设置绝对阈值、环比回归和版本发布对比;
  7. 优化层:回到资源、网络、服务端、主线程、布局和业务代码逐项修复。

指标不是越多越好。建议先从 LCP、INP、CLS、TTFB、FCP、资源失败率和 JavaScript 错误率开始,再根据业务问题增加指标。

参考资料


457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS