性能优化的重要性不言而喻,Google 的研究表明,当网站达到核心 Web 指标(Core Web Vitals)阈值时,用户放弃加载网页的可能性会降低 24%。

  如何科学地定位到网页的性能瓶颈,就需要找到一个合理的方式来测量和监控页面的性能,确定优化的方向。

  前端的性能监控分为 2 种:

  • 第一种是合成监控(Synthetic Monitoring,SYN),模拟网页加载或脚本运行等方式来测量网页性能,输出性能报告以供参考,常用的工具有 Chrome DevTools 的 Performance 面板、LighthouseWebPageTest 等。
  • 第二种是真实用户监控(Real User Monitoring,RUM),采集真实用户所访问到的页面数据,通过 PerformancePerformanceObserver 等 API 计算得到想要的性能参数,各种第三方的性能监控的 SDK 就属于此类。

  本文的示例代码摘取自 shin-monitor,一款开源的前端监控脚本。

  为了便于记忆,特将此系列的所有重点内容浓缩成一张思维导图。

  

一、Performance

  W3C 在 2012 年制订了第一版测量网页性能的规范:Navigation Timing。下图提供了页面各阶段可用的性能计时参数。

  

  注意,若重定向是非同源,那么带下划线的 redirectStart、redirectEnd、unloadStart、unloadEnd 四个值将一直都是 0。

  W3C 在几年后又制订了第二版的规范:Navigation Timing Level 2,如下图所示。

  

  注意,在浏览器中,读取 unloadEventStart 的值后,会发现这个时间点并不会像图中那样在 fetchStart 之前,因为 unload 不会阻塞页面加载。

  接下来,会用代码来演示性能参数的计算,后文中的 navigationStart 参数其实就是 startTime。

1)性能对象

  第一版获取性能参数的方法是调用 performance.timing,第二版的方法是调用 performance.getEntriesByType('navigation')[0]。

  前者得到一个 PerformanceTiming 对象,后者得到一个 PerformanceNavigationTiming 对象。

  在下面的代码中,若当前浏览器不支持第二版,则回退到第一版。不过,目前主流的浏览器对第一版的支持也比较好。

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

  以我公司为例,投放到线上的页面,其中只有大概 5.5% 的用户读取的第一版。

  2023-02-27 注意,PerformanceNavigationTiming 继承了 PerformanceResourceTiming

  在 iOS 设备中,若 SDK 涉及跨域,并且其响应没有声明 timing-allow-origin 首部,那么 PerformanceResourceTiming 中的大部分属性都是 0。

  包括 responseStart、connectStart、domainLookupStart 等都为 0,若 responseStart 为 0,那就会影响 TTFB 的计算,其值也会一直为 0。

  可以将 timing-allow-origin 设为星号,或指定域名,如下所示。

Timing-Allow-Origin: *
Timing-Allow-Origin: https://www.pwstrick.com

  2023-03-14 虽然添加了 timing-allow-origin,但是统计结果中 TTFB 仍然包含大量的 0。

  经过抓包发现,是因为没有请求服务器中的 SDK,而是直接读取了客户端中的缓存。

  为了让客户端每次都去校验资源是否需要更新(即破缓存),就在 SDK 的响应头中增加 Cache-Control: no-cache。

2)fetchStart

  从上面的计时图中可知,在 fetchStart 之前,浏览器会先处理重定向。

  重定向的本质是在服务器第一次响应时,返回 301 或 302 状态码,让客户端进行下一步请求。

  会多走一遍响应流程,若不是像鉴权、短链等有意义的重定向,都应该要避免。

  比较常见的有浏览器强制从 HTTP 页面重定向到对应的 HTTPS 页面,以及主域名的重定向,例如从 https://pwstrick.com 重定向至 https://www.pwstrick.com。

  由于浏览器安全策略的原因,不同域名之间的重定向时间,是无法精确计算的,只能统计 fetchStart 之前的总耗时。

  fetchStart 还会包含新标签页初始化的时间,但并不包括上一个页面的 unload 时间。

  由此可知,startTime 其实是在卸载上个页面之后开始统计的。 fetchStart 最主要的优化手段就是减少重定向次数。

  例如若页面需要登录,则做成一个弹框,不要做页面跳转,还例如在编写页面时,不要显式地为 URL 添加协议。

3)TCP

  TCP 在建立连接之前,要经过三次握手,若是 HTTPS 协议,还要包括 SSL 握手,计算规则如下所示。

/**
 * SSL连接耗时
 */
const sslTime = timing.secureConnectionStart;
connectSslTime = sslTime > 0 ? timing.connectEnd - sslTime : 0;
/**
 * TCP连接耗时
 */
connectTime = timing.connectEnd - timing.connectStart;

  在建立连接后,TCP 就可复用,所以有时候计算得到的值是 0。

  若要减少 TCP 的耗时,可通过减少物理距离、使用 HTTP/3 协议等方法实现。

  还有一种方法是通过 preconnect 提前建立连接,如下所示,浏览器会抢先启动与该来源的连接。

<link rel="preconnect" href="https://pwstrick.com" />

4)TTFB

  TTFB(Time To First Byte)是指读取页面第一个字节的时间,即从发起请求到服务器响应后收到的第一个字节的时间差,用于衡量服务器处理能力和网络的延迟。

  TTFB 包括重定向、DNS 解析、TCP 连接、网络传输、服务器响应等时间消耗的总和,计算规则就是 responseStart 减去 redirectStart。

TTFB = timing.responseStart - timing.redirectStart;

  其实,TTFB 计算的是整个通信的往返时间(Round-Trip Time,RTT),以及服务器的处理时间。

  所以,设备之间的距离、网络传输路径、数据库慢查询等因素都会影响 TTFB。

  一般来说,TTFB 保持在 75ms 以内会比较完美,而在 200ms 以内会比较理想,若超过 500ms,用户就会感觉到明显地白屏。

  TTFB 常用的优化手段包括增加 CDN 动态加速、减少请求的数据量、服务器硬件升级、优化后端代码(引入缓存、慢查询优化等服务端的工作)。

5)FP 和 FCP

  白屏(First Paint,FP)也叫首次绘制,是指屏幕从空白到显示第一个画面的时间,即渲染树转换成屏幕像素的时刻。

  这是用户可感知到的一个性能参数,1 秒内是比较理想的白屏时间。

  白屏时间的计算规则有 3 种:

  • 第一种是读取 PerformancePaintTiming 对象,再减去 fetchStart。
  • 第二种是通过 responseEnd 和 fetchStart 相减。
  • 第三种是当 startTime 和 responseEnd 都是 0 时,改用进入页面的时间和 fetchStart 相减(2023-07-06)。
const paint = performance.getEntriesByType("paint");
// entryType 是为了区分新旧两个版本的性能对象,只有新版本才有此属性
if (paint && timing.entryType && paint[0]) {
  api.firstPaint = paint[0].startTime - timing.fetchStart;
  api.firstPaintStart = paint[0].startTime; // 记录白屏时间点
}
// 如果白屏时间是 0 或不存在,则还需要计算
if (!api.firstPaint || api.firstPaint === 0) {
  // 临时变量,选择白屏的结束时间,若 responseEnd 是 0,则用进入页面的时间
  const fpEnd = timing.responseEnd === 0 ? this.beginStayTime : timing.responseEnd;
  api.firstPaint = fpEnd - timing.fetchStart;
}

  在实践中发现,每天有大概 2 千条记录中的白屏时间为 0,而且清一色的都是苹果手机。

  一番搜索后,了解到,当 iOS 设备通过浏览器的前进或后退按钮进入页面时,fetchStart、responseEnd 等性能参数很可能为 0。

  还发现当初始页面的结构中,若包含渐变的效果时,1 秒内的白屏占比会从最高 94% 降低到 85%。

  2023-07-11 发现数据库中有 40 多条记录,白屏时间是 0,查看 timing 发现使用的是第一版的规范,并且 responseEnd 和 fetchStart 数值相同,相减得到了 0。

{
  "fetchStart": 1688976670518,
  "responseEnd": 1688976670518,
}

  注意,PerformancePaintTiming 包含两个性能数据,FP 和 FCP,理想情况下,两者的值可相同。

  FCP(First Contentful Paint)是指首次有实际内容渲染的时间,测量页面从开始加载到页面内容的任何部分在屏幕上完成渲染的时间。

  内容是指文本、图像(包括背景图像)、svg 元素或非白色的 canvas 元素,不包括 iframe 中的内容。

  网站性能测试工具 GTmetrix 认为 FCP 比较理想的时间是控制在 943ms 以内,字节的标准是控制在 1.8s 内。

if (paint && timing.entryType && paint[1]) {
  firstContentfulPaint = paint[1].startTime - timing.fetchStart;
} else {
  firstContentfulPaint = 0;
}

  影响上述两个指标的主要因素包括网络传输和页面渲染,优化的核心就是降低网络延迟以及加速渲染。

  优化手段包括剔除阻塞渲染的 JavaScript 和 CSS、优化图像、压缩合并文件、延迟加载非关键资源、使用 HTTP/2 协议、SSR 等。

  2023-07-03 对于白屏时间超过 4 秒的页面,会对其进行录像存储。

  但是在这个场景中,有时候录像脚本都还没加载完成,性能参数就已上报,所以经常会收到空的录像。

  理想状态下,这个脚本不需要网络传输,内嵌在客户端中,这样就能在页面加载开始阶段,就进行录制。

  上线后发现,有一个项目中的页面都没有上传录像,经过排查发现这些页面上传的录像都要 280KB 以上。

  而传输性能参数采用的是 navigator.sendBeacon() ,此方法只能传输 64KB 左右的数据,远超其容量,导致请求最终被阻塞,没有传输到服务器中。

  从另外几张上传录制成功的页面发现,虽然白屏时间较长,但是最终还是能呈现,不过有些样式有点错位,有些内容渲染不完整。

  为了能更方便的对录像进行控制,在 shin-monitor 的参数中增加一个开关。

  若需要发送录像信息,则使用 fetch() 方法,普通的性能信息传输仍然使用 sendBeacon(),毕竟数据量增大会影响上传成功率。

6)DOM

  在性能计时图中,有 4 个与 DOM 相关的参数,包括 domInteractivedomCompletedomContentLoadedEventStart 和 domContentLoadedEventEnd。

  domInteractive 记录的是在加载 DOM 并执行网页的阻塞脚本的时间。

  在这个阶段,具有 defer 属性的脚本还没有执行,某些样式表加载可能仍在处理并阻止页面呈现。

  domComplete 记录的是完成解析 DOM 树结构的时间。

  在这个阶段,DOM 中的所有脚本,包括具有 async 属性的脚本,都已执行。并且开始加载 DOM 中定义的所有页面静态资源,包括图像、iframe 等。

  loadEventStart 会紧跟在 domComplete 之后。在大多数情况下,这 2 个指标是相等的。在 loadEventStart 之前可能的延迟将由 onReadyStateChange 引起。

  由 domInteractive 和 domComplete 两个参数可计算出两个 DOM 阶段的耗时,如下所示。

initDomTreeTime = timing.domInteractive - timing.responseEnd;    // 请求完毕至 DOM 加载的耗时
parseDomTime = timing.domComplete - timing.domInteractive;       // 解析 DOM 树结构的耗时

  若 initDomTreeTime 过长的话,就需要给脚本瘦身了。若 parseDomTime过长的话,就需要减少资源的请求了。

  DOMContentLoaded(DCL)紧跟在 domInteractive 之后,该事件包含开始和结束两个参数,jQuery.ready() 就是封装了此事件。

  该事件会在 HTML 加载完毕,并且 HTML 所引用的内联和外链的非 async/defer 的同步 JavaScript 脚本和 CSS 样式都执行完毕后触发,无需等待图像和 iframe 完成加载。

  由 domContentLoadedEventEnd 可计算出用户可操作时间,即 DOM Ready 时间。

domReadyTime = timing.domContentLoadedEventEnd - navigationStart;        // 用户可操作时间(DOM Ready时间)

  注意,若 domContentLoadedEventEnd 高于 domContentLoadedEventStart,则说明该页面中也注册了此事件。

  与 DCL 相比,load 事件触发的时机要晚的多。

  它会在页面的 HTML、CSS、JavaScript(包括 async/defer)、图像等静态资源都已经加载完之后才触发。

  2023-06-21 增加对 DOM 节点的计算结果,通过逐层遍历得到页面中的总节点数、最大节点深度以及最大子节点数。

  /**
   * 计算 DOM 相关的数据
   */
  private countAllElementsOnPage(): TypeDOMCount {
    let nodes: (HTMLElement | Element)[] = [document.documentElement];
    // 总节点数
    let totalElementCount = 0;
    // 最大节点深度
    let maxDOMTreeDepth = 0;
    // 最大子节点数
    let maxChildrenCount = 0;
    // 逐层遍历
    while (nodes.length) {
      maxDOMTreeDepth++;
      const children: Element[] = [];
      for (let node of nodes) {
        totalElementCount++;
        children.push(...Array.from(node.children));
        maxChildrenCount = Math.max(maxChildrenCount, node.children.length);
      }
      // nodes 是一个由 HTMLElement 组成的数组
      nodes = children;
    }
    return {
      maxDOMTreeDepth,
      maxChildrenCount,
      totalElementCount,
    };
  }

  本来以为相同页面的数据,每次都会相同,其实不然,会有些差异,应该就是提交的时候,页面搭建的程度不同。

7)停留时长

  2023-07-05 增加停留时长,当页面性能不佳时,观察用户在页面中耗费的时间。

  停留时长的计算规则比较复杂,需要考虑很多场景,不过我们当前公司场景比较单一。

  所以不考虑多页面跳转和多标签的场景,因为我们主要监控的是移动端的活动页面,不需要页面跳转,也不会多标签。

  计算结果不能说非常准确,但也有参考价值,为方便起见,在触发 beforeunload 或 pagehide 事件时,上报数据。

// 计算行为数据
const caculateBehavior = (): TypeBehavior => {
  const behavior: TypeBehavior = {};
  behavior.duration = rounded(getNowTimestamp() - this.beginStayTime);   // 页面停留时长
  return behavior;
};
// 发送用户行为数据
const sendBehavior = (): void => {
  const behavior = caculateBehavior();
  this.http.sendBehavior(behavior);
  localStorage.removeItem(CONSTANT.SHIN_BEHAVIOR_DATA);     // 移除行为缓存
};
/**
 * iOS 设备不支持 beforeunload 事件,需要使用 pagehide 事件
 * 在页面卸载之前,推送性能信息
 */
const isIOS = !!navigator.userAgent.match(/\(i[^;]+;( U;)? CPU.+Mac OS X/);
const eventName = isIOS ? "pagehide" : "beforeunload";
window.addEventListener(eventName, (): void => {
  sendPerformance();
  sendBehavior();
},  false);

  其中 beginStayTime 会在进入页面时将其初始化,如果某些设备不支持这两个事件,那么就无法得到停留时长。

  2023-12-05 公司 iOS 版本的客户端的停留时长经常是空白,也就是上述 pagehide 事件没有被触发,那么就需要一种补救措施。

  最简单的是加个定时器,隔一段时间向服务器发送数据,但是这样会增加服务器的压力,于是换了一种,先将数据缓存到本地。

/**
 * 发送缓存的行为数据
 * 例如停留时长需要在 pagehide 或 beforeunload 两个事件中发送
 * 但如果两个事件都不支持,那么这个数据就是空的
*/
const sendExistBehavior = (): void => {
  const exist = localStorage.getItem(CONSTANT.SHIN_BEHAVIOR_DATA);
  if (!exist) { return; }
  this.http.sendBeacon(exist);                              // 直接发送,不需要再次封装数据
  localStorage.removeItem(CONSTANT.SHIN_BEHAVIOR_DATA);     // 移除行为缓存
};

  当进入页面时,再发送请求。不过这样的话,若没有再次进入,那么仍然是无法上报停留时长的。

window.addEventListener('load', (): void => {
  // 发送缓存的行为数据
  sendExistBehavior();
  // 通过定时器缓存数据
  setInterval((): void => {
     const behavior = caculateBehavior();
     localStorage.setItem(CONSTANT.SHIN_BEHAVIOR_DATA, this.http.paramifyBehavior(behavior));
  }, 1000);
});

  在首次上线期间发生了意外,数据库的 CPU 被下面这条语句拉到了 100%,直接让很多服务无响应。

SELECT * FROM `web_performance` WHERE `identity` = ? AND `referer` = ? AND `project` = ? ORDER BY `id` DESC LIMIT 1

  用 EXPLAIN 查看了这条语句的性能,受影响行数是 2,但是居然造成了非常恶劣的影响。

  优化手段是在查询条件中增加时间缩小范围以及增加联合索引。

ALTER TABLE `web_performance` ADD INDEX `idx_identity_referer_project` (`identity`, `referer`, `project`)

  在执行索引语句时,会报错,因为建表时用了一个 4 字节的 utf8mb4 字符集。

Index column size too large. The maximum column size is 767 bytes.

  当索引最大限制是 767 bytes 时,那么一个 varchar 字段最大长度是 767/4=191.75,而 referer 字段的长度是 200,故而报错。

  将 referer 字段替换成长度更短的 referer_path,优化后 CPU 非常稳定。

ALTER TABLE `web_performance` ADD INDEX `idx_identity_referer_project` (`identity`, `referer_path`, `project`)

二、Core Web Vitals

  Google 在众多的性能指标中选出了几个核心 Web 指标(Core Web Vitals),让网站开发人员可以专注于这几个指标的优化。

  2024-03-20 下图有助于了解核心指标与其他指标之间的关系。

  

  下表是关键指标的基准线,来源于字节Google 的标准,除了 CLS,其他数据的单位都是毫秒。

Metric Name Good Needs Improvement Poor
FP 0-1000 1000-2500 > 2500
FCP 0-1800 1800-3000 > 3000
LCP 0-2500 2500-4000 > 4000
FID 0-100 100-300 > 300
TTI 0-3800 3800-7300 > 7300
CLS <= 0.1 <= 0.25 > 0.25

1)LCP

  LCP(Largest Contentful Paint)是指最大的内容在可视区域内变得可见的时间点,理想的时间是 2.5s 以内。

  

  一般情况下,LCP 的时间都会比 FCP 大(如上图所示),除非页面非常简单,FCP 的重要性也比 LCP 低很多。

  LCP 的读取并不需要手动计算,浏览器已经提供了 PerformanceObserver.observe() 方法,如下所示。

  /**
   * 判断当前宿主环境是否支持 PerformanceObserver
   * 并且支持某个特定的类型
   */
  private checkSupportPerformanceObserver(type: string): boolean {
    if(!(window as any).PerformanceObserver) return false;
    const types = (PerformanceObserver as any).supportedEntryTypes;
    // 浏览器兼容判断,不存在或没有关键字
    if(!types || types.indexOf(type) === -1) {
      return false;
    }
    return true;
  }
  /**
   * 浏览器 LCP 计算
   */
  public observerLCP(): void {
    const lcpType = 'largest-contentful-paint';
    const isSupport = this.checkSupportPerformanceObserver(lcpType);
    // 浏览器兼容判断
    if(!isSupport) {
      return;
    }
    const po = new PerformanceObserver((entryList): void=> {
      const entries = entryList.getEntries();
      const lastEntry = (entries as any)[entries.length - 1] as TypePerformanceEntry;
      this.lcp = {
        time: rounded(lastEntry.renderTime || lastEntry.loadTime),                  // 时间取整
        url: lastEntry.url,                                                         // 资源地址
        element: lastEntry.element ? removeQuote(lastEntry.element.outerHTML) : ''  // 参照的元素
      };
    });
    // buffered 为 true 表示调用 observe() 之前的也算进来
    po.observe({ type: lcpType, buffered: true } as any);
    /**
     * 当有按键或点击(包括滚动)时,就停止 LCP 的采样
     * once 参数是指事件被调用一次后就会被移除
     */
    ['keydown', 'click'].forEach((type): void => {
      window.addEventListener(type, (): void => {
        // 断开此观察者的连接
        po.disconnect();
      }, { once: true, capture: true });
    });
  }

  entries 是一组 LargestContentfulPaint 类型的对象,它有一个 url 属性,如果记录的元素是个图像,那么会存储其地址。

  注册 keydown 和 click 事件是为了停止 LCP 的采样,once 参数会在事件被调用一次后将事件移除。

  在 iOS 的 WebView 中,只支持三种类型的 entryType,不包括 largest-contentful-paint,所以加了段浏览器兼容判断。

  在页面转移到后台后,得停止 LCP 的计算,因此需要找到隐藏到后台的时间。

let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
// 记录页面隐藏时间 iOS 不会触发 visibilitychange 事件
const onVisibilityChange = (event) => {
  // 页面不可见状态
  if (lcp && document.visibilityState === 'hidden') {
    firstHiddenTime = event.timeStamp;
    // 移除事件
    document.removeEventListener('visibilitychange', onVisibilityChange, true);
  }
}
document.addEventListener('visibilitychange', onVisibilityChange, true);

  利用 visibilitychange 事件,就能准确得到隐藏时间,然后在读取 LCP 时,大于这个时间的就直接忽略掉。不过在实践中发现,iOS 的 WebView 并不支持此事件。

  注意,largest-contentful-paint 不会计算 iframe 中的元素,返回上一页也不会重新计算。

  有个成熟的库:web-vitals,提供了 LCP、FID、CLS、FCP 和 TTFB 指标,对上述所说的特殊场景做了处理,若要了解原理,可以参考其中的计算过程。

  LCP 会被一直监控(其监控的元素如下所列),这样会影响结果的准确性。

  例如有个页面首次进入是个弹框,确定后会出现动画,增加些图像,DOM结构也都会跟着改变。

  • img 元素
  • 内嵌在 svg 中的 image 元素
  • video 元素(使用到封面图像)
  • 拥有背景图像的元素(调用 CSS 的 url() 函数)
  • 包含文本节点或或行内文本节点的块级元素

  如果在等待一段时间,关闭页面时才上报,那么 LCP 将会很长,所以需要选择合适的上报时机,例如 load 事件中。

2)FMP

  FMP(First Meaningful Paint)是首次绘制有意义内容的时间,这是一个比较复杂的指标。

  因为算法的通用性不够高,探测结果也不理想,所以 Google 已经废弃了 FMP,转而采用含义更清晰的 LCP。

  虽然如此,但网上仍然有很多开源的解决方案,毕竟 Google 是要找出一套通用方案,但我们并不需要通用。

  只要结合那些方案,再写出最适合自己环境的算法就行了,目前初步总结了一套计算 FMP 的步骤(仅供参考)。

  首先,通过 MutationObserver 监听每一次页面整体的 DOM 变化,触发 MutationObserver 的回调。

  然后在回调中,为每个 HTML 元素(不包括忽略的元素)打上标记,记录元素是在哪一次回调中增加的,并且用数组记录每一次的回调时间。

const IGNORE_TAG_SET = ['SCRIPT', 'STYLE', 'META', 'HEAD', 'LINK'];
const WW = window.innerWidth;
const WH = window.innerHeight;
const FMP_ATTRIBUTE = '_ts';
class FMP { 
  private cacheTrees: TypeTree[];
  private callbackCount: number;
  private observer: MutationObserver;
  public constructor() {
    this.cacheTrees = [];       // 缓存每次更新的DOM元素
    this.callbackCount = 0;     // DOM 变化的计数
    // 开始监控DOM的变化
    this.observer = new MutationObserver((): void => {
      const mutationsList = [];
      // 从 body 元素开始遍历
      document.body && this.doTag(document.body, this.callbackCount++, mutationsList);
      this.cacheTrees.push({
        ts: performance.now(),
        children: mutationsList  
      });
      // console.log("mutationsList", performance.now(), mutationsList);
    });
    this.observer.observe(document, {
      childList: true,    // 监控子元素
      subtree: true   // 监控后代元素
    });
  }
  /**
   * 为 HTML 元素打标记,记录是哪一次的 DOM 更新
   */
  private doTag(target: Element, callbackCount: number, mutationsList: Element[]): void {
    const childrenLen = target.children ? target.children.length : 0;
    // 结束递归
    if(childrenLen === 0)
      return;
    for (let children = target.children, i = childrenLen - 1; i >= 0; i--) {
      const child = children[i];
      const tagName = child.tagName;
      if (child.getAttribute(FMP_ATTRIBUTE) === null && 
            IGNORE_TAG_SET.indexOf(tagName) === -1  // 过滤掉忽略的元素
      ) {
        child.setAttribute(FMP_ATTRIBUTE, callbackCount.toString());
        mutationsList.push(child);  // 记录更新的元素
      }
      // 继续递归
      this.doTag(child, callbackCount, mutationsList);
    }
  }
}

  接着在触发 load 事件时,先过滤掉首屏外和没有高度的元素,以及元素列表之间有包括关系的祖先元素,再计算各次变化时剩余元素的总分。

  一开始是只记录没有后代的元素,但是后面发现有时候 DOM 变化时,没有这类元素。

  /**
   * 是否超出屏幕外
   */
  private isOutScreen(node: Element): boolean {
    const { left, top } = node.getBoundingClientRect();
    return  WH < top || WW < left;
  }
  /**
   * 读取 FMP 信息
   */
  public getFMP(): TypeMaxElement {
    this.observer.disconnect(); // 停止监听
    const maxObj = {
      score: -1,  //最高分
      elements: [],   // 首屏元素
      ts: 0   // DOM变化时的时间戳
    };
      // 遍历DOM数组,并计算它们的得分
    this.cacheTrees.forEach((tree): void => {
      let score = 0;
      // 首屏内的元素
      let firstScreenElements = [];
      tree.children.forEach((node): void => {
        // 只记录元素
        if(node.nodeType !== 1 || IGNORE_TAG_SET.indexOf(node.tagName) >= 0) {
          return;
        }
        const { height } = node.getBoundingClientRect();
        // 过滤高度为 0,在首屏外的元素
        if(height > 0 && !this.isOutScreen(node)) {
          firstScreenElements.push(node);
        }
      });
      // 若首屏中的一个元素是另一个元素的后代,则过滤掉该祖先元素
      firstScreenElements = firstScreenElements.filter((node): boolean => {
        // 只要找到一次包含关系,就过滤掉
        const notFind = !firstScreenElements.some((item ): boolean=> node !== item && node.contains(item));
        // 计算总得分
        if(notFind) {
          score += this.caculateScore(node);
        }
        return notFind;
      });
      // 得到最高值
      if(maxObj.score < score) {
        maxObj.score = score;
        maxObj.elements = firstScreenElements;
        maxObj.ts = tree.ts;
      }
    });
    // 在得分最高的首屏元素中,找出最长的耗时
    return this.getElementMaxTimeConsuming(maxObj.elements, maxObj.ts);
  }

  不同类型的元素,权重也是不同的,权重越高,对页面呈现的影响也越大。

  在 caculateScore() 函数中,通过getComputedStyle得到 CSS 类中的背景图属性,注意,node.style 只能得到内联样式中的属性。

const TAG_WEIGHT_MAP = {
  SVG: 2,
  IMG: 2,
  CANVAS: 4,
  OBJECT: 4,
  EMBED: 4,
  VIDEO: 4
};
/**
 * 计算元素分值
 */
private caculateScore(node: Element): number {
  const { width, height } = node.getBoundingClientRect();
  let weight = TAG_WEIGHT_MAP[node.tagName] || 1;
  if (weight === 1 &&
      window.getComputedStyle(node)['background-image'] && // 读取CSS样式中的背景图属性
      window.getComputedStyle(node)['background-image'] !== 'initial'
  ) {
    weight = TAG_WEIGHT_MAP['IMG']; //将有图像背景的普通元素 权重设置为img
  }
  return width * height * weight;
}

  最后在得到分数最大值后,从这些元素中挑选出最长的耗时,作为 FMP。

  /**
   * 读取首屏内元素的最长耗时
   */
  private getElementMaxTimeConsuming(elements: Element[], observerTime: number): TypeMaxElement {
    // 记录静态资源的响应结束时间
    const resources = {};
    // 遍历静态资源的时间信息
    performance.getEntries().forEach((item: PerformanceResourceTiming): void => {
      resources[item.name] = item.responseEnd;
    });
    const maxObj: TypeMaxElement = {
      ts: observerTime,
      element: ''
    };
    elements.forEach((node: Element): void => {
      const stage = node.getAttribute(FMP_ATTRIBUTE);
      let ts = stage ? this.cacheTrees[stage].ts : 0;  // 从缓存中读取时间
      switch(node.tagName) {
        case 'IMG':
          ts = resources[(node as HTMLImageElement).src];
          break;
        case 'VIDEO':
          ts = resources[(node as HTMLVideoElement).src];
          !ts && (ts = resources[(node as HTMLVideoElement).poster]);    // 读取封面
          break;
        default: {
          // 读取背景图地址
          const match = window.getComputedStyle(node)['background-image'].match(/url\(\"(.*?)\"\)/);
          if(!match) break;
          let src: string;
          // 判断是否包含协议
          if (match && match[1]) {
            src = match[1];
          }
          if (src.indexOf('http') == -1) {
            src = location.protocol + match[1];
          }
          ts = resources[src];
          break;
        }
          
      }
      // console.log(node, ts)
      if(ts > maxObj.ts) {
        maxObj.ts = ts;
        maxObj.element = node;
      }
    });
    return maxObj;
  }

  在将 LCP 和 FMP 两个指标都算出后,就会取这两者的较大值,作为首屏的时间。

  在还未完成 FMP 算法之前,首屏采用的是两种有明显缺陷的计算方式。

  • 第一种是算出首屏页面中所有图像都加载完后的时间,这种方法难以覆盖所有场景,例如 CSS 中的背景图、Image 元素等。
  • 第二种是自定义首屏时间,也就是自己来控制何时算首屏全部加载好了,虽然相对精确点,但要修改源码。

3)FID

  FID(First Input Delay)是用户第一次与页面交互(例如点击链接、按钮等操作)到浏览器对交互作出响应的时间,比较理想的时间是控制在 100ms 以内。

  FID 只关注不连续的操作,例如点击、触摸和按键,不包含滚动和缩放之类的连续操作。

  这个指标是用户对网站响应的第一印象,若延迟时间越长,那就会降低用户对网站的整体印象。

  减少站点初始化时间(即加速渲染)和消除冗长的任务(避免阻塞主线程)有助于消除首次输入延迟。

  在下图的 Chrome DevTools Performance 面板中,描绘了一个繁忙的主线程。

  如果用户在较长的帧(600.9 毫秒和 994.5 毫秒)期间尝试交互,那么页面的响应需要等待比较长的时间。

  

  FID 的计算方式和 LCP 类似,也是借助 PerformanceObserver 实现,如下所示。

  public observerFID(): void {
    const fidType = 'first-input';
    const isSupport = this.checkSupportPerformanceObserver(fidType);
    // 浏览器兼容判断
    if(!isSupport) {
      return;
    }
    const po = new PerformanceObserver((entryList, obs): void => {
      const entries = entryList.getEntries();
      const firstInput = (entries as any)[0] as TypePerformanceEntry;
      // 测量第一个输入事件的延迟
      this.fid = rounded(firstInput.processingStart - firstInput.startTime);
      // 断开此观察者的连接,因为回调仅触发一次
      obs.disconnect();
    });
    po.observe({ type: fidType, buffered: true } as any);
    // po.observe({ entryTypes: [fidType] });
  }

  INP(Interaction to Next Paint)是 Google 的一项新指标,用于衡量页面对用户输入的响应速度。

  它测量用户交互(如单击或按键)与屏幕的下一次更新之间经过的时间,如下图所示。

  

  在未来,INP 将会取代 FID,因为 FID 有两个限制:

  • 它只考虑用户在页面上的第一次交互。
  • 它只测量浏览器开始响应用户输入所需的时间,而不是完成响应所需的时间。

4)TTI

  TTI(Time to Interactive)是一个与交互有关的指标,它可测量页面从开始加载到主要子资源完成渲染,并能够快速、可靠地响应用户输入所需的时间。

  它的计算规则比较繁琐:

  • 先找到 FCP 的时间点。
  • 沿时间轴正向搜索时长至少为 5 秒的安静窗口,其中安静窗口的定义为:没有长任务(Long Task)且不超过两个正在处理的网络 GET 请求。
  • 沿时间轴反向搜索安静窗口之前的最后一个长任务,如果没有找到长任务,则在 FCP 处终止。
  • TTI 是安静窗口之前最后一个长任务的结束时间,如果没有找到长任务,则与 FCP 值相同。

  下图有助于更直观的了解上述步骤,其中数字与步骤对应,竖的橙色虚线就是 TTI 的时间点。

  

  TBT(Total Blocking Time)是指页面从 FCP 到 TTI 之间的阻塞时间,一般用来量化主线程在空闲之前的繁忙程度。

  它的计算方式就是取 FCP 和 TTI 之间的所有长任务消耗的时间总和。

  不过网上有些资料认为 TTI 可能会受当前环境的影响而导致测量结果不准确,因此更适合在实验工具中测量,例如 LightHouse、WebPageTest 等

  Google 的 TTI Polyfill 库的第一句话就是不建议在线上搜集 TTI,建议使用 FID。

5)CLS

  CLS(Cumulative Layout Shift)会测量页面意外产生的累积布局的偏移分数,即衡量布局的稳定性。

  布局不稳定会影响用户体验,例如按钮在用户试图点击时四处移动,或者文本在用户开始阅读后四处移动,而这类移动的元素会被定义成不稳定元素。

  在下图中,描绘了内容在页面中四处移动的场景。

  

  布局偏移分数 = 影响分数 * 距离分数,而这个 CLS 分数应尽可能低,最好低于 0.1。

  • 影响分数指的是前一帧和当前帧的所有不稳定元素在可视区域的并集占比。
  • 距离分数指的是任何不稳定元素在一帧中位移的最大距离(水平或垂直)除以可视区域的最大尺寸(宽高取较大者)。

  若要计算 CLS,可以参考 Layout Instability Metric 给出的思路或 onCLS.ts,借助 PerformanceObserver 侦听 layout-shift 的变化,如下所示。

let clsValue = 0;
let clsEntries = [];
let sessionValue = 0;
let sessionEntries = [];
new PerformanceObserver((entryList) => {
  for (const entry of entryList.getEntries()) {
    // 只将不带有最近用户输入标志的布局偏移计算在内。
    if (!entry.hadRecentInput) {
      const firstSessionEntry = sessionEntries[0];
      const lastSessionEntry = sessionEntries[sessionEntries.length - 1];

      // 如果条目与上一条目的相隔时间小于 1 秒且
      // 与会话中第一个条目的相隔时间小于 5 秒,那么将条目
      // 包含在当前会话中。否则,开始一个新会话。
      if (sessionValue &&
          entry.startTime - lastSessionEntry.startTime < 1000 &&
          entry.startTime - firstSessionEntry.startTime < 5000) {
        sessionValue += entry.value;
        sessionEntries.push(entry);
      } else {
        sessionValue = entry.value;
        sessionEntries = [entry];
      }

      // 如果当前会话值大于当前 CLS 值,
      // 那么更新 CLS 及其相关条目。
      if (sessionValue > clsValue) {
        clsValue = sessionValue;
        clsEntries = sessionEntries;

        // 将更新值(及其条目)记录在控制台中。
        console.log('CLS:', clsValue, clsEntries)
      }
    }
  }
}).observe({type: 'layout-shift', buffered: true});

  优化 CLS 的手段有很多,例如一次性呈现所有内容、在某些内容仍在加载时使用占位符、图像或视频预设尺寸等。

总结

  在开篇就提出了量化性能的重要性,随后就引出了两个版本的性能规范,目前主流的是第二个版本。

  根据浏览器提供的性能参数,分析了 fetchStart、TCP、TTFB、白屏的计算细节,并且说明了几个影响 DOM 的性能参数。

  最后详细介绍了 Google 的核心Web指标,例如 LCP、FID、CLS 等。还介绍了一个已经废弃,但还在广泛使用的 FMP 指标。

 posted on 2023-02-20 09:47  咖啡机(K.F.J)  阅读(787)  评论(0编辑  收藏  举报