前端性能优化之指标采集和上报

495 阅读5分钟

指标采集:首屏时间指标采集具体办法

手动采集办法及优缺点

手动采集,一般是通过埋点的方式进行, 比如在页面开始位置打上 FMP.Start(),在首屏结束位置打上 FMP.End(),利用 FMP.End()-FMP.Start() 获取到首屏时间。

它兼容性强,业务同学知道在这个业务场景下首屏结束点在哪里,可以随情况变动。但手动采集会和业务代码严重耦合,如果首屏采集逻辑调整,业务代码也需要修改;还有,它的覆盖率不足,因为要手动采集,业务一旦忙起来,性能优化方案就会延迟排后。

最后,手动采集的统计结果并不精确,因为依赖于人,每个人对首屏的理解有偏差,经常打错或者忘记打点。

自动化采集优势及办法

自动化采集,即引入一段通用的代码来做首屏时间自动化采集,引入过程中,除了必要的配置不需要做其他事情。

首屏指标自动化采集,需要考虑是服务端模板业务,还是单页面(SPA)应用开发业务,业务场景不同,对应的采集方法也不同。下面我来分别介绍下。

服务端模板业务下的采集办法

服务端模板项目的加载大致流程是这样的:HTTP 请求 → HTML 文档加载解析完成 → 加载样式和脚本文件 → 完成页面渲染。

其中,HTML 文档加载解析完成的时间点,就是首屏时间点,而要采集这个首屏时间,可以用浏览器提供的 DOMContentLoaded 接口来实现。

当页面中的 HTML 元素被加载和解析完成(不需要等待样式表、图片和一些脚本的加载过程),DOMContentLoaded 事件触发。此时我们记录下当前时间 domContentLoadedEventEnd,再减去页面初始进入的时间 fetchStart,就是 DOMContentLoaded 的时间,也就是我们要采集的首屏时间。 即首屏时间=DOMContentLoaded 时间=domContentLoadedEventEnd-fetchStart。

单页面(SPA)应用业务下的采集办法

SPA下,用户请求一个页面时,页面会先加载 index.html,加载完成后,就会触发 DOMContentLoaded 和 load。而这个时候,页面展示的只是个空白页。此时根本不算真正意义的首屏。接下来,页面会加载相关脚本资源并通过 axios 异步请求数据,使用数据渲染页面主题部分,这个时候首屏才渲染完成。

目前主流的统计是参考淘宝首屏统计方案,使用MutationObserver:

MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。

使用 MutationObserver 能监控页面信息的变化,当页面 body 变化最剧烈的时候,我们拿到的时间数据,就是首屏时间。

具体实现是在用户进入页面时,我们可以使用 MutationObserver 监控 DOM 元素 (Document Object Model,文档对象模型)。当 DOM 元素发生变化时,程序会标记变化的元素,记录时间点和分数,存储到数组中。数据的格式类似于 [200ms,18.5] 。而计算分数需要设定元素权重。递归遍历 DOM 元素及其子元素,根据子元素所在层数设定元素权重,比如第一层元素权重是 1,当它被渲染时得 1 分,每增加一层权重增加 0.5,比如第五层元素权重是 3.5,渲染时给出对应分数。因为页面中每个 DOM 元素对于首屏的意义是不同的,越往内层越接近真实的首屏内容,如图片和文字,越往外层越接近 body 等框架层。

function CScor(el, tiers, parentScore) {
    let score = 0;
    const tagName = el.tagName;
    if ("SCRIPT" !== tagName && "STYLE" !== tagName && "META" !== tagName && "HEAD" !== tagName) {
      const childrenLen = el.children ? el.children.length : 0;
      if (childrenLen > 0) for (let childs = el.children, len = childrenLen - 1; len >= 0; len--) {
        score += calculateScore(childs[len], tiers + 1, score > 0);
      }
      if (score <= 0 && !parentScore) {
        if (!(el.getBoundingClientRect && el.getBoundingClientRect().top < WH)) return 0;
      }
      score += 1 + .5 * tiers;
    }
    return score;
  }
  calFinallScore() {
    try {
      if (this.sendMark) return;
      const time = Date.now() - performance.timing.fetchStart;
      var isCheckFmp = time > 30000 || SCORE_ITEMS && SCORE_ITEMS.length > 4 && time - (SCORE_ITEMS && SCORE_ITEMS.length && SCORE_ITEMS[SCORE_ITEMS.length - 1].t || 0) > 2 * CHECK_INTERVAL || (SCORE_ITEMS.length > 10 && window.performance.timing.loadEventEnd !== 0 && SCORE_ITEMS[SCORE_ITEMS.length - 1].score === SCORE_ITEMS[SCORE_ITEMS.length - 9].score);
      if (this.observer && isCheckFmp) {
        this.observer.disconnect();
        window.SCORE_ITEMS_CHART = JSON.parse(JSON.stringify(SCORE_ITEMS));
        let fmps = getFmp(SCORE_ITEMS);
        let record = null
        for (let o = 1; o < fmps.length; o++) {
          if (fmps[o].t >= fmps[o - 1].t) {
            let l = fmps[o].score - fmps[o - 1].score;
            (!record || record.rate <= l) && (record = {
              t: fmps[o].t,
              rate: l
            });
          }
        }
        //  
        this.fmp = record && record.t || 30001;
        try {
          this.checkImgs(document.body)
          let max = Math.max(...this.imgs.map(element => {
            if(/^(//)/.test(element)) element = 'https:' + element;
            try {
              return performance.getEntriesByName(element)[0].responseEnd || 0
            } catch (error) {
              return 0
            }
          }))
          record && record.t > 0 && record.t < 36e5 ? this.setPerformance({
            fmpImg: parseInt(Math.max(record.t , max))
          }) : this.setPerformance({});
        } catch (error) {
          this.setPerformance({});
          // console.error(error)
        }
      } else {
        setTimeout(() => {
          this.calFinallScore();
        }, CHECK_INTERVAL);
      }
    } catch (error) {
      
    }
  }

同时需要考虑图片加载:

<!doctype html>
<body><img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
  <img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
  <style type=text/css>
    background-image:url('https://www.baidu.com/img/dong_8f1d47bcb77d74a1e029d8cbb3b33854.gif);
  </style>
</body>
<html>
<script type="text/javascript">
  (() => {
    const imgs = []
    const getImageDomSrc = {
      _getImgSrcFromBgImg: function (bgImg) {
        var imgSrc;
        var matches = bgImg.match(/url(.*?)/g);
        if (matches && matches.length) {
          var urlStr = matches[matches.length - 1];
          var innerUrl = urlStr.replace(/^url(['"]?/, '').replace(/['"]?)$/, '');
          if (((/^http/.test(innerUrl) || /^///.test(innerUrl)))) {
            imgSrc = innerUrl;
          }
        }
        return imgSrc;
      },
      getImgSrcFromDom: function (dom, imgFilter) {
        if (!(dom.getBoundingClientRect && dom.getBoundingClientRect().top < window.innerHeight))
          return false;
        imgFilter = [/(.)(png|jpg|jpeg|gif|webp|ico|bmp|tiff|svg)/i]
        var src;
        if (dom.nodeName.toUpperCase() == 'IMG') {
          src = dom.getAttribute('src');
        } else {
          var computedStyle = window.getComputedStyle(dom);
          var bgImg = computedStyle.getPropertyValue('background-image') || computedStyle.getPropertyValue('background');
          var tempSrc = this._getImgSrcFromBgImg(bgImg, imgFilter);
          if (tempSrc && this._isImg(tempSrc, imgFilter)) {
            src = tempSrc;
          }
        }
        return src;
      },
      _isImg: function (src, imgFilter) {
        for (var i = 0, len = imgFilter.length; i < len; i++) {
          if (imgFilter[i].test(src)) {
            return true;
          }
        }
        return false;
      },
      traverse(e) {
        var _this = this , tName = e.tagName;
        if ("SCRIPT" !== tName && "STYLE" !== tName && "META" !== tName && "HEAD" !== tName) {
          var el = this.getImgSrcFromDom(e)
          if (el && !imgs.includes(el))
            imgs.push(el)
          var len = e.children ? e.children.length : 0;
          if (len > 0)
            for (var child = e.children, _len = len - 1; _len >= 0; _len--)
              _this.traverse(child[_len]);
        }
      }
    }
    getImageDomSrc.traverse(document.body);
    window.onload = function () {
      var max = Math.max(...imgs.map(element => {
        if (/^(//)/.test(element))
          element = 'https:' + element;
        try {
          return performance.getEntriesByName(element)[0].responseEnd || 0
        } catch (error) {
          return 0
        }
      }
      ))
      console.log(max);
    }
  }
  )()
</script>

将图片加载时间与使用 MutationObserver 获得的 DOM 首屏时间相比较,哪个更长,哪个就是最终的首屏时间。

白屏指标采集

先来回顾一下前面讲过的浏览器的页面加载过程:

客户端发起请求 -> 下载 HTML 及 JS/CSS 资源 -> 解析 JS 执行 -> JS 请求数据 -> 客户端解析 DOM 并渲染 -> 下载渲染图片-> 完成渲整体染。

在这个过程中,客户端解析 DOM 并渲染之前的时间,都算白屏时间。所以,白屏时间的采集思路如下:白屏时间 = 页面开始展示时间点 - 开始请求时间点。FP = domLoading - navigationStart。

卡顿指标采集

所谓卡顿,简单来说就是页面出现卡住了的不流畅的情况。

难点在于,在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。

在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。

H5 场景下获取 FPS 方案如下:

var fps_compatibility= function () {
    return (
        window.requestAnimationFrame ||
        window.webkitRequestAnimationFrame ||
        function (callback) {
            window.setTimeout(callback, 1000 / 60);
        }
    );
}();
var fps_config={
  lastTime:performance.now(),
  lastFameTime : performance.now(),
  frame:0
}
var fps_loop = function() {
    var _first =  performance.now(),_diff = (_first - fps_config.lastFameTime);
    fps_config.lastFameTime = _first;
    var fps = Math.round(1000/_diff);
    fps_config.frame++;
    if (_first > 1000 + fps_config.lastTime) {
        var fps = Math.round( ( fps_config.frame * 1000 ) / ( _first - fps_config.lastTime ) );
        console.log(`time: ${new Date()} fps is:`, fps);
        fps_config.frame = 0;    
        fps_config.lastTime = _first ;    
    };           
    fps_compatibility(fps_loop);   
}
fps_loop();
function isBlocking(fpsList, below=20, last=3) {
  var count = 0
  for(var i = 0; i < fpsList.length; i++) {
    if (fpsList[i] && fpsList[i] < below) {
      count++;
    } else {
      count = 0
    }
    if (count >= last) {
      return true
    }
  }
  return false
}

利用 requestAnimationFrame 在一秒内执行 60 次(在不卡顿的情况下)这一点,假设页面加载用时 X ms,这期间 requestAnimationFrame 执行了 N 次,则帧率为1000* N/X,也就是FPS。

网络环境采集

除了在APP或者微信小程序,我们没法直接拿到当前网络情况。可以拿到两张不同尺寸图片的加载时间,通过计算结果来判定当前网络环境。

具体来说,我们在每次页面加载时,通过客户端向服务端发送图片请求,比如,请求一张 11 像素的图片和一张 33 像素的图片,然后在图片请求之初打一个时间点,在图片 onLoad 完成后打一个时间点,两个时间点之差,就是图片的加载时间。

接着,我们用文件体积除以加载时间,就能得出两张图片的加载速度,然后把两张图片的加载速度求平均值,这个结果就可以当作网络速度了。

因为每个单页面启动时,都会做一次网速采集,得到一个网络速度,我们可以把这些网络速度做概率分布,就能得出当前网络情况是 2G (750-1400ms)、3G (230-750ms)、4G或者WiFi(0-230ms)。