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

前言

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

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

另一种是 真实用户监控
Real User Monitoring,RUM
。监控真实的用户访问数据,上报数据到服务器,然后经过数据清洗加工,得到最终的性能数据。
在前端性能监控中有一个非常重要的指标就是首屏时间,因为首屏时间直接反应了用户多久能看到页面的主要内容,这决定了用户体验。这样的话,如何取到准确的首屏时间对我们来说就变的非常重要。本文就结合之前的实践,聊一聊首屏时间如何计算。

Performance

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

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

image

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

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

但是随着 Vue
React
等前端框盛行, 导致 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”,期望与大家多多交流