前端性能精进之优化方法论(一)——测量 (下)

简介: 前端性能精进之优化方法论(一)——测量 (下)

二、Core Web Vitals


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

  下表是关键指标的基准线,来源于字节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 指标。

相关文章
|
3天前
|
编解码 前端开发 JavaScript
从入门到精通:揭秘前端开发中那些不为人知的优化秘籍!
前端开发是充满无限可能的领域,从初学者到资深专家,每个人都追求更快、更稳定、更用户体验友好的网页。本文介绍了四大优化秘籍:1. HTML的精简与语义化;2. CSS的优雅与高效;3. JavaScript的精简与异步加载;4. 图片与资源的优化。通过这些方法,可以显著提升网页性能和用户体验。
11 3
|
12天前
|
缓存 前端开发 JavaScript
前端性能优化:Webpack与Babel的进阶配置与优化策略
【10月更文挑战第28天】在现代Web开发中,Webpack和Babel是不可或缺的工具,分别负责模块打包和ES6+代码转换。本文探讨了它们的进阶配置与优化策略,包括Webpack的代码压缩、缓存优化和代码分割,以及Babel的按需引入polyfill和目标浏览器设置。通过这些优化,可以显著提升应用的加载速度和运行效率,从而改善用户体验。
32 6
|
14天前
|
缓存 监控 前端开发
前端工程化:Webpack与Gulp的构建工具选择与配置优化
【10月更文挑战第26天】前端工程化是现代Web开发的重要趋势,通过将前端代码视为工程来管理,提高了开发效率和质量。本文详细对比了Webpack和Gulp两大主流构建工具的选择与配置优化,并提供了具体示例代码。Webpack擅长模块化打包和资源管理,而Gulp则在任务编写和自动化构建方面更具灵活性。两者各有优势,需根据项目需求进行选择和优化。
46 7
|
13天前
|
缓存 前端开发 JavaScript
前端工程化:Webpack与Gulp的构建工具选择与配置优化
【10月更文挑战第27天】在现代前端开发中,构建工具的选择对项目的效率和可维护性至关重要。本文比较了Webpack和Gulp两个流行的构建工具,介绍了它们的特点和适用场景,并提供了配置优化的最佳实践。Webpack适合大型模块化项目,Gulp则适用于快速自动化构建流程。通过合理的配置优化,可以显著提升构建效率和性能。
26 2
|
20天前
|
缓存 前端开发 JavaScript
前端性能优化:打造流畅用户体验的秘籍
【10月更文挑战第20天】前端性能优化:打造流畅用户体验的秘籍
31 3
|
19天前
|
存储 缓存 算法
前端算法:优化与实战技巧的深度探索
【10月更文挑战第21天】前端算法:优化与实战技巧的深度探索
14 1
|
19天前
|
缓存 前端开发 JavaScript
如何优化前端资源
如何优化前端资源
|
20天前
|
监控 前端开发 JavaScript
前端性能优化:打造流畅用户体验的秘籍
【10月更文挑战第20天】前端性能优化:打造流畅用户体验的秘籍
27 2
|
1天前
|
缓存 前端开发 JavaScript
前端性能优化:让你的网站更快、更流畅
前端性能优化:让你的网站更快、更流畅
6 0
|
20天前
|
前端开发 JavaScript UED
前端性能优化:打造流畅用户体验的秘诀
【10月更文挑战第20天】前端性能优化:打造流畅用户体验的秘诀
24 0