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

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

二、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 UED
探索前端性能优化技巧:提升用户体验的关键
在现代Web应用程序开发中,前端性能优化变得至关重要。本文将介绍一些关键的前端性能优化技巧,帮助开发者提高网页加载速度、响应能力和用户体验。我们将探讨优化资源加载、减少HTTP请求、优化代码结构等方面的方法,并分享一些实用的工具和技术,让您的Web应用程序更快、更高效。
|
8天前
|
缓存 前端开发 JavaScript
如何优化前端性能:关键步骤和技巧
提高前端性能是网站和应用程序开发中至关重要的一步。本文将介绍一些关键的优化步骤和技巧,帮助开发人员有效地提升前端性能,包括资源压缩、减少HTTP请求、使用CDN加速、优化图片等方面的方法。
|
2天前
|
缓存 前端开发 JavaScript
前端性能优化
在前端重构项目中,为提升用户体验和页面响应速度,采用React框架。遇到页面加载慢和白屏问题,主要归因于数据渲染效率低和状态管理复杂。通过路由懒加载减少初次加载时间,使用Redux Toolkit和immer优化状态管理,配合精细化数据缓存策略。此外,借助React.memo和shouldComponentUpdate避免不必要的渲染,并实施预加载和预渲染策略。关键在于性能意识、技术工具选择、状态管理和用户体验优先。前端开发是技术、用户体验和性能的综合艺术,需持续学习和优化。
|
2天前
|
Web App开发 前端开发 UED
前端开发之移动端体验优化
在一个前端项目中,面对移动端网页加载慢的问题,团队使用Chrome开发者工具和Lighthouse进行性能分析,发现资源加载、重绘回流和首屏空白是瓶颈。通过压缩图片和视频、使用懒加载、优化CSS样式、预加载内容及利用阿里云CDN,成功提升加载速度,改善用户体验,强调了前端性能优化的关键性。
76 0
前端开发之移动端体验优化
|
2天前
|
缓存 监控 前端开发
前端性能优化以及解决方案
前端性能优化关乎用户体验和网站竞争力,包括减少HTTP请求、使用CDN、压缩资源、延迟加载、利用浏览器缓存等策略。制定优化计划,使用监控工具,遵循最佳实践并持续学习,能提升网站速度和稳定性。
19 0
|
4天前
|
存储 缓存 监控
深入理解前端性能优化:从网络到渲染
网络优化包括减少HTTP请求、使用HTTP/2、开启GZIP压缩和缓存策略。资源加载优化涉及懒加载、预加载和预读取。渲染优化建议使用Critical CSS、减少CSS和JavaScript阻塞以及避免强制同步布局。Service Worker实现离线存储,性能监控使用Lighthouse等工具。动态导入和代码拆分减少初始加载时间,响应式图片适应不同设备。Web Workers处理耗时任务,避免内存泄漏。
26 3
|
8天前
|
缓存 前端开发 JavaScript
优化前端性能:提升网页加载速度的10个技巧
在当今互联网时代,网页加载速度已成为用户体验的重要指标之一。本文将介绍10个有效的前端优化技巧,帮助开发人员提升网页加载速度,提升用户体验,包括减少HTTP请求、压缩资源、优化图像等方面的实用建议。
|
8天前
|
前端开发 测试技术 持续交付
《跨界合作:前端与后端如何优化协作效率》
在当今软件开发领域,前端和后端开发团队通常是分开工作的,但他们的协作质量直接影响着项目的成功与否。本文将探讨如何通过优化前端与后端的协作方式,提高开发效率和项目质量,从而实现更好的跨界合作。
|
8天前
|
缓存 前端开发 UED
实战指南:如何优化前端性能提升用户体验
本文探讨了在当今互联网时代,前端性能优化对于提升用户体验的重要性,以及如何利用各种技术手段实现前端性能的优化。通过介绍前端性能优化的原则、常见的性能优化技巧和工具,以及实际案例分析,帮助开发者深入了解并掌握提升前端性能的方法,从而提升网站的加载速度、响应速度,提高用户的满意度和留存率。
|
8天前
|
Web App开发 缓存 前端开发
如何优化前端网页加载速度:最佳实践和工具推荐
本文探讨了如何通过采用最佳实践和利用先进的工具来优化前端网页加载速度。从压缩资源到使用CDN,从减少HTTP请求到利用缓存策略,我们将介绍一系列提高网页性能的技术手段。同时,我们还将推荐一些广受好评的工具,帮助开发者更轻松地实施这些优化策略。