如何统计首屏渲染时间

5,536 阅读7分钟

仓库完整代码:first-screen-paint,如果来过,期待留下你的一颗小星星~

认识几个概念

1. First Paint(FP)

First Paint的定义是渲染树首次转变为屏幕像素的过程,我们用FP time来表达首次渲染时间。在FP之前我们看见的屏幕是空白的,那么FP time也可理解为白屏时间。如何计算呢?

if (window.performance) {
    let pf = window.performance;
    let pfEntries = pf.getEntriesByType('paint')
    let fp = pfEntries.find(each => each.name === 'first-paint')
    console.log('first paint time: ', fp && fp.startTime)
}

2. First Contentful Paint(FCP):

FCP定义的是从页面加载到屏幕上首次有渲染内容的过程,这里的内容可以是文本、图像、svg元素和非白色canvas元素。在下图加载时间线中,图二是FCP的时间点: image.png 我们用FCP time来表达内容首次渲染时间。如何计算呢?

if (window.performance) {
    let pf = window.performance;
    let pfEntries = pf.getEntriesByType('paint')
    let fp = pfEntries.find(each => each.name === 'first-contentful-paint')
    console.log('first paint time: ', fp && fp.startTime)
}

需要区别于FP,总有FP time ≤ FCP time

3. First Meaningful Paint(FMP)

FMP定义的是从页面开始加载到渲染出主要内容的过程,这个“主要内容”的定义依赖于各浏览器中的实现细节,因此它并没有作为一个标准化的指标。在Chrome的Lighthouse面板中我们可以看到这个指标: image.png

4. Largest Contentful Paint(LCP)

FMP的范围不好界定,但LCP的范围是恒定的,它定义的是页面开始加载到渲染出(视口内)最大内容(文本或图像等)的过程。如下图加载时间线: image.png

image.png 第一个示例中,Instagram logo是视口中的最大内容,第二个示例中,绿色的文本是视口中的最大内容块。我们用LCP time表达最大内容渲染时间,如何计算呢?

new PerformanceObserver(list => {
    let entries = list.getEntriesByType('largest-contentful-paint');
    entries.forEach(item => {
        console.log('largest contentful pain time: ', item.startTime)
    })
}).observe({ entryTypes: ['largest-contentful-paint'] });

什么是首屏渲染?

我们这里定义的首屏是指页面无滚动的情况下,从开始加载到视窗第一屏内容渲染完成的过程,遵循上面几个概念的定义,我们可以称它为 last contentful paint,亦或first screen paint更贴切一些。在本文,我们就把首屏渲染时间叫做first screen paint time(FSP time),要如何来统计呢?

统计首屏渲染时间

先考虑最简单的场景:我们的页面是纯静态文本型的,即首屏里面没有图片,内容是静态文本。

我们要先解决一个问题:如何界定哪些元素是属于屏内的?

1. getBoundingClientRect

getBoundingClientRect用于获取某个元素相对于视窗的位置,理论上我们只要计算每一个元素的位置信息,结合视窗的高度信息,我们就能判断元素是否属于屏内。

但在真实情况下,一个页面dom的数量是很庞大的,大量的dom操作本身就会影响整个页面的性能!何况,getBoundingClientRect会引起页面重排(what forces reflow/layout),这并不是一个理想的方案;

2. IntersectionObserver + MutationObserver

IntersectionObserver通过启动一个观察器,以一种异步的方式检查目标元素是否出现于视窗(viewport)中,它返回的数据里面包含了两个重要的信息:

  • time:元素可见性发生变化的时间,一个高精度时间戳,单位毫秒;
  • intersectionRatio:目标元素的可见比例,介于0.0-1.0,为0时表示元素不可见,为1时表示元素完全可见。

接下来我们需要给每一个元素添加一个intersection观察器,MutationObserver可以帮助我们,它提供了监视dom树变更的能力,我们使用它监视document根节点的子树的变化,为新增的每一个子节点注册一个IntersectionObserver,参考如下代码:

// 注册可视性监听器
const isObserver = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        // 屏内元素
        if (entry.intersectionRatio > 0) {
            // 记录节点及其时间,这里也可以使用人工打点的方式:performance.now()
            console.log(`${entry.target}: ${entry.time}`);
        }
    });
});

// 注册DOM树变更监听器
const muObserver = new MutationObserver((mutations) => {
    if (!mutations) return;
    mutations.forEach((mu) => {
        if (!mu.addedNodes || !mu.addedNodes.length) return;
        mu.addedNodes.forEach((ele) => {
            // 只对元素节点进行监听
            if (ele.nodeType === 1) {
                // 添加可视性变化监听器
                isObserver.observe(ele);
            }
        });
    });
});

// 监听document的子树变化
muObserver.observe(document, {
    childList: true,
    subtree: true
});

更完整的代码参考:first-screen-paint

场景2:首屏包含图片资源,可能是图片元素或背景,需要计算加载最慢那张图片资源的耗时

问题1:图片资源是异步加载的,如何获取资源的请求耗时?

前文我们介绍了获取LCP time的方法,用类似的方式,我们也能获取图片资源的耗时,使用PerformanceObserver api监听资源的加载耗时,它返回的数据里面包含了几个重要的信息:

  • name:资源URL;
  • initiatorType:资源类型,取值可能是css|img|xmlhttprequest等;
  • startTime:请求开始时间,高精度时间戳值,单位毫秒;
  • responseEnd:请求响应返回的时间,高精度时间戳值,单位毫秒;
  • duration:responseEndstartTime的差值;
const pfObserver = new PerformanceObserver((list) => {
    const entries = list.getEntriesByType('resource');
    entries.forEach((item) => {
        // 各种资源的耗时
        // 首屏图片资源白名单:imgUrlWhiteList = []
        console.log(`${item.name: ${item.duration}}`);
    });
});
// 设定性能监听类别:资源
pfObserver.observe({ entryTypes: ['resource'] });

问题2:上面代码中我们监听了所有资源的请求,如何取出首屏的图片资源请求?

  1. 对于img标签的图片资源,我们可以在MutationObserver或者IntersectionObserver监听器中直接操作dom读取imgsrc或者data-src属性,把图片URL保存起来;
  2. 针对背景图片,我们使用getComputedStyle方法获取节点的样式表,并取出其background-image的值;

场景3:首屏内容是动态fetch的,甚至fetch的是图片资源,就如商城首页?

数据是动态fetch的,如果是纯文本数据,无图片资源。我们的DOM树变更监听器可以监听到数据返回之后的渲染情况,渲染过程会收集这些节点的可见性变化时间(这个时间肯定是在fetch数据返回时间点之后的);如果渲染的是图片资源,那么就进入了上一个处理图片资源的场景。

两个问题

1. 首屏内容还在加载中,用户触发了页面滚动?

页面滚动之后,第二屏的内容就会出现在视窗,原本属于首屏的内容(部分内容可能并未完成渲染)却没在视窗中。那么,按照如上的统计方式,就会统计到当前处于视窗内容的渲染时间,这可能就是一个“误差”。

我们需要一个共识在首屏内容完全渲染之前页面触发了滚动,说明页面已经是一个可交互的状态,这种情况下,我们认为,用户触发滚动时那一帧的内容,已经是用户和开发者双方都能接受的首屏内容。基于这个前提,我们的处理方式是:

在页面滚动时,加一个锁,停止监听后续内容的变更,以初次滚动的时间点为时间界线,统计在此时间点前发出的(依据startTime)所有资源的请求耗时和dom树节点的渲染时间;

2. 在场景3下,首屏内容未加载完,用户触发了页面滚动?

  • 这种情况下只能保底统计到fetch请求的响应结束时间;
  • 如果用户在响应之前触发了滚动,这时候数据渲染尚未开始,我们的程序无法捕捉到dom节点,那么也拿不到响应的图片资源,也就无法统计后续的渲染时间;
  • 如果用户是在数据返回之后,图片资源渲染之前触发的滚动,这种情况下由于能够捕捉dom树节点的渲染,理论上我们也能够获取响应图片资源的加载耗时;

测试一下

在本测试demo中,页面的主体内容是img元素,按照LCP(lagest contentful paint)的定义,LCP time会返回这张图片渲染的时间;而我们的首屏内容亦是这张图片,那么我们的FSP time应该基本等于LCP time,在下面截图中,也基本验证了这一点! image.png

最后,对于上面提到的几个问题,各位读者有任何看法也可在评论区留言~

仓库地址:first-screen-paint,欢迎提issue~

参考: