稀土掘金技术社区 02月21日
前端监控SDK:从基础到实践 (1. 性能监控)
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了前端监控中性能监控的关键技术。文章详细介绍了如何利用PerformanceObserver API来捕获网页资源加载的各项性能指标,包括DNS解析时间、TCP连接时间、重定向时间、首字节时间(TTFB)、请求协议、响应内容大小等。同时,还介绍了如何监控FCP、LCP、FP等核心性能指标,这些指标能够帮助开发者全面了解页面加载过程中的性能瓶颈,并为优化提供数据支撑。文章还分享了作者在开发开源项目monitor-sdk过程中的实践经验,旨在帮助读者从0到1搭建自己的前端监控平台。

⏱️**资源加载监控**:通过PerformanceObserver API捕获资源加载数据,过滤SDK自身请求,提取关键性能指标如DNS解析时间、TCP连接时间、TTFB等,并进行批量上报,以便全面了解页面资源加载情况。

🎨**核心指标监控**:利用PerformanceObserver监控FCP(首次内容绘制)、LCP(最大内容渲染时间)、FP(首次绘制)等关键性能指标,这些指标反映了用户在页面加载过程中的视觉体验。

🚀**优化建议**:虽然FMP(首次有意义绘制)的统计方法尚无定论,但文章调研了一种基于DOM结构变化剧烈程度来估算FMP的方案,为进一步优化页面加载速度提供了思路。

原创 四十还是十四 2025-02-16 09:00 重庆

点击关注公众号,“技术干货” 及时达!

前言

在现代前端开发中,随着应用复杂度的提高和用户体验的精细化要求,「前端监控」已经成为开发者不可或缺的一项技能。从性能优化到错误跟踪,再到用户行为分析,构建一个完整的前端监控平台不仅能帮助团队快速发现问题,还能为业务决策提供可靠的数据支撑。

作为一名前端开发者,我是在实习过程中接触到的监控平台,一是感觉这相比业务开发来说更有技术含量一点,二是觉得在学习过程中能查漏补缺很多以前没注意到知识点,所以打算写一个前端监控的SDK学习一下。

在开发自己的开源项目 monitor-sdk 的过程中,深刻感受到前端监控的重要性与挑战。在这个系列文章中,我将通过「理论结合实践」的方式,带大家了解前端监控平台的核心知识点,包括:

在文章中,我会结合 monitor-sdk 的实现细节,和给大家学习搭建的demo来逐步拆解前端监控的各个模块,带领大家从 0 到 1 搭建属于自己的监控平台。

性能监控

首先声明,在性能监控中我们要监控的对象有:

网页性能数据统计方法调研

提到页面性能监控我相信很多人都见过这张图

那么要怎么统计这些数据呢?方法有二

方法一:PerformanceObserver (性能监测对象):

PerformanceObserver 用于监测性能度量事件,在浏览器的性能时间轴记录新的 performance entry 的时候将会被通知。

其中有个方法PerformanceObserver.observe():

指定监测的 entry types 的集合。当 performance entry 被记录并且是指定的 entryTypes 之一的时候,性能观察者对象的回调函数会被调用。

当监测到的时候可以调用PerformanceObserver.disconnect()停止接收 性能条目。

可以通过这个Api获取FP、FCP、LCP、CLS

方法二:PerformanceTiming

PerformanceTiming 接口是为保持向后兼容性而保留的传统接口,并且提供了在加载和使用当前页面期间发生的各种事件的性能计时信息。

可以通过只读属性window.performance.timing 获得实现该接口的一个对象。

这个api统计了浏览器从网址开始导航到 window.onload事件触发的时间点,比如请求开始的时间点——requestStart,响应结束的时间点——responseEnd,通过这些时间点我们可以计算出一些对页面加载质量有指导意见的时长,比如以下几个:

由于PerformanceTiming已经显示废弃,故放弃方法二选择方法一

在此之前需要先展示一下全局配置的数据结构

export const config = {  url: 'http://127.0.0.1:3000/api/data', // 上报地址  projectName: 'monitor', // 项目名称  appId: '123456', // 项目id  userId: '123456', // 用户id  isAjax: false, // 是否开启ajax上报  batchSize: 5, // 批量上报大小  containerElements: ['html', 'body', '#app', '#root'], // 容器元素  skeletonElements: [], // 骨架屏元素  reportBefore: () => {}, // 上报前回调  reportAfter: () => {}, // 上报后回调  reportSuccess: () => {}, // 上报成功回调  reportFail: () => {}, // 上报失败回调}

统计资源加载

我们上报资源加载的数据结构

type commonType = {    type: string // 类型    subType: string // 一级类型    timestamp: number}
export type PerformanceResourceType = commonType & { /** 资源的名称或 URL */ name: string /** DNS 查询所花费的时间,单位为毫秒 */ dns: number /** 请求的总持续时间,从开始到结束,单位为毫秒 */ duration: number /** 请求使用的协议,如 HTTP 或 HTTPS */ protocol: string /** 重定向所花费的时间,单位为毫秒 */ redirect: number /** 资源的大小,单位为字节 */ resourceSize: number /** 响应体的大小,单位为字节 */ responseBodySize: number /** 响应头的大小,单位为字节 */ responseHeaderSize: number /** 资源类型,如 "script", "css" 等 */ sourceType: string /** 请求开始的时间,通常是一个高精度的时间戳 */ startTime: number /** 资源的子类型,用于进一步描述资源 */ subType: string /** TCP 握手时间,单位为毫秒 */ tcp: number /** 传输过程中实际传输的字节大小,单位为字节 */ transferSize: number /** 首字节时间 (Time to First Byte),从请求开始到接收到第一个字节的时间,单位为毫秒 */
ttfb: number /** 类型,通常用于描述性能记录的类型,如 "performance" */ type: string /** 页面路径" */ pageUrl: string}

然后我们就可以用PerformanceObserver来捕获资源加载数据了

「主要流程」

    通过 PerformanceObserver 捕获资源加载数据。

    「对监控数据进行过滤,避免上报 SDK 自己的请求」。因为数据上报是sdk自己的行为它可能是ajax请求,那么它也会被PerformanceObserver捕获到,所以需要过滤掉。

    加工数据,提取资源的性能指标。

    「批量上报处理后的数据」。 在初始页面加载的时候会有很多资源数据被捕获,所以采用批量上传是很有必要的,后续再讲解「lazyReportBatch」

import { getConfig } from '../common/config'import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { PerformanceResourceType, resourceType } from '../types'
export function observerEvent() { const config = getConfig() const url = config.url const parsedUrl = new URL(url) const host = parsedUrl.host const entryHandler = (list: PerformanceObserverEntryList) => { const dataList: PerformanceResourceType[] = [] const entries = list.getEntries() for (let i = 0; i < entries.length; i++) { const resourceEntry = entries[i] as PerformanceResourceTiming
// 避免sdk自己发的请求又被上报无限循环 if (resourceEntry.name.includes(host)) { continue } const data: PerformanceResourceType = { type: TraceTypeEnum.performance, subType: resourceEntry.entryType, // 类型 name: resourceEntry.name, // 资源的名字 sourceType: resourceEntry.initiatorType, // 资源类型 duration: resourceEntry.duration, // 加载时间 dns: resourceEntry.domainLookupEnd - resourceEntry.domainLookupStart, // dns解析时间 tcp: resourceEntry.connectEnd - resourceEntry.connectStart, // tcp连接时间 redirect: resourceEntry.redirectEnd - resourceEntry.redirectStart, // 重定向时间 ttfb: resourceEntry.responseStart, // 首字节时间 protocol: resourceEntry.nextHopProtocol, // 请求协议 responseBodySize: resourceEntry.encodedBodySize, // 响应内容大小 responseHeaderSize: resourceEntry.transferSize - resourceEntry.encodedBodySize, // 响应头部大小 transferSize: resourceEntry.transferSize, // 请求内容大小 resourceSize: resourceEntry.decodedBodySize, // 资源解压后的大小 startTime: resourceEntry.startTime, // 资源开始加载的时间 pageUrl: window.location.href, // 页面地址 timestamp: new Date().getTime() } dataList.push(data) if (i === entries.length - 1) { const reportData: resourceType = { type: TraceTypeEnum.performance, // 类型 subType: TraceSubTypeEnum.resource, // 类型 resourceList: dataList, timestamp: new Date().getTime() } lazyReportBatch(reportData) } } }
const observer = new PerformanceObserver(entryHandler) observer.observe({ type: 'resource', buffered: true })}

然后是开始收集的时机

export default function observerEntries() {  if (document.readyState === 'complete') {    observerEvent()  } else {    const onLoad = () => {      observerEvent()      window.removeEventListener('load', onLoad, true)    }    window.addEventListener('load', onLoad, true)  }}

大家可以上demo项目自己启动一下,这就是一次批量上报的请求,箭头所指的地方就是本次上报的资源加载的数据



统计FCP、LOAD等数据

这也是同样使用PerformanceObserver

FCP(首次内容绘制)

「简介」:是指浏览器将第一个DOM渲染到屏幕的时间,可以是任何文本、图像、SVG等的时间。

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { PaintType } from '../types'
export default function observerFCP() { const entryHandler = (list: PerformanceObserverEntryList) => { for (const entry of list.getEntries()) { if (entry.name === 'first-contentful-paint') { observer.disconnect() const json = entry.toJSON() const reportData: PaintType = { ...json, type: TraceTypeEnum.performance, subType: TraceSubTypeEnum.fcp, pageUrl: window.location.href, timestamp: new Date().getTime() } // 发送数据 todo; lazyReportBatch(reportData) } } } // 统计和计算fcp的时间 const observer = new PerformanceObserver(entryHandler) // buffered: true 确保观察到所有paint事件 observer.observe({ type: 'paint', buffered: true })}

像其他的FP、LOAD、LCP都是类似的写法了

LCP(最大内容渲染时间)

「简介」:用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,另外该指标会在用户第一次交互后停止记录。

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { PaintType } from '../types'
export default function observerLCP() { const entryHandler = (list: PerformanceObserverEntryList) => { if (observer) { observer.disconnect() } for (const entry of list.getEntries()) { const json = entry.toJSON() const reportData: PaintType = { ...json, type: TraceTypeEnum.performance, subType: TraceSubTypeEnum.lcp, pageUrl: window.location.href, timestamp: new Date().getTime() } // 发送数据 todo; lazyReportBatch(reportData) } } // 统计和计算lcp的时间 const observer = new PerformanceObserver(entryHandler) // buffered: true 确保观察到所有paint事件 observer.observe({ type: 'largest-contentful-paint', buffered: true })}

FP(首次绘制)

「简介」:是指浏览器首次将像素绘制到屏幕上的时间点,具体来说,FP表示浏览器首次绘制了至少一个像素,并将其显示在用户的屏幕上。

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { PaintType } from '../types'
export default function observerPaint() { const entryHandler = (list: PerformanceObserverEntryList) => { for (const entry of list.getEntries()) { if (entry.name === 'first-paint') { observer.disconnect() const json = entry.toJSON() as PerformanceEntry // 定义 reportData 的类型 const reportData: PaintType = { ...json, type: TraceTypeEnum.performance, subType: TraceSubTypeEnum.fp, pageUrl: window.location.href, timestamp: new Date().getTime() }
// 发送数据 todo; lazyReportBatch(reportData) } } }
// 统计和计算fp的时间 const observer = new PerformanceObserver(entryHandler)
// buffered: true 确保观察到所有 paint 事件 observer.observe({ type: 'paint', buffered: true })}

LOAD 执行事件load的时机

「简介」:当所有需要立即加载的资源(如图片和样式表)已加载完成时的时间点

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { PaintType } from '../types'
export default function observePageLoadTime() { // 记录页面加载开始的时间 const startTimestamp = performance.now()
// 监听 load 事件 window.addEventListener('load', () => { // 记录 load 事件触发的时间 const loadTimestamp = performance.now()
// 计算从页面开始加载到 load 事件触发的时间差 const loadTime = loadTimestamp - startTimestamp
// 构建性能数据对象 const reportData: PaintType = { name: '', entryType: 'load', type: TraceTypeEnum.performance, subType: TraceSubTypeEnum.load, pageUrl: window.location.href, startTime: startTimestamp, duration: loadTime, timestamp: new Date().getTime() } // 发送数据 lazyReportBatch(reportData) })}

上报的数据

然后还有其他类似的性能指标例如:

FMP(首次有意义绘制)

「简介」:是指在网页加载过程中,用户可以在屏幕上看到有意义内容的时间点。

fmp的统计还没有目前没有一个正统一点的计算方法,我自己也没有实现统计它

「调研方案:」

认定页面在加载和渲染过程中最大布局变动之后的那个绘制时间即为当前页面的 FMP 」。由于在页面渲染过程中,「 DOM 结构变化的时间点」和与之对应的「渲染的时间点」近似相同,所以一般计算 FMP 的方式是:计算出 DOM 结构变化最剧烈的时间点,即为 FMP。我查了下资料有前端监控实践——FMP的智能获取算法 - 斑驳光影 - SegmentFault 思否 - 掘金

在load事件触发后,遍历dom树,通过对一些标签设计一套权重系统,例如svg,img的权重为2,canvas,object,embed,video的权重为4,其他的元素为1,然后计算dom元素大小占比大小权重得到分数,通过上面的步骤我们获取到了一个集合,这个集合是"可视区域内得分最高的元素的集合",我们会对这个集合的得分取均值,然后过滤出在平均分之上的元素集合,然后通过performance.getEntries去获取对应资源的加载时间,获取元素的加载速度,最后取所有元素最大的加载时间值,作为页面加载的FMP时间

CLS(累积布局偏移)

「简介」:从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数。

「调研方案:」

布局偏移分数 = 影响分数 * 距离分数

影响分数测量不稳定元素对两帧之间的可视区域产生的影响。

距离分数指的是任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。

「CLS 就是把所有布局偏移分数加起来的总和」。CLS 一共有三种计算方式:

    累加

    取所有会话窗口的平均数

    取所有会话窗口中的最大值

FID (首次可交互时间)

「简介」:用户首次与页面交互(如点击、触摸、键盘输入)到浏览器实际响应事件的时间间隔。

TTI(首次可交互时间)

「简介」:它用于衡量「网页完全加载完成后,用户可以与页面进行交互的时间」。它是页面加载过程中的一个关键度量标准,更准确地反映了用户实际体验的时间点。

捕获http网络请求(fetch、xhr)

在网页中网络请求大致分fetch和xhr,axios发的请求它的底层是xhr那么要捕获网络请求的办法就是我们要重写一下fetch和xhr

fetch

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { urlToJson } from '../common/utils'import { AjaxType } from '../types'
const originalFetch: typeof window.fetch = window.fetch
function overwriteFetch(): void { window.fetch = function newFetch( url: any, config?: RequestInit ): Promise<Response> { const params = ( config?.body ? config.body : urlToJson(url as string) ) as string const startTime = Date.now() const urlString = typeof url === 'string' ? url : url instanceof URL ? url.href : url.url const reportData: AjaxType = { type: TraceTypeEnum.performance, subType: TraceSubTypeEnum.fetch, url: urlString, startTime, endTime: 0, duration: 0, status: 0, success: false, method: config?.method || 'GET', pageUrl: window.location.href, params, timestamp: new Date().getTime() } return originalFetch(url, config) .then(res => { reportData.status = res.status return res }) .catch(err => { reportData.status = err.status throw err }) .finally(() => { const endTime = Date.now() reportData.endTime = endTime reportData.duration = endTime - startTime reportData.success = false // todo 上报数据 lazyReportBatch(reportData) }) }}
export default function fetch(): void { overwriteFetch()}

xhr

import { TraceSubTypeEnum, TraceTypeEnum } from '../common/enum'import { lazyReportBatch } from '../common/report'import { urlToJson } from '../common/utils'import { AjaxType } from '../types'
export const originalProto = XMLHttpRequest.prototypeexport const originalSend = originalProto.sendexport const originalOpen = originalProto.open
// 扩展 XMLHttpRequest 类型,允许自定义属性declare global { interface XMLHttpRequest { startTime?: number endTime?: number duration?: number method?: string url?: string }}
function overwriteOpenAndSend() { originalProto.open = function newOpen( method: string, url: string | URL, async: boolean = true, username?: string, password?: string ) { // 这将保留原始的 open 方法签名,并确保 async、username 和 password 可选 this.url = url.toString() // 可能需要转为 string 类型 this.method = method originalOpen.apply(this, [method, url, async, username, password]) }
originalProto.send = function newSend( ...args: [Document | XMLHttpRequestBodyInit | null | undefined] ) { this.addEventListener('loadstart', () => { this.startTime = Date.now() })
const onLoaded = () => { this.endTime = Date.now() this.duration = (this.endTime ?? 0) - (this.startTime ?? 0) const { url, method, startTime, endTime, duration, status } = this const params = (args[0] ? args[0] : urlToJson(url as string)) as string
const reportData: AjaxType = { status, duration, startTime, endTime, url, method: method?.toUpperCase(), type: TraceTypeEnum.performance, success: status >= 200 && status < 300, subType: TraceSubTypeEnum.xhr, pageUrl: window.location.href, params, timestamp: new Date().getTime() } // todo: 发送数据 lazyReportBatch(reportData) this.removeEventListener('loadend', onLoaded, true) }
this.addEventListener('loadend', onLoaded, true) originalSend.apply(this, args) }}
export default function xhr() { overwriteOpenAndSend()}

上报的请求例子


尾言

以上就是性能监控的全部内容了,笔者不一定正确,如果大家有发现有错误或有优化的地方也可以在评论区指出。下一篇会写错误监控的内容。

点击关注公众号,“技术干货” 及时达!

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

前端监控 性能监控 PerformanceObserver
相关文章