统计页面首屏时间,很多人第一步就错了

avatar
公众号:转转技术

前言

前端页面性能对用户留存、用户直观体验有着重要作用。这样的话如何更好的监控前端页面性能就变的十分重要。前端页面的性能监控主要分为两个方式:

一种叫做合成监控 Synthetic Monitoring, SYN。就是在一个模拟场景里,提交一个需要做性能审计的页面,通过一系列的工具、规则去运行页面,提取一些性能指标,得出一个审计报告。合成监控中最近比较流行的是 GoogleLighthouse

另一种是真实用户监控Real User Monitoring,RUM。监控真实的用户访问数据,上报数据到服务器,然后经过数据清洗加工,得到最终的性能数据。

在前端性能监控中有一个非常重要的指标就是首屏时间,因为首屏时间直接反应了用户多久能看到页面的主要内容,这决定了用户体验。这样的话,如何取到准确的首屏时间对我们来说就变的非常重要。本文就结合之前的实践,聊一聊首屏时间如何计算。

Performance

在 SSR(服务端渲染)的应用中,我们认为htmlbody渲染完成的时间就是首屏时间。我们通常使用 W3C 标准的Performance对象来计算首屏时间。

Performance经常被用于采集性能数据,因为对象内置了几乎所有常用前端需要的性能参数。

image

Performance包含了四个属性:memorynavigationtimeOrigintiming,以及一个事件处理程序onresourcetimingbufferfull。下面我们简单介绍一下Performanceapi

memory

memory这个属性提供了一个可以获取到基本内存使用情况的对象MemoryInfo

performance.memory = {
  jsHeapSizeLimit, // 内存大小限制,单位是字节B
  totalJSHeapSize, // 可使用的内存大小,单位是字节B
  usedJSHeapSize   // JS对象占用的内存大小,单位是字节B
}

navigation

返回PerformanceNavigation对象,提供了在指定的时间段发生的操作相关信息,包括页面是加载还是刷新、发生了多少重定向等。

performance.navigation = {
  redirectCount: '',
  type: ''
}

timeOrigin

返回性能测量开始的时间的高精度时间戳

timing

返回 PerformanceTiming 对象,包含了各种与浏览器性能相关的数据,提供了浏览器处理页面的各个阶段的耗时。下面是常用时间点计算

window.onload = function() {
  var timing  = performance.timing;
  console.log('准备新页面时间耗时: ' + timing.fetchStart - timing.navigationStart);
  console.log('redirect 重定向耗时: ' + timing.redirectEnd  - timing.redirectStart);
  console.log('Appcache 耗时: ' + timing.domainLookupStart  - timing.fetchStart);
  console.log('unload 前文档耗时: ' + timing.unloadEventEnd - timing.unloadEventStart);
  console.log('DNS 查询耗时: ' + timing.domainLookupEnd - timing.domainLookupStart);
  console.log('TCP连接耗时: ' + timing.connectEnd - timing.connectStart);
  console.log('request请求耗时: ' + timing.responseEnd - timing.requestStart);
  console.log('白屏时间: ' + timing.responseStart - timing.navigationStart);
  console.log('请求完毕至DOM加载: ' + timing.domInteractive - timing.responseEnd);
  console.log('解释dom树耗时: ' + timing.domComplete - timing.domInteractive);
  console.log('从开始至load总耗时: ' + timing.loadEventEnd - timing.navigationStart);
}

通过上面的介绍, 我们可以轻松的得到首屏时间

domLoadedTime = timing.domContentLoadedEventStart - timing.navigationStart

FMP

但是随着 VueReact等前端框盛行, 导致Performance无法准确的监控到页面的首屏时间。因为页面的body是空,浏览器需要先加载js, 然后再通过js来渲染页面内容。那我们使用什么数据来当做首屏时间呢?

Lighthouse中我们可以得到 FMP 值,FMP(全称 First Meaningful Paint,翻译为首次有效绘制)表示页面的主要内容开始出现在屏幕上的时间点,它是我们测量用户加载体验的主要指标。我们可以认为FMP的值就是首屏时间,但是浏览器并没有把FMP的数据提供出来。那我们如何计算呢?

整个计算流程分为两个下面两个部分: 1、监听元素加载,主要是为了计算Dom的分数 2、计算分数的曲率,计算出最终的FMP

初始化监听

initObserver() {
  try {
    if (this.supportTiming()) {
      this.observer = new MutationObserver(() => {
        let time = Date.now() - performance.timing.fetchStart;
        let bodyTarget = document.body;
        if (bodyTarget) {
          let score = 0;
          score += calculateScore(bodyTarget, 1, false);
          SCORE_ITEMS.push({
            score,
            t: time
          });
        } else {
          SCORE_ITEMS.push({
            score: 0,
            t: time
          });
        }
      });
    }

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

    if (document.readyState === "complete") {
      this.mark = 'readyState';
      this.calFinallScore();
    } else {
      window.addEventListener(
        "load",
        () => {
          this.mark = 'load';
          this.calFinallScore();
        },
        true
      );
      window.addEventListener(
        'beforeunload',
        () => {
          this.mark = 'beforeunload';
          this.calFinallScore();
        },
        true
      )
      const that = this;
      function listenTouchstart() {
        if(Date.now() > 2000) {
          that.calFinallScore();
          this.mark = 'touch';
          window.removeEventListener('touchstart', listenTouchstart, true);
        }
      }
      window.addEventListener(
        'touchstart',
        listenTouchstart,
        true
      )
    }
  } catch (error) {}
}

我们通过MutationObserver来监听Dom的变化, 然后计算当前时刻Dom的分数。有人可能会问,如果Dom每一次变化,都进行监听,是不是会特别消耗页面的性能?其实MutationObserver在执行回调时是批量执行,有些类似Vue等前端框架的渲染过程。

计算分数

function calculateScore(el, tiers, parentScore) {
  try {
    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;
  } catch (error) {

  }
}

通过上面的代码,我们可以得到计算分数的步骤

1、从body元素开发递归计算 2、会排查无用的元素标签比较SCRIPT等 3、如果元素超出屏幕就认为是 0 分 4、第一层的元素是 1 分,第二次的元素是 1 + (层数 * 0.5),也就是 1.5 分,依次类推,最终得打整个Dom数的总体分数

计算出 FMP

我们通过MutationObserver得到了一个数组,数组的每一项就是每次Dom变化的时间和分数。那么我们怎么计算出想要的FMP的值呢?

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
    });
  }
}

通过上面的代码,我们会得到最终的FMP的值,就是变化最大的这个DOM变化。

image

总结

到这里我们就基本把首屏时间的计算方式介绍完毕。总结为一句话,就是SSR使用Dom渲染结束的时间,SPA的项目使用FMP的时间。

本月文章预告

预告下,接下来我们会陆续发布转转在多端 SDK、移动端等基础架构和中台技术相关的实践与思考,欢迎大家关注公众号 “大转转 FE”,期望与大家多多交流