前端性能优化——监控体系之FMP的智能获取算法(转载)

性能优化的算法你知道多少

不错鼓励并赞赏 标签: HTML CSS Javascript      评论 / 2019-09-15

今天来给大家介绍下前端监控中一个特定指标的获取算法,有人会问,为啥就单单讲一个指标?这是因为,目前大部分的指标,比如白屏时间,dom加载时间等等,都能通过现代浏览器提供的各种api去进行较为精确的获取,而今天讲的这个指标,以往获取他的方式只能是通过逻辑埋点去获取它的值,因此在做一些前端监控时,需要根据业务需要去改变页面对这个值的埋点方式,会比较繁琐,恰巧最近刚刚好在做一些前端监控相关的项目,遇到这个问题时就在想,能不能通过一种无须埋点的方式,将这个值给获取到?倒腾了一段时间,终于把算法弄出来了,今天就来给大家介绍下————FMP(first meaning paint) 指标的智能获取算法

什么是FMP

解答这个问题之前,我们先来了解下现代前端监控性能的主要指标统计方法,在2013年之后,标准组织推出了 performance timing api ,如下图

前端监控实践——FMP的智能获取算法

这个api统计了浏览器从网址开始导航到 window.onload 事件触发的时间点,比如请求开始的时间点—— requestStart ,响应结束的时间点—— responseEnd ,通过这些时间点我们可以计算出一些对页面加载质量有指导意见的时长,比如以下几个:

  • TTFB : ResponseStart - RequestStart (首包时间,关注网络链路耗时)
  • FPT : ResponseEnd - FetchStart (首次渲染时间 / 白屏时间)
  • TTI : DomInteractive - FetchStart (首次可交付时间)
  • Ready : DomContentLoadEventEnd - FetchStart (加载完成时间)
  • Load : LoadEventStart - FetchStart (页面完全加载时间)
  • DNS查询耗时 :domainLookupEnd - domainLookupStart
  • TCP链接耗时 :connectEnd - connectStart
  • request请求耗时 :responseEnd - responseStart
  • 解析dom树耗时 : domComplete- domInteractive
  • 白屏时间 :responseStart - navigationStart
  • domready时间 :domContentLoadedEventEnd - navigationStart
  • onload时间 :loadEventEnd - navigationStart

通过这些指标我们可以得到很多有用的web端网页加载信息,建立对网页性能概况

以上的指标可以对网页进行数值化的衡量,但是其实这种衡量只能体现一个视角的性能观点,比如TTFB很快,就能代表用户能够很快的看到页面的内容嘛?这个不一定是成立的,因此人们有开始从用户的视角去分析网页加载的性能情况,将用户看待加载过程,分成了以下几个阶段:

  • 页面是否正在正常加载 (happening)
  • 页面加载的内容是否已经足够(useful)
  • 页面是否已经可以操作了 (usable)
  • 页面是否可以交互,动画是否顺畅(delightful)

而我们今天讨论的 FMP(first meaningful paint) ,其实就是回答 is it useful ,加载的内容是否已经足够,其实这是一个很难被定义的概念。每个网页都有自己的特点,只有开发者和产品能够比较确定哪个元素加载的时间点属于 FMP ,今天我们就来讨论一下,如何比较智能的去找出页面那个主要的元素,确定页面的 FMP

成为FMP元素的条件

首先我们可以看看下面的图:

前端监控实践——FMP的智能获取算法

我们可以发现在页面中比较 useful 的内容,都是含有信息量比较丰富的,比如图片,视频,动画,另外就是占可视面积较大的,页面中还存在两种形态的内容可以被视为是 useful 的,一种是单一的块状元素,另外一种是由多个元素组合而成的大元素,比如视频元素,banner图,这种属于单一的块状元素,而像图片列表,多图像的组合,这种属于元素组合

总结一下成为FMP元素的条件:

  • 体积占比比较大
  • 屏幕内可见占比大
  • 资源加载元素占比更高(img, svg , video , object , embed, canvas)
  • 主要元素可能是多个组成的

算法如何设计

前面介绍了 FMP 的概念还有成为 FMP 的条件,接下来我们来看看如何设计 FMP 获取的算法,按照上面的介绍,我们知道算法分为以下两个部分:

FMP元素
FMP元素

如果有了解过浏览器加载原理的同学都知道,浏览器在在获取到 html 页面之后会逐步的对html文档进行解析,遇到 javascript 会停止html文档的解析工作,执行javascript,执行完继续解析html,直到整个页面解析完成为止。页面除了html文档中的元素加载,可能在执行javascript的时候,会产生动态的元素片段加载,一般来说,首屏元素会在这期间加载。因此我们只需要监控元素的加载和加载的时间点,然后再进行计算。

具体的算法流程如下图

前端监控实践——FMP的智能获取算法

相关的代码链接我已经放在最后面了,下面我会逐步的讲解整个算法流程

我把整个流程分为两个下面两个部分:

  1. 监听元素加载,主要是为了确定普通元素加载的时间点
  2. 确定 FMP元素 ,计算出最终的 FMP 值

下面我们按照步骤来分析

初始化监听

前端监控实践——FMP的智能获取算法
  • 可以看到首先我们先执行了 firstSnapshot 方法,用于记录在代码执行之前加载的元素的时间点
  • 接下来初始化 MutationObserver ,开始监听 document 的加载情况,在发生回调的时候,记录下当前到 performance.timing.fetchStart 的时间间隔,然后对body的元素进行深度遍历,进行打点,记录是在哪一次回调的时候记录的,如下图
前端监控实践——FMP的智能获取算法
  • 监听的最后我们会将在 window.onload 的时候去触发检查是否停止监听的条件,如下图

前端监控实践——FMP的智能获取算法

如果监听的时间超过 LIMIT ,或者发生回调的时间间隔已经超过1s中,我们认为页面已经稳定,停止dom元素加载的监听,开始进入计算过程

完成监听,进行元素得分计算

  • 首先前面我们说了,我们的元素对于页面的贡献是不同的,资源加载的元素会对用户视觉感官的影响比较大,比如图片,带背景的元素,视频等等,因此我设计了一套权重系统,如下:

前端监控实践——FMP的智能获取算法

可以看到 svg , img 的权重为2, canvas , object , embed , video 的权重为4,其他的元素为1,

也就是说,如果一个图片面积为1/2首屏面积,其实他的影响力会和普通元素占满首屏的影响力一样

  • 接着我们回到代码,我们首先会对整个页面进行深度优先遍历搜索,然后对每一个元素进行进行分数计算,如下图

前端监控实践——FMP的智能获取算法

可以看到我们通过 element.getBoundingClientRect 获取了元素的位置和大小,然后通过计算 "width * height * weight * 元素在viewport的面积占比" 的乘积,确定元素的最终得分,然后将改元素的子元素得分之和与其得分进行比较,去较大值,记录得分元素集

通过计算确定 FMP 元素,计算最终 FMP 时间

通过上面的步骤我们获取到了一个集合,这个集合是"可视区域内得分最高的元素的集合",我们会对这个集合的得分取均值,然后过滤出在平均分之上的元素集合,然后进行时间计算

前端监控实践——FMP的智能获取算法

可以看到分为两种情况去处理:

  1. weight 为1的普通元素,那么我们会通过元素上面的标记,去查询之前保存的时间集合,得到这个元素的加载时间点
  2. weight 不为1的元素,那么其实就存在资源加载情况,元素的加载时间其实是资源加载的时间,我们通过 performance.getEntries 去获取对应资源的加载时间,获取元素的加载速度

最后去所有元素最大的加载时间值,作为页面加载的 FMP 时间

最后

以上就是整个算法的比较具体的流程,可能有人会说,这个东西算出来的就是准确的么?这个算法其实是按照特征分析,特定的规则总结出来的算法, 总体来说还是会比较准确,当然web页面的布局如果比较奇特,可能是会存在一些偏差的情况。也希望大家能够一起来丰富这个东西,为 FMP 这个计算方法提出自己的建议

附上代码 链接


完整代码如下


const START_TIME = performance && performance.timing.responseEnd;

const IGNORE_TAG_SET = ["SCRIPT", "STYLE", "META", "HEAD", "LINK"];

const TAG_WEIGHT_MAP = {
  SVG: 2,
  IMG: 2,
  CANVAS: 4,
  OBJECT: 4,
  EMBED: 4,
  VIDEO: 4
};

const LIMIT = 1000;

const WW = window.innerWidth;

const WH = window.innerHeight;

const VIEWPORT_AREA = WW * WH;

const DELAY = 500;

class FMPTiming {
  constructor() {
    this.statusCollector = [];
    this.flag = true;
    this.muo = MutationObserver;
    this.observer = null;
    this.callbackCount = 1;
    this.mp = {};

    this.initObserver();
  }
  firstSnapshot() {
    let t = window.__DOMSTART - START_TIME;
    let bodyTarget = document.body;

    if (bodyTarget) {
      this.doTag(bodyTarget, this.callbackCount++);
    }
    this.statusCollector.push({
      t
    });
  }
  initObserver() {
    this.firstSnapshot();

    this.observer = new MutationObserver(() => {
      let t = Date.now() - START_TIME;
      let bodyTarget = document.body;

      if (bodyTarget) {
        this.doTag(bodyTarget, this.callbackCount++);
      }
      this.statusCollector.push({
        t
      });
    });

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

    if (document.readyState === "complete") {
      this.calFinallScore();
    } else {
      window.addEventListener(
        "load",
        () => {
          this.calFinallScore();
        },
        true
      );
    }
  }

  initResourceMap() {
    performance.getEntries().forEach(item => {
      this.mp[item.name] = item.responseEnd;
    });
  }

  doTag(target, callbackCount) {
    let tagName = target.tagName;

    if (IGNORE_TAG_SET.indexOf(tagName) === -1) {
      let childrenLen = target.children ? target.children.length : 0;
      if (childrenLen > 0) {
        for (let childs = target.children, i = childrenLen - 1; i >= 0; i--) {
          if (childs[i].getAttribute("f_c") === null) {
            childs[i].setAttribute("f_c", callbackCount);
          }
          this.doTag(childs[i], callbackCount);
        }
      }
    }
  }

  calFinallScore() {
    if (MutationObserver && this.flag) {
      if (!this.checkCanCal(START_TIME)) {
        console.time("calTime");
        this.observer.disconnect();

        this.flag = false;

        let res = this.deepTraversal(document.body);

        let tp;

        res.dpss.forEach(item => {
          if (tp && tp.st) {
            if (tp.st < item.st) {
              tp = item;
            }
          } else {
            tp = item;
          }
        });

        console.log(tp, this.statusCollector);

        this.initResourceMap();

        let resultSet = this.filterTheResultSet(tp.els);

        let fmpTiming = this.calResult(resultSet);

        console.log("fmp : ", fmpTiming);

        console.timeEnd("calTime");
      } else {
        setTimeout(() => {
          this.calFinallScore();
        }, DELAY);
      }
    }
  }

  calResult(resultSet) {
    let rt = 0;

    resultSet.forEach(item => {
      let t = 0;
      if (item.weight === 1) {
        let index = +item.node.getAttribute("f_c") - 1;
        t = this.statusCollector[index].t;
      } else if (item.weight === 2) {
        if (item.node.tagName === "IMG") {
          t = this.mp[item.node.src];
        } else if (item.node.tagName === "SVG") {
          let index = +item.node.getAttribute("f_c") - 1;
          t = this.statusCollector[index].t;
        } else {
          //background image
          let match = item.node.style.backgroundImage.match(/url\(\"(.*?)\"\)/);
          let s;
          if (match && match[1]) {
            s = match[1];
          }
          if (s.indexOf("http") == -1) {
            s = location.protocol + match[1];
          }
          t = this.mp[s];
        }
      } else if (item.weight === 4) {
        if (item.node.tagName === "CANVAS") {
          let index = +item.node.getAttribute("f_c") - 1;
          t = this.statusCollector[index].t;
        } else if (item.node.tagName === "VIDEO") {
          t = this.mp[item.node.src];

          !t && (t = this.mp[item.node.poster]);
        }
      }

      console.log(t, item.node);
      rt < t && (rt = t);
    });

    return rt;
  }

  filterTheResultSet(els) {
    let sum = 0;
    els.forEach(item => {
      sum += item.st;
    });

    let avg = sum / els.length;

    return els.filter(item => {
      return item.st > avg;
    });
  }

  deepTraversal(node) {
    if (node) {
      let dpss = [];

      for (let i = 0, child; (child = node.children[i]); i++) {
        let s = this.deepTraversal(child);
        if (s.st) {
          dpss.push(s);
        }
      }

      return this.calScore(node, dpss);
    }
    return {};
  }

  calScore(node, dpss) {
    let {
      width,
      height,
      left,
      top,
      bottom,
      right
    } = node.getBoundingClientRect();
    let f = 1;

    if (WH < top || WW < left) {
      //不在可视viewport中
      f = 0;
    }

    let sdp = 0;

    dpss.forEach(item => {
      sdp += item.st;
    });

    let weight = TAG_WEIGHT_MAP[node.tagName] || 1;

    if (
      weight === 1 &&
      node.style.backgroundImage &&
      node.style.backgroundImage !== "initial"
    ) {
      weight = TAG_WEIGHT_MAP["IMG"]; //将有图片背景的普通元素 权重设置为img
    }

    let st = width * height * weight * f;

    let els = [{ node, st, weight }];

    let areaPercent = this.calAreaPercent(node);

    if (sdp > st * areaPercent || areaPercent === 0) {
      st = sdp;
      els = [];

      dpss.forEach(item => {
        els = els.concat(item.els);
      });
    }

    return {
      dpss,
      st,
      els
    };
  }

  checkCanCal(start) {
    let ti = Date.now() - start;
    return !(
      ti > LIMIT ||
      ti -
        ((this.statusCollector &&
          this.statusCollector.length &&
          this.statusCollector[this.statusCollector.length - 1].t) ||
          0) >
        1000
    );
  }

  calAreaPercent(node) {
    let {
      left,
      right,
      top,
      bottom,
      width,
      height
    } = node.getBoundingClientRect();
    let wl = 0;
    let wt = 0;
    let wr = WW;
    let wb = WH;

    let overlapX =
      right - left + (wr - wl) - (Math.max(right, wr) - Math.min(left, wl));
    if (overlapX <= 0) {
      //x 轴无交点
      return 0;
    }

    let overlapY =
      bottom - top + (wb - wt) - (Math.max(bottom, wb) - Math.min(top, wt));
    if (overlapY <= 0) {
      return 0;
    }

    return (overlapX * overlapY) / (width * height);
  }
}

new FMPTiming();
Hi 看这里!

大家好,我是PRO

我会陆续分享生活中的点点滴滴,当然不局限于技术。希望笔墨之中产生共鸣,每篇文章下面可以留言互动讨论。Tks bd!

博客分类

您可能感兴趣

作者推荐

呃,突然想说点啥

前端·博客

您的鼓励是我前进的动力---

使用微信扫描二维码完成支付