一个完整的前端监控平台包括三个部分:数据采集与上报、数据整理和存储、数据展示。
本文要讲的就是其中的第一个环节——数据采集与上报。下图是本文要讲述内容的大纲,大家可以先大致了解一下:
仅看理论知识是比较难以理解的,为此我结合本文要讲的技术要点写了一个简单的监控 SDK,可以用它来写一些简单的 DEMO,帮助加深理解。再结合本文一起阅读,效果更好。
性能数据采集
chrome 开发团队提出了一系列用于检测网页性能的指标:
- FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的时间
- FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间
- LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间
- CLS(layout-shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数
这四个性能指标都需要通过 PerformanceObserver 来获取(也可以通过 performance.getEntriesByName()
获取,但它不是在事件触发时通知的)。PerformanceObserver 是一个性能监测对象,用于监测性能度量事件。
FP
FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的时间。其实把 FP 理解成白屏时间也是没问题的。
测量代码如下:
const entryHandler = (list) => { for (const entry of list.getEntries()) { if (entry.name === 'first-paint') { observer.disconnect() } console.log(entry) } } const observer = new PerformanceObserver(entryHandler) // buffered 属性表示是否观察缓存数据,也就是说观察代码添加时机比事情触发时机晚也没关系。 observer.observe({ type: 'paint', buffered: true })
通过以上代码可以得到 FP 的内容:
{ duration: 0, entryType: "paint", name: "first-paint", startTime: 359, // fp 时间 }
其中 startTime
就是我们要的绘制时间。
FCP
FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间。对于该指标,"内容"指的是文本、图像(包括背景图像)、<svg>
元素或非白色的<canvas>
元素。
为了提供良好的用户体验,FCP 的分数应该控制在 1.8 秒以内。
测量代码:
const entryHandler = (list) => { for (const entry of list.getEntries()) { if (entry.name === 'first-contentful-paint') { observer.disconnect() } console.log(entry) } } const observer = new PerformanceObserver(entryHandler) observer.observe({ type: 'paint', buffered: true })
通过以上代码可以得到 FCP 的内容:
{ duration: 0, entryType: "paint", name: "first-contentful-paint", startTime: 459, // fcp 时间 }
其中 startTime
就是我们要的绘制时间。
LCP
LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间。LCP 指标会根据页面首次开始加载的时间点来报告可视区域内可见的最大图像或文本块完成渲染的相对时间。
一个良好的 LCP 分数应该控制在 2.5 秒以内。
测量代码:
const entryHandler = (list) => { if (observer) { observer.disconnect() } for (const entry of list.getEntries()) { console.log(entry) } } const observer = new PerformanceObserver(entryHandler) observer.observe({ type: 'largest-contentful-paint', buffered: true })
通过以上代码可以得到 LCP 的内容:
{ duration: 0, element: p, entryType: "largest-contentful-paint", id: "", loadTime: 0, name: "", renderTime: 1021.299, size: 37932, startTime: 1021.299, url: "", }
其中 startTime
就是我们要的绘制时间。element 是指 LCP 绘制的 DOM 元素。
FCP 和 LCP 的区别是:FCP 只要任意内容绘制完成就触发,LCP 是最大内容渲染完成时触发。
LCP 考察的元素类型为:
<img>
元素- 内嵌在
<svg>
元素内的<image>
元素 <video>
元素(使用封面图像)- 通过
url()
)函数(而非使用CSS 渐变)加载的带有背景图像的元素 - 包含文本节点或其他行内级文本元素子元素的块级元素。
CLS
CLS(layout-shift),从页面加载开始和其生命周期状态变为隐藏期间发生的所有意外布局偏移的累积分数。
布局偏移分数的计算方式如下:
布局偏移分数 = 影响分数 * 距离分数
影响分数测量不稳定元素对两帧之间的可视区域产生的影响。
距离分数指的是任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸维度(宽度或高度,以较大者为准)。
CLS 就是把所有布局偏移分数加起来的总和。
当一个 DOM 在两个渲染帧之间产生了位移,就会触发 CLS(如图所示)。
上图中的矩形从左上角移动到了右边,这就算是一次布局偏移。同时,在 CLS 中,有一个叫会话窗口的术语:一个或多个快速连续发生的单次布局偏移,每次偏移相隔的时间少于 1 秒,且整个窗口的最大持续时长为 5 秒。
例如上图中的第二个会话窗口,它里面有四次布局偏移,每一次偏移之间的间隔必须少于 1 秒,并且第一个偏移和最后一个偏移之间的时间不能超过 5 秒,这样才能算是一次会话窗口。如果不符合这个条件,就算是一个新的会话窗口。可能有人会问,为什么要这样规定?其实这是 chrome 团队根据大量的实验和研究得出的分析结果 Evolving the CLS metric。
CLS 一共有三种计算方式:
- 累加
- 取所有会话窗口的平均数
- 取所有会话窗口中的最大值
累加
也就是把从页面加载开始的所有布局偏移分数加在一起。但是这种计算方式对生命周期长的页面不友好,页面存留时间越长,CLS 分数越高。
取所有会话窗口的平均数
这种计算方式不是按单个布局偏移为单位,而是以会话窗口为单位。将所有会话窗口的值相加再取平均值。但是这种计算方式也有缺点。
从上图可以看出来,第一个会话窗口产生了比较大的 CLS 分数,第二个会话窗口产生了比较小的 CLS 分数。如果取它们的平均值来当做 CLS 分数,则根本看不出来页面的运行状况。原来页面是早期偏移多,后期偏移少,现在的平均值无法反映出这种情况。
取所有会话窗口中的最大值
这种方式是目前最优的计算方式,每次只取所有会话窗口的最大值,用来反映页面布局偏移的最差情况。详情请看 Evolving the CLS metric。
下面是第三种计算方式的测量代码:
let sessionValue = 0 let sessionEntries = [] const cls = { subType: 'layout-shift', name: 'layout-shift', type: 'performance', pageURL: getPageURL(), value: 0, } const entryHandler = (list) => { for (const entry of list.getEntries()) { // Only count layout shifts without recent user input. if (!entry.hadRecentInput) { const firstSessionEntry = sessionEntries[0] const lastSessionEntry = sessionEntries[sessionEntries.length - 1] // If the entry occurred less than 1 second after the previous entry and // less than 5 seconds after the first entry in the session, include the // entry in the current session. Otherwise, start a new session. if ( sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000 ) { sessionValue += entry.value sessionEntries.push(formatCLSEntry(entry)) } else { sessionValue = entry.value sessionEntries = [formatCLSEntry(entry)] } // If the current session value is larger than the current CLS value, // update CLS and the entries contributing to it. if (sessionValue > cls.value) { cls.value = sessionValue cls.entries = sessionEntries cls.startTime = performance.now() lazyReportCache(deepCopy(cls)) } } } } const observer = new PerformanceObserver(entryHandler) observer.observe({ type: 'layout-shift', buffered: true })
在看完上面的文字描述后,再看代码就好理解了。一次布局偏移的测量内容如下:
{ duration: 0, entryType: "layout-shift", hadRecentInput: false, lastInputTime: 0, name: "", sources: (2) [LayoutShiftAttribution, LayoutShiftAttribution], startTime: 1176.199999999255, value: 0.000005752046026677329, }
代码中的 value
字段就是布局偏移分数。