spa项目如何计算首屏

3,422 阅读7分钟

对于首屏的定义,浏览器没有给出标准的指标,因为不同网站对于首屏的要求也是不尽相同的。我们从谷歌的第一次有效时间(first meaningfull paint)得到了一些启发,例如,一个新闻网站文字跟字体对于它来说是更重要的,而图片是次要的。新闻网站可以认为所有文字或字体加载出来即为首屏。但是对于电商网站来说,电商网站的图片可能更加重要,因为图片占据整个网站的80%以上。所以仅仅字体或文字被加载出来并不能定义为首屏时间。以此可以看出,首屏并不是一个可以通过简单的api就能计算出来的,首屏的方案也是因公司而异的。幸运的是,浏览器提供了各种监测性能及dom的api,可以让我们通过这些api来计算首屏时间。

首屏定义

举个例子,作为一个二手交易的电商平台,转转网站一半以上是由图片组成。我们以优品首页为例。它的页面加载过程看起来是这样的:

优品

由白屏 -> 加载文字、布局 -> 渲染图片 -> 图片完全加载出来

通过上图,我们可以看出来,在图片加载出来之前(第一张图片),我们并不能清楚知道这个页面想要告诉我们什么,上面白色的位置跟下面热卖专区即将是什么内容我们一概不知。直到所有图片加载出来为止,我们才能够清楚的知道整个页面所要表达的意图。

那么我们用一句话来概括电商首屏定义:初次手机屏幕内页面有效元素(图片)完全展现时间。

这里面有几个关键词:初次、屏幕内、有效元素、完全展现。

我们如何判断页面初次有效渲染的时间?

因为现在我们的前端页面大都是spa项目,而spa项目所有的渲染都是通过js来动态渲染的。所以w3c提供的一些api(load、DOMContentLoaded、domComplete)都会计算的不准确。因为我们整个网站html最初只有一个div。

那我们对于spa项目如何做到比较准确的性能统计呢?假如准许你可以使用框架钩子并可以侵入业务代码,那么你可以做哪些事情?

以vue为例,我们混合为每个组件添加一个mounted钩子,并记录mounted时间,最后在onLoad时候,取最后那个mounted时间,即最后一个组件挂载完成的时间为首屏时间。下面伪代码示例。

Vue.mixin({
    mounted() {
        setStore(time)
    }
})

window.addEventListener('load', () => {
    Pref.send(Math.max(...getStore))
}

这种方法在某些场景可以作为首屏时间,因为一个完整的可复用的高可维护的页面它的颗粒度是足够细的。但是你可能有好多疑问?

  • 如果一个页面并没有抽象成组件化,而它所有的渲染都是依赖于接口返回的数据,那么这种计算方式可能是有瑕疵的。
  • spa项目是异步加载的,onload时间是否是初次渲染结束的时间?
  • 还有最重要的一点,它并没有统计到图片下载的时间。
  • 如何判断当前页面dom初次渲染完成时间?
  • 。。。

好吧,我承认这种方式我们很容易找到投机的方式,并做到很好的性能数据。比如我的数据请求是在onload之后,页面使用模版渲染来代替使用组件,组件渲染时机放在onload之后...等等。但是这些操作并不是我们所提倡的,它反而延长了我们页面真正的渲染时间!

那么我们如何另辟蹊径,找到另一个突破口,尽量不侵入代码的情况下而做到准确的首屏时间呢?

在我不知道MutationObserver这个方法之前,我甚至觉得这是不可能做到。

MutationObserver接口提供了监视对DOM树所做更改的能力

我们可以大胆假设,如果通过 MutationObserver 监听页面body,当页面body元素变化最剧烈并达到最大时就是首屏初次渲染完成的时间。让我们试一下吧~

var targetNode = document.body;
var observerOptions = {
  childList: true, // 观察目标子节点的变化,添加或者删除
  subtree: true // 默认为 false,设置为 true 可以观察后代节点
}
var store = [];
var o = n.MutationObserver;
(new o(function () {
  // 计算dom数量并将dom变化时间记录下来,放进store
  store.push({
    num: computedDomNum(), // 计算dom元素,这个后面会讲
    time: performance.now() // 高精度时间获取
  })
  
})).observe(targetNode, observerOptions)

这样确实可以得到dom变化的数量以及速率,但是我们的首页往往是一个很长页面,而下面列表的dom元素被加载出来的时候其实我们并不是很关心,因为有很多已经不在我们的可视范围内了。所以我们需要将页面的元素增加不同的权重。

OK,我们调整一下计算dom的方法,这也是阿里云的计算方法

function r(e, n, t) {
    var i = 0,
        u = e.tagName;
    if ("SCRIPT" !== u && "STYLE" !== u && "META" !== u && "HEAD" !== u) {
      var c = e.children ? e.children.length : 0;
      if (c > 0) for (var a = e.children, l = c - 1; l >= 0; l--) {
        i += r(a[l], n + 1, i > 0);
      }if (i <= 0 && !t) {
        if (!(e.getBoundingClientRect && e.getBoundingClientRect().top < o)) return 0;
      }
      i += 1 + .5 * n;
    }
    return i;
  }

这样我们就可以达到,只计算首屏时间,这段代码的意思就是:只计算页面在屏幕内出现的元素,屏幕之外的元素不会统计在内。每一层子元素的权重会增加0.5,比如一个元素是在第一层那么这个元素的权重就是1.5,如果元素在第五层那么这个元素就是3.5。

接下来解决图片的加载问题

要解决图片加载问题,首先就要找出页面中所有的img跟div的background-image。

如果是img的话,我们可以使用img标签下的src属性获取属性值即可,如果是div的化可以使用 window.getComputedStyle(dom) 方式获取它的属性值

var computedStyle = window.getComputedStyle(dom);
var bgImg = computedStyle.getPropertyValue('background-image') || computedStyle.getPropertyValue('background');

然后通过正则获取图片的链接即可

然后通过 performance.getEntriesByName(element)[0].responseEnd 的方式可以获取到图片的下载时间,与我们计算的dom响应时间相比取最大值。

这个是获取图片的demo

(() => {
  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;
    },

    f(e) {
      var t = this
        , u = e.tagName;
      if ("SCRIPT" !== u && "STYLE" !== u && "META" !== u && "HEAD" !== u) {
        var b = this.getImgSrcFromDom(e)
        if (b && !imgs.includes(b))
          imgs.push(b)
        var c = e.children ? e.children.length : 0;
        if (c > 0)
          for (var a = e.children, l = c - 1; l >= 0; l--)
            t.f(a[l]);
      }
    }
  }

  getImageDomSrc.f(document.body)
  // 获取到的首屏所有图片
  console.log(imgs) 
  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
    }
  }
  ))
  // 所有图片的responseEnd时间跟计算的fmp相比较得出最大值
  console.log(max)
}
)()

这就是性能统计的关键代码,现在许多公司都是使用的这种计算方法,希望通过这篇文章帮助大家了解首屏的计算。各个公司也应该根据自己的业务场景做一些计算上的修改。比如你项目中使用的图片较少,就可以不把图片计算在内,如果你项目对字体比较敏感,那你就应该把字体的加载计算在内...。总之,计算首屏是没有统一标准的,因为所有公司的页面性质是不同的,侧重点也不一样,要根据公司业务的实际情况来计算。