性能监控
在性能监控中我们要监控的对象有:
网页资源加载的内容(img、video、js...) 网页FCP、LCP、FP等 网络接口相关的数据
网页性能数据统计方法
PerformanceObserver (性能监测对象):
PerformanceObserver 用于监测性能度量事件,在浏览器的性能时间轴记录新的 performance entry 的时候将会被通知。
其中有个方法PerformanceObserver.observe():
指定监测的 entry types 的集合。当 performance entry 被记录并且是指定的 entryTypes 之一的时候,性能观察者对象的回调函数会被调用。
当监测到的时候可以调用PerformanceObserver.disconnect()停止接收 性能条目。
可以通过这个Api获取FP、FCP、LCP、CLS
全局配置结构
全局配置结构
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)
}
}
统计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.prototype
export const originalSend = originalProto.send
export 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()
}