电话

028-126520321

BOB半岛官网:前端监控 SDK 的一些技术要点原理分析

标签: 2024-07-28 

 

一个完整的前端监控平台包括三个部分:数据采集与上报、数据整理和存储、数据展示。

本文要讲的就是其中的第一个环节——数据采集与上报。下图是本文要讲述内容的大纲,大家可以先大致了解一下:

仅看理论知识是比较难以理解的,为此我结合本文要讲的技术要点写了一个简单的监控 SDK[1],可以用它来写一些简单的 DEMO,帮助加深理解。再结合本文一起阅读,效果更好。

性能数据采集

chrome 开发团队提出了一系列用于检测网页性能的指标:

 FP(first-paint),从页面加载开始到第一个像素绘制到屏幕上的时间  FCP(first-contentful-paint),从页面加载开始到页面内容的任何部分在屏幕上完成渲染的时间  LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间  CLS(layout-shift),从页面加载开始和其生命周期状态[2]变为隐藏期间发生的所有意外布局偏移的累积分数

这四个性能指标都需要通过 PerformanceObserver[3] 来获取(也可以通过 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 属性表示是否观察缓存数据,也就是说观察代码添加时机比事情触发时机晚也没关系BOB SPORTS。  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 的内容: BOB半岛下载

{      duration: 0,      entryType: "paint",      name: "first-contentful-paint",      startTime: 459, // fcp 时间  } 

其中 startTime 就是我们要的绘制时间。

LCP

LCP(largest-contentful-paint),从页面加载开始到最大文本块或图像元素在屏幕上完成渲染的时间。LCP 指标会根据页面首次开始加载[4]的时间点来报告可视区域内可见的最大图像或文本块[5]完成渲染的相对时间。

一个良好的 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()](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fdocs%2FWeb%2FCSS%2Furl( "url()"))函数(而非使用CSS 渐变[6])加载的带有背景图像的元素  包含文本节点或其他行内级文本元素子元素的块级元素[7]。 CLS

CLS(layout-shift),从页面加载开始和其生命周期状态[8]变为隐藏期间发生的所有意外布局偏移的累积分数。

布局偏移分数的计算方式如下: 

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

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

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

CLS 就是把所有布局偏移分数加起来的总和。

当一个 DOM 在两个渲染帧之间产生了位移,就会触发 CLS(如图所示)。

上图中的矩形从左上角移动到了右边,这就算是一次布局偏移。同时,在 CLS 中,有一个叫会话窗口的术语:一个或多个快速连续发生的单次布局偏移,每次偏移相隔的时间少于 1 秒,且整个窗口的最大持续时长为 5 秒。

例如上图中的第二个会话窗口,它里面有四次布局偏移,每一次偏移之间的间隔必须少于 1 秒,并且第一个偏移和最后一个偏移之间的时间不能超过 5 秒,这样才能算是一次会话窗口。如果不符合这个条件,就算是一个新的会话窗口。可能有人会问,为什么要这样规定?其实这是 chrome 团队根据大量的实验和研究得出的分析结果 Evolving the CLS metric[10]。

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 字段就是布局偏移分数。 DOMContentLoaded、load 事件

当纯 HTML 被完全加载以及解析时,DOMContentLoaded 事件会被触发,不用等待 css、img、iframe 加载完。

当整个页面及所有依赖资源如样式表和图片都已完成加载时,将触发 load 事件。

虽然这两个性能指标比较旧了,但是它们仍然能反映页面的一些情况。对于它们进行监听仍然是必要的。 

import { lazyReportCache } from '../utils/report'  ['load', 'DOMContentLoaded'].forEach(type => onEvent(type))  function onEvent(type) {         function callback() {            lazyReportCache({                   type: 'performance',                  subType: type.toLocaleLowerCase(),                 startTime: performance.now(),               })                     window.removeEventListener(type, callback, true)         }            window.addEventListener(type, callback, true)  } 
首屏渲染时间

大多数情况下,首屏渲染时间可以通过 load 事件获取。除了一些特殊情况,例如异步加载的图片和 DOM。 

<script>      setTimeout(() => {             document.body.innerHTML = `               <div>                             <!-- 省略一堆代码... -->                   </div>            `      }, 3000)  </script> 

像这种情况就无法通过 load 事件获取首屏渲染时间了。这时我们需要通过 MutationObserver[11] 来获取首屏渲染时间。MutationObserver 在监听的 DOM 元素属性发生变化时会触发事件。

前端监控 SDK 的一些技术要点原理分析

首屏渲染时间计算过程:

 利用 MutationObserver 监听 document 对象,每当 DOM 元素属性发生变更时,触发事件。  判断该 DOM 元素是否在首屏内,如果在,则在 requestAnimationFrame() 回调函数中调用 performance.now() 获取当前时间,作为它的绘制时间。  将最后一个 DOM 元素的绘制时间和首屏中所有加载的图片时间作对比,将最大值作为首屏渲染时间。

监听 DOM 

const next = window.requestAnimationFrame ? requestAnimationFrame : setTimeout  const ignoreDOMList = ['STYLE', 'SCRIPT', 'LINK']    observer = new MutationObserver(mutationList => {       const entry = {              children: [],        }            for (const mutation of mutationList) {             if (mutation.addedNodes.length && isInScreen(mutation.target)) {                       // ...              }         }        if (entry.children.length) {              entries.push(entry)              next(() => {                       entry.startTime = performance.now()             })       }  })  observer.observe(document, {        childList: true,        subtree: true,  }) 

上面的代码就是监听 DOM 变化的代码,同时需要过滤掉 style、script、link 等标签。

判断是否在首屏

一个页面的内容可能非常多,但用户最多只能看见一屏幕的内容。所以在统计首屏渲染时间的时候,需要限定范围,把渲染内容限定在当前屏幕内。 

const viewportWidth = window.innerWidth  const viewportHeight = window.innerHeight  // dom 对象是否在屏幕内  function isInScreen(dom) {       const rectInfo = dom.getBoundingClientRect()        if (rectInfo.left < viewportWidth && rectInfo.top < viewportHeight) {                return true        }         return false  } 

使用 requestAnimationFrame() 获取 DOM 绘制时间

当 DOM 变更触发 MutationObserver 事件时,只是代表 DOM 内容可以被读取到,并不代表该 DOM 被绘制到了屏幕上。

从上图可以看出,当触发 MutationObserver 事件时,可以读取到 document.body 上已经有内容了,但实际上左边的屏幕并没有绘制任何内容。所以要调用 requestAnimationFrame() 在浏览器绘制成功后再获取当前时间作为 DOM 绘制时间。

和首屏内的所有图片加载时间作对比 

function getRenderTime() {         let startTime = 0        entries.forEach(entry => {            if (entry.startTime > startTime) {               startTime = entry.startTime            }       })         // 需要和当前页面所有加载图片的时间做对比,取最大值         // 图片请求时间要小于 startTime,响应结束时间要大于 startTime       performance.getEntriesByType('resource').forEach(item => {               if (                 item.initiatorType === 'img'                  && item.fetchStart < startTime                   && item.responseEnd > startTime             ) {                       startTime = item.responseEnd             }        })             return startTime  } 

优化

现在的代码还没优化完,主要有两点注意事项:

 什么时候上报渲染时间?  如果兼容异步添加 DOM 的情况?

第一点,必须要在 DOM 不再变化后再上报渲染时间,一般 load 事件触发后,DOM 就不再变化了。所以我们可以在这个时间点进行上报。

第二点,可以在 LCP 事件触发后再进行上报。不管是同步还是异步加载的 DOM,它都需要进行绘制,所以可以监听 LCP 事件,在该事件触发后才允许进行上报。BOB半岛中国

将以上两点方案结合在一起,就有了以下代码: 

let isOnLoaded = false  executeAfterLoad(() => {        isOnLoaded = true  })  let timer  let observer  function checkDOMChange() {        clearTimeout(timer)       timer = setTimeout(() => {              // 等 load、lcp 事件触发后并且 DOM 树不再变化时,计算首屏渲染时间               if (isOnLoaded && isLCPDone()) {                  observer && observer.disconnect()                   lazyReportCache({                            type: 'performance',                      subType: 'first-screen-paint',                     startTime: getRenderTime(),                        pageURL: getPageURL(),                    })                              entries = null               } else {                    checkDOMChange()              }        }, 500)  } 

checkDOMChange() 代码每次在触发 MutationObserver 事件时进行调用,需要用防抖函数进行处理。

接口请求耗时

接口请求耗时需要对 XMLHttpRequest 和 fetch 进行监听。

监听 XMLHttpRequest BOB半岛

originalProto.open = function newOpen(...args) {        this.url = args[1]        this.method = args[0]         originalOpen.apply(this, args)  }  originalProto.send = function newSend(...args) {        this.startTime = Date.now()            const onLoadend = () => {            this.endTime = Date.now()           thisthis.duration = this.endTime - this.startTime                    const { status, duration, startTime, endTime, url, method } = this                const reportData = {                  status,                     duration,                     startTime,                     endTime,                       url,                     method: (method 
BOB半岛

推荐新闻