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

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

二、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 指标。

相关文章
|
2月前
|
存储 前端开发 安全
实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡
实现“永久登录”:针对蜻蜓Q系统的用户体验优化方案(前端uni-app+后端Laravel详解)-优雅草卓伊凡
193 5
|
5月前
|
前端开发 JavaScript 索引
前端性能优化:虚拟滚动技术原理与实战
前端性能优化:虚拟滚动技术原理与实战
762 80
|
5月前
|
缓存 监控 前端开发
前端性能优化:现代框架的关键策略
前端性能优化:现代框架的关键策略
312 74
|
5月前
|
缓存 前端开发 JavaScript
前端性能优化:打造流畅的用户体验
前端性能优化:打造流畅的用户体验
|
9月前
|
缓存 前端开发 UED
如何优化前端性能以提高加载速度
前端性能优化对提升网站加载速度至关重要,直接影响用户体验、SEO排名和转化率。本文介绍了优化前端加载速度的关键技巧,包括最小化HTTP请求、使用CDN、优化图片、利用浏览器缓存、压缩文件和实现懒加载。通过这些方法,可以显著减少页面加载时间,提高网站的整体性能和用户满意度。
|
10月前
|
前端开发 JavaScript 开发者
前端 CSS 优化:提升页面美学与性能
前端CSS优化旨在提升页面美学与性能。通过简化选择器(如避免复杂后代选择器、减少通用选择器使用)、合并样式表、合理组织媒体查询,可减少浏览器计算成本和HTTP请求。利用硬件加速和优化动画帧率,确保动画流畅。定期清理冗余代码并使用缩写属性,进一步精简代码。这些策略不仅加快页面加载和渲染速度,还提升了视觉效果,为用户带来更优质的浏览体验。
|
8月前
|
人工智能 JavaScript 前端开发
Vue 性能革命:揭秘前端优化的终极技巧;Vue优化技巧,解决Vue项目卡顿问题
Vue在处理少量数据和有限dom的情况下技术已经非常成熟了,但现在随着AI时代的到来,海量数据场景会越来越多,Vue优化技巧也是必备技能。 博客不应该只有代码和解决方案,重点应该在于给出解决方案的同时分享思维模式,只有思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
|
11月前
|
机器学习/深度学习 前端开发 算法
婚恋交友系统平台 相亲交友平台系统 婚恋交友系统APP 婚恋系统源码 婚恋交友平台开发流程 婚恋交友系统架构设计 婚恋交友系统前端/后端开发 婚恋交友系统匹配推荐算法优化
婚恋交友系统平台通过线上互动帮助单身男女找到合适伴侣,提供用户注册、个人资料填写、匹配推荐、实时聊天、社区互动等功能。开发流程包括需求分析、技术选型、系统架构设计、功能实现、测试优化和上线运维。匹配推荐算法优化是核心,通过用户行为数据分析和机器学习提高匹配准确性。
816 4
|
11月前
|
缓存 监控 前端开发
探索前端性能优化:关键策略与代码实例
本文深入探讨前端性能优化的关键策略,结合实际代码示例,帮助开发者提升网页加载速度和用户体验,涵盖资源压缩、懒加载、缓存机制等技术。
|
12月前
|
搜索推荐 前端开发 定位技术
前端开发人员SEO优化技术方案
不同的搜索引擎提供了服务后台常见功能来优化网站搜索
253 2

热门文章

最新文章

  • 1
    前端如何存储数据:Cookie、LocalStorage 与 SessionStorage 全面解析
  • 2
    前端工程化演进之路:从手工作坊到AI驱动的智能化开发
  • 3
    Vue 3 + TypeScript 现代前端开发最佳实践(2025版指南)
  • 4
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(五):背景属性;float浮动和position定位;详细分析相对、绝对、固定三种定位方式;使用浮动并清除浮动副作用
  • 5
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(六):全方面分析css的Flex布局,从纵、横两个坐标开始进行居中、两端等元素分布模式;刨析元素间隔、排序模式等
  • 6
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(一):CSS发展史;CSS样式表的引入;CSS选择器使用,附带案例介绍
  • 7
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(八):学习transition过渡属性;本文学习property模拟、duration过渡时间指定、delay时间延迟 等多个参数
  • 8
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(九):强势分析Animation动画各类参数;从播放时间、播放方式、播放次数、播放方向、播放状态等多个方面,完全了解CSS3 Animation
  • 9
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(四):元素盒子模型;详细分析边框属性、盒子外边距
  • 10
    【CSS】前端三大件之一,如何学好?从基本用法开始吧!(二):CSS伪类:UI伪类、结构化伪类;通过伪类获得子元素的第n个元素;创建一个伪元素展示在页面中;获得最后一个元素;处理聚焦元素的样式
  • 下一篇
    oss云网关配置