Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

原创:别再用performance计算首屏时间了!! #1

Open
zxyue25 opened this issue Nov 28, 2021 · 0 comments
Open

原创:别再用performance计算首屏时间了!! #1

zxyue25 opened this issue Nov 28, 2021 · 0 comments
Labels
原创 Something isn't working 性能优化 New feature or request 监控 This issue or pull request already exists

Comments

@zxyue25
Copy link
Owner

zxyue25 commented Nov 28, 2021

一、背景

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

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

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

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

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

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;
}
@zxyue25 zxyue25 changed the title 进阶必看:首屏时间硬核计算方式 原创:《面试官:让你计算首屏时间你会怎么算》 Nov 30, 2021
@zxyue25 zxyue25 added 原创 Something isn't working 性能优化 New feature or request 监控 This issue or pull request already exists labels Nov 30, 2021
@zxyue25 zxyue25 changed the title 原创:《面试官:让你计算首屏时间你会怎么算》 原创:别再用performance计算首屏时间了!! Dec 7, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
原创 Something isn't working 性能优化 New feature or request 监控 This issue or pull request already exists
Projects
None yet
Development

No branches or pull requests

1 participant