别再用performance计算首屏时间了!!

别再用performance计算首屏时间了!!

一、背景

前段时间备战双十一前期,线上项目的性能问题引起了我们的重视

公司内部是有统一的性能监控平台的,我们的项目也都统一接入了监控平台,但是这个时间的计算方式我们是不清楚的,于是花时间深入调研了一番

调研后的结果是,其他时间的计算方式(比如网路请求时间,首包时间...)是比较清晰的,指路,除了首屏时间,业内没有一个统一的标准

调研后首屏时间的计算方式还是很硬核的,最近得空记录分享出来~

本篇文章讲一种前端首屏时间的计算方案,偏算法实现,重点是思想,看懂就等于赚到!

image.png

二、什么是首屏时间

首屏时间:也称用户完全可交互时间,即整个页面首屏完全渲染出来,用户完全可以交互,一般首屏时间小于页面完全加载时间,该指标值可以衡量页面访问速度

1、首屏时间 VS 白屏时间

这两个完全不同的概念,白屏时间是小于首屏时间的 白屏时间:首次渲染时间,指页面出现第一个文字或图像所花费的时间

2、为什么 performance 直接拿不到首屏时间

随着 Vue 和 React 等前端框架盛行,Performance 已无法准确的监控到页面的首屏时间

因为 DOMContentLoaded 的值只能表示空白页(当前页面 body 标签里面没有内容)加载花费的时间

浏览器需要先加载 JS , 然后再通过 JS 来渲染页面内容,这个时候单页面类型首屏才算渲染完成

三、常见计算方式

  • 用户自定义打点—最准确的方式(只有用户自己最清楚,什么样的时间才算是首屏加载完成)
    • 缺点:侵入业务,成本高
  • 粗略的计算首屏时间: loadEventEnd - fetchStart/startTime 或者 domInteractive - fetchStart/startTime
  • 通过计算首屏区域内的所有图片加载时间,然后取其最大值
  • 利用 MutationObserver 接口,监听 document 对象的节点变化

四、我们的计算方案

利用 MutationObserver 接口,监听 DOM 对象的节点变化

提示:算法比较复杂,文章尽量用通俗易懂的方式表达,分析过程尽量简化,实际情况比这个复杂 首先,假设页面DOM最终结构如下,页面dom深度为3

<body>
  <div>
    <div>
      <div>1</div>
      <div>2</div>
    </div>
    <div>3</div>
    <div style="display: none;">4</div>
  </div>
  <ul>
    <li>1</li>
    <li>2</li>
  </ul>
</body>
复制代码

1、初始化 MutationObserver 监听

初始化代码如下

  • 如果当前浏览器不支持 MutationObserver 放弃上报
  • this.startTime取的window.performance.getEntriesByType('navigation')[0].startTime,即开始记录性能时间
  • this.observerData 数组用来记每次录DOM变化的时间以及变化的得分(变化的剧烈程度)
function mountObserver () {
    if (!window.MutationObserver) {
      // 不支持 MutationObserver 的话
      console.warn('MutationObserver 不支持,首屏时间无法被采集');
      return;
    }
    
    // 每次 dom 结构改变时,都会调用里面定义的函数
    const observer = new window.MutationObserver(() => {
      const time = getTimestamp() - this.startTime; // 当前时间 - 性能开始计算时间
      
      const body = document.querySelector('body');
      let score = 0;
      
      if (body) {
        score = traverseEl(body, 1, false);
        this.observerData.push({ score, time });
      } else {
        this.observerData.push({ score: 0, time });
      }
    });
    
    // 设置观察目标,接受两个参数: target:观察目标,options:通过对象成员来设置观察选项
    // 设为 childList: true, subtree: true 表示用来监听 DOM 节点插入、删除和修改时
    observer.observe(document, { childList: true, subtree: true });
    
    this.observer = observer;
 
   
    if (document.readyState === 'complete') {
      // MutationObserver监听的最大时间,10秒,超过 10 秒将强制结束
      this.unmountObserver(10000);
    } else {
      win.addEventListener(
        'load',
        () => {
          this.unmountObserver(10000);
        },
        false
      );
    }
  }
复制代码

Mutation 第一次监听到DOM变化时,DOM结构如下,可以看到div标签渲染出来了

<body>
  <div>
    <div>
      <div>1</div>
      <div>2</div>
    </div>
    <div>3</div>
    <div style="display: none;">4</div>
  </div>
</body>
复制代码

遍历 body 下的元素,通过方法 traverseEl 计算每次监听到 DOM 变化时得分,算法如下

2、计算 DOM 变化时得分

计算函数 traverseEl 如下

  • body 元素开始递归计算,第一次调用为 traverseEl(body, 1, false)
  • 排除无用的element节点,如 scriptstylemetahead
  • layer表示当前DOM层数,每层的得分等于1 + (层数 * 0.5) + 该层children的所有得分
  • 如果元素高度超出屏幕可视高度直接返回 0 分,即第一次调用时,如果元素高度已经超过屏幕可视高度了,直接返回 0
/**
 * 深度遍历 DOM 树
 * 算法分析
 * 首次调用为 traverseEl(body, 1, false);
 * @param element 节点
 * @param layer 层节点编号,从上往下,依次表示层数
 * @param identify 表示每个层次得分是否为 0
 * @returns {number} 当前DOM变化得分
 */
function traverseEl (element, layer, identify) {
  // 窗口可视高度
  const height = win.innerHeight || 0;
  let score = 0;
  const tagName = element.tagName;

  if (
    tagName !== 'SCRIPT' &&
    tagName !== 'STYLE' &&
    tagName !== 'META' &&
    tagName !== 'HEAD'
  ) {
    const len = element.children ? element.children.length : 0;

    if (len > 0) {
      for (let children = element.children, i = len - 1; i >= 0; i--) {
        score += traverseEl(children[i], layer + 1, score > 0);
      }
    }

    // 如果元素高度超出屏幕可视高度直接返回 0 分
    if (score <= 0 && !identify) {
      if (
        element.getBoundingClientRect &&
        element.getBoundingClientRect().top >= height
      ) {
        return 0;
      }
    }
    score += 1 + 0.5 * layer;
  }
  return score;
}
复制代码

第一次DOM变化计算分数score = traverseEl(body, 1, false)如下,可以看到此次变化得分是8.5
得分保存到this.observerDatathis.observerData.push({ score, time })

body =》 traverseEl(body, 1, false); score = 8.5;
   div =》 traverseEl(div, 2, false); score = 8.5;
     div =》 traverseEl(div, 3, false);  score = 6;
       div  =》 traverseEl(div, 4, false);  score = 3;
       div  =》 traverseEl(div, 4, false);  score = 3;
     div =》 traverseEl(div, 3, false);  score = 2.5;
     div =》 traverseEl(div, 3, false);  score = 0;
复制代码

Mutation 第二次监听到 DOM 变化时,可以看到ul标签也渲染出来了

<body>
  <div>
    <div>1</div>
    <div>2</div>
    <div style="display: none;">3</div>
  </div>
  <ul>
    <li>1</li>
    <li>2</li>
  </ul>
</body>
复制代码

同样计算分数score = traverseEl(body, 1, false),可以看到此次变化得分是10
把得分保存到数组this.observerData

body =》 traverseEl(body, 1, false); score = 10;
   div =》 traverseEl(div, 2, false); score = 5;
     div =》 traverseEl(div, 3, false);  score = 2.5;
     div =》 traverseEl(div, 3, false);  score = 2.5;
     div =》 traverseEl(div, 3, false);  score = 0;
   ul =》 traverseEl(div, 2, false); score = 5;
     li =》 traverseEl(div, 3, false);  score = 2.5;
     li =》 traverseEl(div, 3, false);  score = 2.5;
复制代码

到此就拿到了一个 DOM 变化的数组 this.observerData

实际上会多次调用 Mutation 监听,会有重复分数的项

3、去掉 DOM 被删除情况的监听

首先删除掉后一个小于前一个的元素,即去掉 DOM 被删除情况的监听,因为页面渲染过程中如有大量 DOM 节点被删除,由于得分小,则会忽略掉
比如 [3,4,2,3,1,5,3],结果为 [3,4,5]

/**
 * @param observerData
 * @returns {*}
 */
function removeSmallScore (observerData) {
  for (let i = 1; i < observerData.length; i++) {
    if (observerData[i].score < observerData[i - 1].score) {
      observerData.splice(i, 1);
      return removeSmallScore(observerData);
    }
  }
  return observerData;
}
复制代码

4、取 DOM变化最大 时间点为首屏时间

依次遍历 observerData,如果 下一个得分score前一个得分score 差值大于 data.rate 则表示后面有新的 dom 元素渲染到页面中,则取下一个 time

这样处理,可以排除有动画的元素渲染,或者轮播图等,更精准的计算首屏渲染时间

所以不能直接取最后一个元素时间,即 observerData[observerData.length-1].score

function getfirstScreenTime() {
    this.observerData = removeSmallScore(this.observerData);

    let data = null;
    const { observerData } = this;

    for (let i = 1; i < observerData.length; i++) {
      if (observerData[i].time >= observerData[i - 1].time) {
        const scoreDiffer =
          observerData[i].score - observerData[i - 1].score;
        if (!data || data.rate <= scoreDiffer) {
          data = { time: observerData[i].time, rate: scoreDiffer };
        }
      }
    }

    if (data && data.time > 0 && data.time < 3600000) {
      // 首屏时间
      this.firstScreenTime = data.time;
    }
}

复制代码

5、异常情况下的处理

页面关闭时如果没有上报,立即上报

  • window 监听 beforeunload事件(当浏览器窗口关闭或者刷新时,会触发beforeunload事件)
  • this.calcFirstScreenTime,计算首屏时间状态,分为 initpending、和 finished 三个状态
  • 当页面关闭时,如果 this.calcFirstScreenTime = pending,则触发 unmountObserver 立即上报,并且卸载事件
window.addEventListener('beforeunload', this.unmountObserverListener);

const unmountObserverListener = () => {

    if (this.calcFirstScreenTime === 'pending') {
      this.unmountObserver(0, true);
    }

    if(!isIE()){
      window.removeEventListener('beforeunload', this.unmountObserverListener);
    }
};
复制代码

6、销毁 MutationObserver

我们看看 卸载MutationObserver 的时候又做了啥,该方法为 unmountObserver

该方法中会判断是否卸载 if (immediately || this.compare(delayTime)),如返回 true 则立即卸载,并给出最终计算的时间;如果返回 false ,500 毫秒后轮询 unmountObserver

this.observer.disconnect() 停止观察变动,MutationObserver.disconnect()

/**
 * @param delayTime 延迟的时间
 * @param immediately 指是否立即卸载
 * @returns {number}
 */
function unmountObserver (delayTime, immediately) {
    if (this.observer) {
      if (immediately || this.compare(delayTime)) {
        // MutationObserver停止观察变动
        this.observer.disconnect();
        this.observer = null;

        this.getfirstScreenTime()

        this.calcFirstScreenTime = 'finished';
      } else {
        setTimeout(() => {
          this.unmountObserver(delayTime);
        }, 500);
      }
    }
}

// * 如果超过延迟时间 delayTime(默认 10 秒),则返回 true
// * _time - time > 2 * OBSERVE_TIME; 表示当前时间与最后计算得分的时间相比超过了 1000 毫秒,则说明页面 DOM 不再变化,返回 true
function compare (delayTime) {
    // 当前所开销的时间
    const _time = Date.now() - this.startTime;
    // 取最后一个元素时间 time
    const { observerData } = this;
    const time =
      (
        observerData &&
        observerData.length &&
        observerData[observerData.length - 1].time) ||
      0;
    return _time > delayTime || _time - time > 2 * 500;
}
复制代码

写在最后

以上就是本文首屏时间的计算方案,欢迎探讨~

本文首发于 GitHub

分类:
前端