前端监控FMP原理

总述

这是前端监控原理系列文章的第三部分。在这篇文章中,会为大家讲解:

  • FMP(First Meaningful Paint,即首次有意义内容绘制时间) 的背景、概念以及缺陷。
  • FMP 监控实现:基于布局变动趋势的 FMP 算法。

FMP 的概念

FMP(首次有意义内容绘制时间,First Meaningful Paint)是页面的主要内容被渲染到屏幕的时间点,换句话来说,即页面的主要内容何时对用户可见。这是我们衡量网页加载对用户体验的一个重要指标。

指标计算原理 -- 基于布局变动趋势衡量 FMP

FMP 并不像 FP、FCP 一样有浏览器直接提供的 API,需要开发者自行研究算法实现,这里介绍一种基于布局变动趋势衡量的算法:基本原理是依照 DOM 的复杂度为当前 DOM 设定一个分数,分析 DOM 分数的变动程度来找出合适的 FMP 时间点,而 DOM 的复杂程度和 DOM 的深度息息相关

TIP: 由于计算 FMP 比较耗费性能,而且在实践中,FMP并不是一个十分准确的值,这和被检测网页的 DOM 性质、用户衡量 FMP 的算法息息相关。

此外,该指标的定义依赖于特定于浏览器的实现细节,这意味着它不能标准化,也不能在所有 Web 浏览器中实现。

核心思路

随着页面加载的进行,布局对象(layout objects)会逐渐添加到布局树中(layout tree)。下面的第一幅图中展示了打开 Google 搜索时,大量的布局对象被添加到布局树中的过程,第二幅图则是重要时间点的屏幕截图,我们可得出以下结论:

  1. 0 - 1.57 秒,页面只渲染了一个搜索框和一个 tabs header,布局对象约为 70 个。此时可以看成是 FCP 的时间点。
  • 1.57 - 1.76 秒,顶部的 header 被渲染。
  • 1.76 秒,第一个搜索结果被渲染,此时布局对象有 100 个左右。
  • 1.8 - 1.9 秒左右,有更多的搜索结果被渲染,另外布局对象的数量也发生了较大的变动,到达了 300 个左右。此时,页面的主要内容都已经渲染完成,从产品的角度来看,页面的主要信息已经基本呈现,满足用户要求,所以这个时间点我们可以看成 FMP。

所以,大量布局对象变动的那个时间点可能就是 FMP,也就是:

FMP = 布局改变最大的那一次绘制

在上图中,布局改变最大的一次绘制是 1.907 秒,故 FMP = 1.907

注意不可见区域

上面的计算方法并不是很完善。请看下面的布局对象和对应网页的案例:

  • 在 6.047 秒和 24.25 秒,发生了一次大的布局变动,其中 24.25 秒的布局变动更大,根据上面的算法,它应该是 FMP。
  • 但是 24.25 秒的变动来自于不可见的区域(页面高度大于浏览器窗口高度),用户没有任何感知,这个不应该称之为有意义的变动。
  • 另外在 6.047 秒,页面的主要内容(基本骨架、文本)都已经呈现,它应该才是我们最终的 FMP,也就是:

FMP = 可见区域布局改变最大的那一次绘制

具体实现

如何计算某个元素是否可见

  • H5 提供了 Element.getBoundingClientRect() 这个 API,可以获取元素距离视口的相对位置、宽度和高度,以下情况均可以排除:

    • 元素的位置不在可视范围内,包括横向和纵向的。
    • 元素的高度、宽度小于等于 0。
  • 另外,注意考虑 CSS 的影响:

    • visibility === 'hidden',这种情况下元素占据空间,有宽度和高度,但不可见。
    • display === 'none',这种情况不占据元素空间,宽度和高度都为 0,可以和上面的判断合并。
  • 但是,出于性能考虑,应该尽可能少的去获取 DOM 的宽高或者样式以避免浏览器回流 & 重绘,在实际应用中我们可以忽略一些边界情况,例如在拥有横向滚动条时,右侧的元素不在视口中,或者 css 样式为 visibility === 'hidden' 的情况。

实现代码如下:

// 为确保性能,exact 默认为 false 以忽略一些边界情况
const isUnderView = top > window.innerHeight;
let isNotVisible: boolean;

if (!exact) {
  isNotVisible = height <= 0;
} else {
  isNotVisible = height <= 0 || width <= 0 || element.style.visibility === 'hidden';
}
console.log(isNotVisible);

如何判断布局变动情况?

浏览器有一个 MutationObserver() API,该接口提供了监听 DOM 树变动的能力。通过这个 API,我们可以在 DOM 发生变动时,在注册的回调函数做出一些分析 DOM 的操作(后面详细讲解),从而判断出布局改变的程度,以获得合适的 FMP 时间。

值得注意的是,我们得考虑一些性能问题:

  • 在上一小节中提到我们需要判断元素的位置,那么这些操作势必会触发浏览器的重绘(paint)甚至重新布局(layout) 这些代码的执行时机应该要好好考量。
  • MutationObserver() API 在 DOM 发生变动的时候就会回调,并且我们的分析操作会在其中执行,这个回调函数很可能会被频繁调用,需要进行一些处理。

对于上面提出的两条性能需求, window.requestAnimationFrame (下面简称 RAF)可以帮到我们,RAF 在浏览器下一帧开始时执行,避免使用 getBoundingClientRect 之类的 API 导致强制回流。

如何对 DOM 进行分析?

一个合理方法是分析出 DOM 的复杂程度并为其打分,理论上 DOM 嵌套层级越深、子节点越多,DOM 树越“茂密”,其复杂程度也越高,如何某一个时刻 DOM 分数变化的越大,那么此时很有可能就是 FMP,下面先给出计算分数的代码:

type HTMLElementWithCss = HTMLElement & {
  readonly style?: CSSStyleDeclaration;
};

// 需要忽略的功能性标签
export const IGNORE_TAGS = ['SCRIPT', 'STYLE', 'META', 'HEAD'];

/**
 * 递归地获取 DOM 布局分数, 该分数体现了某个节点的复杂程度
 * 注:不在视口中的子元素不会被考虑
 * @param element 根 dom 元素
 * @param depth 当前元素的深度
 * @param isSiblingExists 符合标准的(在视口中)的兄弟节点是否存在
 * @param exact 是否开启精确模式,如果开启,则还会验证元素的宽度和 css 样式属性,确保不在视口内,这可能会影响性能,默认为 false
 */

export const getDomLayoutScore = (
  element: HTMLElementWithCss,
  depth: number,
  isSiblingExists: boolean,
  exact?: boolean
) => {
  const { tagName, children } = element;

  if (!element || IGNORE_TAGS.includes(tagName)) {
    return 0;
  }



  const childNodes = Array.from(children || []) as HTMLElementWithCss[];



  const childrenScore = childNodes.reduceRight((siblingScore, currentNode) => {

    // 如果它的右子树兄弟分数存在,则无需计算 dom 位置

    const score = getDomLayoutScore(currentNode, depth + 1, siblingScore > 0, exact, onGetScore);

    return siblingScore + score;

  }, 0);



  // 如果有必要的话,会对该元素的位置进行 check

  // 需要满足的条件:1. 它的相邻兄弟节点没有分数 2. 它不是叶子节点

  const isPositionCheckNeeded = childrenScore <= 0 && !isSiblingExists;



  if (isPositionCheckNeeded) {

    if (!isFunction(element.getBoundingClientRect)) {

      return 0;

    }



    const { top, height, width } = element.getBoundingClientRect();



    // 这个 dom 元素是否可见,如果不可见那么这个元素对我们的 fmp 没有影响

    // https://docs.google.com/document/d/1BR94tJdZLsin5poeet0XoTW60M0SjvOJQttKT-JK8HI/view#

    // 主要包括:元素顶部位置是否在视口之下

    // 宽度是否小于 0,visibility 是否为 hidden 按理说也应该被考虑进去

    // 但是基于性能考虑默认尽可能忽略它们(在实际应用中这样的 DOM 元素应该也很少碰到,但也提供了 exact 选项进行判断)

    const isUnderView = top > window.innerHeight;

    let isNotVisible: boolean;



    if (!exact) {

      isNotVisible = height <= 0;

    } else {

      isNotVisible = height <= 0 || width <= 0 || element.style.visibility === 'hidden';

    }



    const isElementOutOfView = isUnderView || isNotVisible;

    if (isElementOutOfView) {

      return 0;

    }

  }

  return childrenScore + 1 + 0.5 * depth;

};

算法(下面称getDomLayoutScore)描述如下:

  • 给定一个根节点 element

  • 如果 element 的 标签是一些功能性标签,例如 script、meta 等,返回 0。

  • 获得所有孩子,递归地为每一个孩子执行 getDomLayoutScore,最终得到所有孩子分数之和。

  • 如果该节点的所有孩子的总分小于等于 0且其未存在兄弟节点, 若:

    • 该元素在可视范围内,返回孩子的总分 + 1 + 0.5 * 当前节点深度。
    • 该元素不在可是范围内,返回 0。
  • 如果该节点的所有孩子的总分大于 0 或其存在兄弟节点,则直接返回 孩子的总分 + 1 + 0.5 * 当前节点深度。

不难看出,这个算法主要通过 DOM 的深度和子节点数目来衡量 DOM 的复杂度。

有一些值得注意的优化点:

  • 在遍历到一些功能性节点时,直接返回 0 而不再递归,达到剪枝的目的。
  • 大部分情况下, 如果孩子节点的总分大于 0,说明孩子存在且在视口范围内,父亲也在视口范围内,不需要再额外获取位置影响性能。
  • 大部分情况下,如果下方兄弟节点的总分大于 0,说明下方兄弟节点在视口范围内,则当前节点也在视口范围内,不需要再额外获取位置影响性能。
  • 对于校验视口范围的方法可以选择精确度,供开发者按照项目的特性自行选择(默认性能优先)。

综合上述内容,我们可以在某个时刻,通过下面的调用来获取某个 DOM 元素的得分

const myScore = getDomLayoutScore(document.body, 1, false);

下面的 CodePen 案例以可视化的形式展示了上面的递归调用逻辑,读者可以更好理解上面的算法:

  • 空白的方框表示这个元素的分数为 0(不在视口内)
  • isPositionCheckedNeeded 表示这个元素是否执行了 getBoundingClientRect(),你可以发现基于上面提到的优化策略,只有很少的元素执行了此操作,大大提高了性能。
  • depth 和 score 表示当前节点的深度和总分。

无法复制加载中的内容

接着,利用上面提到的 MutationObserverrequestAnimationFrame 调度它,首先对 raf 进行一次封装,这里有个小注意点,那就是 requestAnimationFrame 不管理回调函数,也就是说绑定 MutationObserver带有 raf 的回调函数被执行多次 -> 导致最终的 DOM 打分在同一帧内执行多次,影响性能,可以看下面这个 CodePen DEMO:

无法复制加载中的内容

上面问题的解决方案使用我们熟悉的节流函数包装回调,或者增加一个标记变量用来标记是否已经初始化,如果是,利用 cancelAnimationFrame 将旧的回调取消而使用新的,具体实现如下:

/**

 * 使用 request animation frame 调度某个回调函数
 */


export const useRequestAnimationFrame = (callback: FrameRequestCallback) => {
  // getAnimationFrame 只是获取了 window 下的相关 api 函数,无额外作用
  const apis = getAnimationFrame();

  if (!apis) {
    return;
  }

  const { raf, caf } = apis;

  if (!isFunction(raf) || !isFunction(caf)) {
    return;
  }

  // raf 的返回值为非 0 数字
  let rafTimer: number = 0;

  const runCallback = () => {
    if (rafTimer) {
      // requestAnimationFrame 不管理回调函数
      // 在回调被执行前,多次调用带有同一回调函数的 requestAnimationFrame,会导致回调在同一帧中执行多次
      // 常见的情况是一些事件机制导致多次触发
      // 设定一个 timer,如果接下来回调再次被调度,那么撤销上一个
      // https://www.w3.org/TR/animation-timing/#dom-windowanimationtiming-requestanimationframe
      caf(rafTimer);
    } else {
      rafTimer = raf(callback);
    }
  };

  const cancelCallback = () => {
    if (rafTimer) {
      caf(rafTimer);
    }
  };
  
  return {
    runCallback,
    cancelCallback,
  };

};

综上所述,主程序代码流程如下:

  • 在主程序启动时,初始化 MutationObserver 和当前时间。
  • 每当 DOM 变动, MutationObserver 回调被执行,会在 requestAnimationCallback 中注册计算 DOM 分数的回调,在下一次绘制之前调用之。
  • 计算 DOM 分数的回调执行完成之后,将本次计算分数和时间保存到一个数组 scoredData 中。
  • onload(DOM 和所需资源加载完成的时机) 事件后 1 秒销毁,原因是 FMP 和 onload 时间并没有直接联系,不过按照常理应该是出现在它之前,至于延迟一秒是为了减少误差。
interface FMPRecodeData {
  time: number;
  domScore: number;
}

export const createFMPMonitor = (options: FMPMonitorOptions) => {
  const MutationObserver = getMutationObserver();

  if (!MutationObserver) {
    return;
  }

  const startTime = Date.now();

  const scoredData: FMPRecodeData[] = [];

  const observeFMP = () => {
    const callback = useRequestAnimationFrame(() => {
 const bodyScore = getDomLayoutScore(document.body, 1, true); 
      scoredData.push({
        domScore: bodyScore,
        time: Date.now() - startTime,
      });
    });

    const observer = new MutationObserver(() => {
      callback.runCallback();
    });

    observer.observe(document.body, {
      subtree: true,
      childList: true,
    });

    return observer;
  };

  const observer = observeFMP();

  const reportData = () => {
    options.onReport({
      data: {
 fmp: calculateFMP(scoredData).time ,
      },
      eventType: EventType.FMP,
    });
    observer.disconnect();
  };

  // FMP 和 onload 事件并不密切相关,但它很可能在 onload 事件附近,所以我们延时一小段时间再报告
  onPageLoad(() => {
    setTimeout(() => {
      reportData();
    }, 1000);
  });
};

结束监听之后,就需要处理并上报数据了,在 scoredData 中已经得到了一个或者多个时间点下的 DOM 分数,接下来需要:

  • scoredData 按照时间的顺序从小到大排序。
  • 计算每两个相邻时间点的 DOM 分数差值,选择一个差值最大的,其对应的较晚的那个时间点就是 FMP,例如下面的代码:
const res = calculateFMP([
  {
    time: 0,
    domScore: 10,
  },
  {
    time: 110,
    domScore: 20,
  },
  {
    time: 220,
    domScore: 1200,
  },
  {
    time: 1220,
    domScore: 1000,
  },
]);

不难得出,最终得到的时间点为 220,对应的分数差为 1200 - 20 = 1180****

具体实现代码如下(TIP: 这种场景下使用数组的 reduce 方法很方便):

export const calculateFMP = (scoredData: FMPRecodeData[]) => {
  // 首先将打分结果基于时间排序(时间戳从小到大)
  scoredData.sort((a, b) => a.time - b.time);

  // 计算每两个时间戳之间的得分差值,变动最大的即为最终结果
  const initInfoValue = {
    maxDelta: -1,
    time: -1,
    prev: {
      time: 0,
      domScore: 0,
    } as FMPRecodeData,
  };

  const res = scoredData.reduce((info, curr) => {
    const delta = curr.domScore - info.prev.domScore;
    if (delta > info.maxDelta) {
      info.maxDelta = delta;
      info.time = curr.time;
    }
    info.prev = curr;
    return info;
  }, initInfoValue);

  return res;
};
全部评论

相关推荐

点赞 评论 收藏
分享
评论
1
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务