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

显示模式

登录
ARCHIVE DOCUMENTBR

一文摸清前端监控自研实践:从页面性能采集到数据上报

所属馆藏
Browser
文件格式
Markdown
原始路径
Browser/08-一文摸清前端监控自研实
本文目录17 个章节
  1. 一、为什么要自建前端监控?
  2. 二、性能是相对的,指标必须可量化
  3. 三、Navigation Timing:从旧接口到 Level 2
  4. 四、以用户为中心的指标
  5. 五、使用 web-vitals 采集现代指标
  6. 六、数据暂存、上报与生命周期
  7. 七、技术指标:正确计算 Navigation Timing
  8. 八、静态资源加载与瀑布分析
  9. 九、不要用简单规则计算缓存命中率
  10. 十、PerformanceObserver 的资源采集
  11. 十一、FMP、首屏和业务可感知时间
  12. 十二、SPA 路由切换不能只看 Navigation Timing
  13. 十三、监控平台如何从数据得到指标?
  14. 十四、采样、隐私和可靠性边界
  15. 十五、推荐的落地顺序
  16. 十六、总结
  17. 参考资料

一文摸清前端监控自研实践:从页面性能采集到数据上报

Category(分类): Browser Status: 已更新

原文主要介绍团队自建前端性能监控、Navigation Timing、FP/FCP/FMP/LCP/FID/CLS、资源瀑布和缓存命中率。本文尽量保留这些主线,并结合当前标准补充 INP、Navigation Timing Level 2、真实用户监控(RUM)、SPA 路由、隐私、采样和可靠上报。

本文只讨论页面性能监控。行为监控、错误监控、接口监控可以复用同一套采集和上报基础设施,但应分别定义数据模型、权限和告警规则。

一、为什么要自建前端监控?

前端监控通常要回答两个问题:

  1. 如何及时发现问题?
  2. 如何快速定位问题?

一个完整的平台可能包含:

  • 页面性能:导航阶段、资源加载、Core Web Vitals、长任务和用户体验;
  • 用户行为:PV、UV、路由、来源和经过授权的业务埋点;
  • 接口调用:请求成功率、耗时、状态码和链路关联;
  • 页面稳定性:JavaScript 异常、资源错误和框架错误;
  • 数据上报与分析:采样、脱敏、聚合、告警和版本对比。

自建方案的价值通常不是“所有功能都自己重新实现”,而是:

  • 按团队业务定义用户、设备、版本和组织维度;
  • 接入现有告警、日志、链路追踪和发布系统;
  • 把性能、错误、接口和用户行为做关联分析;
  • 对敏感字段实施自己的采样、脱敏和留存策略。

如果团队没有这些定制需求,Sentry、商业 RUM 或云监控产品可能更省维护成本。自研之前应先明确数据规模、隐私边界、SLA、告警责任人和长期运维成本。

原文系列链接

二、性能是相对的,指标必须可量化

同一个页面在不同用户那里可能表现不同:

  • 设备性能、网络类型、内存压力、浏览器版本不同;
  • 缓存命中、CDN 节点、服务端响应和地理位置不同;
  • 页面可能很快显示骨架屏,但真正内容或交互仍然很慢;
  • 首次访问、刷新、前进后退缓存和 SPA 路由切换的生命周期不同。

因此,“页面打开很快”不是可操作的结论。监控需要把体验拆成可计算指标,并按照设备、网络、地域、版本、页面和发布批次统计分布。Core Web Vitals 通常以真实用户数据的第 75 百分位作为通过参考,而不是只看平均值或某个开发者设备上的一次结果。

三、Navigation Timing:从旧接口到 Level 2

Navigation Timing 记录当前文档导航的各个时间点。原文使用 performance.timingperformance.navigation,它们仍可用于理解历史文章,但新代码应优先使用 PerformanceNavigationTiming

const navigation = performance.getEntriesByType('navigation')[0]

if (navigation) {
  console.log(navigation.type) // navigate、reload 或 back_forward
  console.log(navigation.responseStart)
  console.log(navigation.domContentLoadedEventEnd)
  console.log(navigation.loadEventEnd)
  console.log(navigation.activationStart) // prerender 激活信息,旧浏览器可能没有
}

Navigation Timing Level 1 处理模型

Navigation Timing Level 2 处理模型

Level 2 的时间通常是相对于当前导航开始点的高精度时间,不是 Unix 时间戳。不同字段可能因为缓存、Service Worker、跨域策略和隐私保护而为 0、缺失或降低精度,采集端应使用 null 表示“不可用”,不要把缺失值当成 0。

旧接口的定位

const legacyTiming = performance.timing
const legacyNavigation = performance.navigation

performance.timing 是 Navigation Timing Level 1 的旧接口;performance.navigation 也已经由导航条目取代。可以为旧浏览器保留降级,但不要把 Level 1 与 Level 2 的时间坐标直接混算:前者是 Unix 时间戳,后者通常从 startTime = 0 开始。

四、以用户为中心的指标

1. FP 和 FCP

  • FP(First Paint):浏览器首次绘制的时间,可能只是非默认背景或其他视觉变化;它不是 Core Web Vital;
  • FCP(First Contentful Paint):首次绘制文本、图片、非空白 canvas 或 SVG 等内容的时间;它比“页面完全加载”早得多,也不等于首屏完成。

可以使用 Paint Timing 条目读取:

const paints = performance.getEntriesByType('paint')
const fp = paints.find(entry => entry.name === 'first-paint')
const fcp = paints.find(entry => entry.name === 'first-contentful-paint')

console.log({
  fp: fp?.startTime ?? null,
  fcp: fcp?.startTime ?? null
})

浏览器也可能在页面加载完成前提供这些条目,因此不应只在 load 事件后才开始监听。

2. LCP:最大内容绘制

LCP 关注视口内最大的图片、文本块或其他候选内容何时完成绘制。它更接近用户感知的主要内容何时出现,但不能理解为“页面已经完全加载”。LCP 可能在更大的候选元素出现时更新,也可能受图片加载、字体、客户端渲染、用户交互和页面隐藏影响。

LCP 候选内容示意

当前常见参考阈值是:

  • 不超过 2.5 秒:良好;
  • 大于 2.5 秒且不超过 4 秒:需要改进;
  • 大于 4 秒:较差。

这些阈值应结合真实用户第 75 百分位和业务场景使用。

3. FID 是历史指标,INP 是当前交互指标

原文介绍的 FID(First Input Delay)只测量页面第一次交互的输入延迟。FID 已不再是 Core Web Vital,INP(Interaction to Next Paint)从 2024 年 3 月起取代了它。

  • FID:第一次交互从输入到事件处理开始的延迟;
  • INP:观察页面生命周期内符合条件的多次交互,关注交互输入到下一次绘制之间的响应时间,更能反映持续交互体验。

FID 历史指标示意

FID 的历史阈值约为 100ms;INP 当前常见参考阈值为:

  • 不超过 200ms:良好;
  • 大于 200ms 且不超过 500ms:需要改进;
  • 大于 500ms:较差。

采集新项目时应优先使用 onINP,仍保留 FID 数据时要明确标注为历史指标,避免把两个指标混为一谈。

4. CLS:累计布局偏移

CLS 衡量页面生命周期内意外布局偏移造成的视觉稳定性问题。它不是简单把所有偏移无条件相加,而是按会话窗口计算:相邻偏移间隔不超过 1 秒、同一窗口总时长不超过 5 秒时归为一组,最终取窗口得分最大值。用户主动操作导致的部分偏移会按规范排除。

CLS 布局偏移示意

当前常见参考阈值为:

  • 不超过 0.1:良好;
  • 大于 0.1 且不超过 0.25:需要改进;
  • 大于 0.25:较差。

CLS 良好阈值示意

常见优化包括为图片、广告和嵌入内容预留尺寸,避免在页面顶部插入无预留内容,以及在字体加载时减少文字布局跳动。

五、使用 web-vitals 采集现代指标

手动处理所有兼容性、页面隐藏、BFCache、SPA 和指标最终值比较容易出错。新项目可以使用 web-vitals

import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'

const metrics = new Map()

function record(metric) {
  metrics.set(metric.name, {
    id: metric.id,
    value: metric.value,
    delta: metric.delta,
    rating: metric.rating,
    navigationType: metric.navigationType
  })
}

onFCP(record)
onLCP(record)
onINP(record)
onCLS(record)
onTTFB(record)

web-vitals 会在指标更新或适合上报的生命周期时机调用回调。LCP、CLS 和 INP 可能在页面生命周期内更新,服务端应按 id、页面会话和指标名去重或保存最后值。库的版本会变化,生产项目应锁定版本并阅读对应版本的升级说明。

如果需要定位 INP 慢在哪里,可以评估 web-vitals/attribution 构建,但应在确实需要诊断信息时再增加 payload 大小。不要把完整 DOM、用户输入内容或含敏感信息的选择器直接上报。

六、数据暂存、上报与生命周期

1. 统一上报函数

下面的示例只展示上报边界,实际系统还应增加鉴权、采样、重试、脱敏、压缩和服务端限流:

function sendMetric(payload) {
  const body = JSON.stringify(payload)
  const blob = new Blob([body], { type: 'application/json' })

  if (navigator.sendBeacon?.('/rum/vitals', blob)) {
    return true
  }

  return fetch('/rum/vitals', {
    method: 'POST',
    body,
    headers: { 'Content-Type': 'application/json' },
    keepalive: true,
    credentials: 'same-origin'
  }).then(() => true).catch(() => false)
}

sendBeacon() 的返回值只表示浏览器是否接受了发送请求,不代表服务端已经成功处理。Beacon 适合小 payload,不能把完整资源列表、堆快照或大量日志塞进一次请求;超出限制时应批量、压缩或丢弃低优先级数据。

2. 页面隐藏时发送最后一次快照

let sent = false

function reportSnapshot() {
  if (sent) return
  sent = true

  sendMetric({
    type: 'web-vitals',
    url: location.href,
    metrics: Object.fromEntries(metrics)
  })
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    reportSnapshot()
  }
})

hidden 可能只是用户切换到后台,不等于页面真正关闭;这里将它当作当前文档的最后可靠上报时机。对需要长时间会话的应用,应结合心跳、页面可见性、服务端时间戳和会话状态分析,不要把一次隐藏事件解释成用户已经离开。

pagehide 可以作为兼容或补充时机,但不应依赖 unload。页面可能进入 BFCache,unload 也可能影响缓存资格。再次从 BFCache 返回时,应使用 pageshowevent.persisted 区分页面恢复场景。

3. 数据模型建议

每条性能数据至少应包含:

const payload = {
  schemaVersion: 1,
  type: 'web-vitals',
  page: '/home',
  release: '2026.08.18',
  metric: 'LCP',
  value: 1840,
  rating: 'good',
  metricId: 'unique-id-from-library',
  navigationType: 'navigate',
  sampledAt: Date.now()
}

建议服务端补充接收时间、数据中心和匿名设备/网络分类。URL 查询参数可能包含 token、手机号、搜索词等敏感信息,采集前应规范化 URL、移除凭证并进行脱敏。不要把访问令牌、密码、完整 Cookie、用户输入或堆快照上传到公共监控系统。

七、技术指标:正确计算 Navigation Timing

以下辅助函数避免把不存在的时间点当成 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 entry = performance.getEntriesByType('navigation')[0]
  if (!entry) return null

  return {
    navigationType: entry.type,
    activationStart: entry.activationStart || 0,
    redirect: diff(entry.redirectEnd, entry.redirectStart),
    dns: diff(entry.domainLookupEnd, entry.domainLookupStart),
    tcp: diff(entry.connectEnd, entry.connectStart),
    tls: entry.secureConnectionStart > 0
      ? diff(entry.connectEnd, entry.secureConnectionStart)
      : null,
    request: diff(entry.responseStart, entry.requestStart),
    ttfbFromNavigationStart: diff(entry.responseStart, entry.startTime),
    download: diff(entry.responseEnd, entry.responseStart),
    domParse: diff(entry.domInteractive, entry.responseEnd),
    domContentLoaded: diff(entry.domContentLoadedEventEnd, entry.startTime),
    load: diff(entry.loadEventEnd, entry.startTime),
    transferSize: entry.transferSize,
    encodedBodySize: entry.encodedBodySize,
    decodedBodySize: entry.decodedBodySize
  }
}

关键时间点和时间段

以技术为中心的 Performance Timeline 示意

字段推荐含义计算方式和注意事项
FP首次绘制paint 条目读取,不应使用 responseEnd - fetchStart 代替
FCP首次内容绘制paint 条目读取
TTFB页面导航到响应首字节的页面级口径常用 responseStart - startTime;若只分析请求阶段,也可使用 responseStart - requestStart,必须写清口径
DNSDNS 查询耗时domainLookupEnd - domainLookupStart,缓存或复用时可能为 0
TCP连接建立耗时connectEnd - connectStart,复用连接时可能接近 0
TLSTLS 握手耗时只有 secureConnectionStart > 0 时才计算 connectEnd - secureConnectionStart
DOMContentLoadedDOMContentLoaded 处理完成domContentLoadedEventEnd - startTime
Loadload 事件处理完成loadEventEnd - startTime,不代表页面已经可交互或体验良好
DOM 解析近似值响应结束到 DOM 可交互附近domInteractive - responseEnd,需要处理缺失和负值

原文中的 TTI(Time to Interactive)不能继续作为简单的 domInteractive - fetchStart。TTI 不是 Core Web Vital,也不是一个可以仅凭 Navigation Timing 两个字段准确得到的标准指标。若业务仍要保留旧报表,应标注为“历史近似值”;新项目更应使用 INP、长任务和业务可交互标记。

同样,responseStart - domainLookupStart 不是通用的“首包时间”:它把 DNS 阶段也混进去了,而且在缓存、连接复用和 Service Worker 场景下解释不同。页面级 TTFB 的常见口径是 responseStart - startTime,服务端响应阶段则需要另行定义。

八、静态资源加载与瀑布分析

Resource Timing 可以记录脚本、样式、图片、字体、XHR/fetch 等资源的时间信息:

const resources = performance.getEntriesByType('resource')

const rows = resources.map(resource => ({
  name: resource.name,
  initiatorType: resource.initiatorType,
  startTime: resource.startTime,
  responseEnd: resource.responseEnd,
  dns: diff(resource.domainLookupEnd, resource.domainLookupStart),
  connect: diff(resource.connectEnd, resource.connectStart),
  tls: resource.secureConnectionStart > 0
    ? diff(resource.connectEnd, resource.secureConnectionStart)
    : null,
  request: diff(resource.responseStart, resource.requestStart),
  ttfb: diff(resource.responseStart, resource.startTime),
  contentDownload: diff(resource.responseEnd, resource.responseStart),
  transferSize: resource.transferSize,
  encodedBodySize: resource.encodedBodySize,
  decodedBodySize: resource.decodedBodySize
}))

console.table(rows)

静态资源瀑布分析示意

资源加载详情示意

资源条目也可能受到以下因素影响:

  • 跨域资源没有 Timing-Allow-Origin 时,详细网络字段可能被置为 0;
  • transferSize、编码大小和解码大小会受到缓存、压缩、跨域和浏览器策略影响;
  • PerformanceResourceTiming 有缓冲区,资源很多时要使用 PerformanceObserver,并按需扩大缓冲区;
  • 资源名字可能包含用户信息或签名参数,上报前应规范化。

跨域资源和 Timing-Allow-Origin

对于可控 CDN,响应可以按实际站点设置:

Timing-Allow-Origin: https://www.example.com

如果资源确实允许所有站点读取时序信息,也可以使用:

Timing-Allow-Origin: *

是否使用通配符应结合资源是否包含敏感信息、凭证和部署策略决定。该响应头只解决 Resource Timing 的读取权限,不会绕过 CORS,也不会允许 JavaScript 读取跨域响应正文。

九、不要用简单规则计算缓存命中率

原文使用“duration === 0transferSize !== 0 就是缓存命中”的规则,这在不同浏览器、缓存层和跨域场景下并不可靠:

  • 内存缓存、磁盘缓存、Service Worker、预加载和 304 的表现可能不同;
  • 跨域资源没有 Timing-Allow-Origin 时也可能显示为 0;
  • duration === 0 可能是精度、时间戳或资源极小造成的结果;
  • transferSize === 0 只能作为线索,不能独立证明缓存命中。

更稳妥的做法是:

  1. 对同源资源结合 transferSizeencodedBodySize、响应头和服务端日志分析;
  2. 使用 PerformanceResourceTimingdeliveryType(浏览器支持时)作为额外线索;
  3. 由 CDN/服务端提供命中、回源和缓存状态统计;
  4. 将“浏览器侧推测值”和“服务端确认值”分成两个指标,避免混淆。
function classifyResource(resource) {
  if (resource.deliveryType) {
    return resource.deliveryType
  }

  if (resource.transferSize === 0) {
    return '可能来自缓存、Service Worker、跨域限制或未传输'
  }

  return 'network-or-revalidated'
}

十、PerformanceObserver 的资源采集

const resourceRows = []

function addResource(entry) {
  resourceRows.push({
    name: entry.name,
    initiatorType: entry.initiatorType,
    startTime: entry.startTime,
    responseEnd: entry.responseEnd,
    ttfb: diff(entry.responseStart, entry.startTime),
    contentDownload: diff(entry.responseEnd, entry.responseStart),
    transferSize: entry.transferSize
  })
}

if (PerformanceObserver.supportedEntryTypes?.includes('resource')) {
  const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      addResource(entry)
    }
  })

  observer.observe({ type: 'resource', buffered: true })
}

不要无限制地把所有资源条目一直保存在内存中。可以按资源类型、耗时、传输大小和采样率筛选,或在页面隐藏时只上报最慢的若干项。

十一、FMP、首屏和业务可感知时间

原文把 FMP(First Meaningful Paint)描述为“页面元素增量最大的点”,并尝试通过 MutationObserver 推断。这个思路可以用于历史项目,但 FMP 没有稳定、跨浏览器一致的标准定义,简单统计 DOM 增量也无法判断内容是否在视口内、是否真正重要或是否已经完成字体和图片绘制。

如果业务确实有“首屏完成”的定义,建议使用显式业务标记:

performance.mark('hero-content-ready')

const navigation = performance.getEntriesByType('navigation')[0]
const ready = performance.getEntriesByName('hero-content-ready')[0]
const duration = ready
  ? ready.startTime - (navigation?.startTime ?? 0)
  : null

console.log(duration)

也可以在首屏主图、核心列表或关键接口完成后记录自定义时间。自定义指标必须写清:起点、终点、触发条件、是否受用户操作影响以及失败时如何处理。LCP 可作为通用体验指标,但不应替代所有业务“可用”定义。

十二、SPA 路由切换不能只看 Navigation Timing

Navigation Timing 主要描述文档导航。SPA 路由切换不会创建新的文档导航条目,需要自己在路由钩子中打点:

function startRouteMeasure(routeName) {
  const mark = `route-start:${routeName}:${performance.now()}`
  performance.mark(mark)
  return mark
}

function finishRouteMeasure(mark, routeName) {
  const end = `route-end:${routeName}:${performance.now()}`
  performance.mark(end)
  const measure = performance.measure(`route:${routeName}`, mark, end)

  performance.clearMarks(mark)
  performance.clearMarks(end)
  performance.clearMeasures(measure.name)

  return measure.duration
}

实际项目还应把路由切换与:

  • 页面骨架、关键数据和主要图片何时可见;
  • 路由切换期间的长任务和 INP;
  • 请求失败、取消和重复导航;
  • 页面隐藏、BFCache 和预渲染;

一起记录,而不是把“路由钩子执行完成”直接当成页面加载完成。

十三、监控平台如何从数据得到指标?

采集到的原始数据通常还不是最终指标,需要按时间窗口和维度聚合:

  • P75/P90/P95:观察长尾用户体验;Core Web Vitals 通常关注 P75;
  • 慢开比:先定义“慢”的页面类型、网络分组和阈值,再计算占比;
  • 跳出率:明确“页面完全加载前离开”的事件是否可靠,不能只用 unload 推断;
  • 多维分析:按发布版本、页面、地域、设备、网络、浏览器和登录状态切分;
  • 回归检测:比较同一页面同一网络分组在发布前后的分布,而不是拿所有页面混合比较。

告警应同时设置最小样本量、持续时间和去重策略。例如某个页面只有几名用户且其中一人网络极差,不应立即触发全站告警。

十四、采样、隐私和可靠性边界

自建监控最容易忽略的不是 API,而是数据治理:

  1. 采样:正常用户可以低采样,错误、极慢页面和关键版本可以提高采样;
  2. 脱敏:删除 URL 中的 token、手机号、搜索词、Cookie 和完整用户输入;
  3. 同意与合规:按业务地区和隐私政策处理设备标识、用户标识和跨站数据;
  4. 失败降级:上报失败不能阻塞页面交互,队列应有大小上限和过期策略;
  5. 版本化:给 payload 增加 schema 版本,避免前后端字段变化导致分析中断;
  6. 限流:服务端按来源、版本和 IP 做限流,防止异常脚本把监控接口打爆;
  7. 安全:监控接口也要鉴权、校验大小和内容类型,不能把它当作任意日志入口。

十五、推荐的落地顺序

  1. 先用 web-vitals 采集 LCP、INP、CLS、FCP 和 TTFB;
  2. 再补充 Navigation Timing 和慢资源;
  3. 为关键 SPA 路由、核心接口和业务首屏增加自定义 performance.mark
  4. 统一使用 visibilitychangesendBeacon 和小 payload 上报;
  5. 按版本、页面、设备和网络建立 P75/P95 基线;
  6. 通过 Chrome DevTools、Lighthouse 和真实用户数据交叉验证;
  7. 最后再增加告警、异常关联和链路追踪,而不是一开始就采集所有字段。

十六、总结

  • 性能监控的目标是量化真实用户感受,而不是收集越多字段越好;
  • 新项目优先使用 PerformanceNavigationTimingPerformanceObserverweb-vitalsvisibilitychange
  • FID 已是历史指标,INP 是当前交互 Core Web Vital;
  • FP、FCP、LCP、CLS、TTFB、资源瀑布和自定义业务时间解决的是不同问题;
  • TTI、FMP 和缓存命中率不能用几个简单字段直接准确推导,应标注口径和限制;
  • 跨域资源需要 Timing-Allow-Origin 才能读取更完整的 Resource Timing;
  • 自研平台的核心难点在于数据模型、隐私、采样、聚合、告警和长期维护,而不仅是调用浏览器 API。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS