背景图
最近和团队中的小伙伴一起开发一个叫 Aegis
的前端监控系统(目前仅在腾讯内部开源)。既然是前端监控系统,除了要对各种异常进行监控,还需要收集前端的各项性能,其中就包括 "首屏渲染时间" 这个重要指标。
由于 React
、Vue
等框架的出现,DOMContentLoaded
事件已经失去了原本的作用,现在 "首屏渲染时间" 的计算大多数时候是依靠人工打点,这与 Aegis
“业务零侵入” 的设计理念不相符,为此,需要开发一套新的算法,尽可能准确的对 “首屏渲染时间” 进行估算。
本文实现的算法主要依靠了浏览器提供的两个API:MutationObserver
、performance
。
可能大家对这个API还比较陌生,不过目前浏览器对该 API 的支持情况已经非常好了。
mutationObserver
MutationObserver
给我们提供了监听页面DOM树变化的能力,其用法非常简单:
// 注册监听函数
const observer = new MutationObserver((mutations) => {
console.log('时间:', performance.now(), ',DOM树发生变化啦!增加了这些节点:');
for(let i = 0; i < mutations.length; i++) {
console.log(mutations[0].addedNodes);
}
})
// 开始监听document的节点变化
observer.observe(document, {
childList: true,
subtree: true
});
performance
目前的兼容性虽然没有 MutationObserver
那么好,不过主流浏览器也基本已经支持。
performance
我们可以通过浏览器提供的 performance
接口查询到当前页面的资源加载情况(本文中只关心图片加载的情况):
console.log('页面加载了这些图片:');
performance.getEntriesByType('resource').forEach((resource) => {
if(resource.initiatorType === 'img') {
console.log(resource.name);
}
})
开始研究 首屏渲染时间 的计算方法前,我们先探索一般情况下一个页面的dom树变化规律,下面以我之前负责的一个活动页面为例:
<img src="https://qpic.url.cn/feeds_pic/ajNVdqHZLLAL0D5d2u4UlBXjic526YjUVYMwCWJ3LQzRyCLuDf0HwcQ/" alt="" style="zoom:30%;" />
页面中的黄色方框为首屏渲染内容(Iphone6),所以这个页面的首屏渲染时间指的是黄色区域里面内容渲染所需要的时间。蓝色方框的内容需要根据后端接口返回的数据进行渲染,这意味着这一块完成渲染的时间需要包括接口请求花费时间,所以该页面首屏渲染中最慢出现的往往是蓝色方框中的元素,这意味着蓝色方框中元素渲染完成时也代表着页面渲染完成。
接下来我们通过 MutationObserver
观察这个页面dom树的变化规律,我们只关心 nodeType === 1
的元素,且剔除 script
、 style
等不会在页面中展示出来的元素节点:
var details = [], // 存放5s内dom树变化数据
ignoreEleList = ['script', 'style', 'link', 'br'];
new Promise(function (resolve, reject) {
// 注册监听函数
var observeDom = new MutationObserver(function (mutations) {
if (!mutations || !mutations.forEach) return;
var detail = {
time: performance.now(),
roots: []
};
mutations.forEach(function (mutation) {
if (!mutation || !mutation.addedNodes || !mutation.addedNodes.forEach) return;
mutation.addedNodes.forEach(function (ele) {
// 元素节点 && 不在剔除范围内 && 当前元素或者其祖先元素还没被收集
if (ele.nodeType === 1
&& ignoreEleList.indexOf(ele.nodeName.toLocaleLowerCase()) === -1
&& !isEleInArray(ele, detail.roots)
) {
// 收集
detail.roots.push(ele);
}
});
});
if (detail.roots.length) {
details.push(detail);
}
});
// 开始监听DOM树变化
observeDom.observe(document, {
childList: true,
subtree: true
});
// 只监听5s之内的变化
setTimeout(function () {
observeDom.disconnect();
resolve(details);
}, 5000);
}).then(function (details) {
// 将收集到的数据打印出来
console.log(details);
});
// 查看当前元素及其祖先元素是否在数组中
function isEleInArray(target, arr) {
if (!target || target === document.documentElement) {
return false;
}
else if (arr.indexOf(target) !== -1) {
return true;
}
else {
return isEleInArray(target.parentElement, arr);
}
}
上面代码中,通过 isEleInArray
函数对变化的dom节点进行了聚合,即每次DOM树发生变化,我们只关心当前变化的节点们最 “外层” 的节点。
万一5s之后首屏才渲染完成呢?本人觉得首屏渲染时间如果超过5s,可以认为首屏渲染失败了,不在本文的讨论范围之内。
下面是控制台打印出来的 details
的值,其中第五个 detail
的 roots
的十个值就是上图中蓝色方框中的十个礼物的dom节点。
deails
接下来我们再看看chrome控制台对页面渲染的各个时间点截图,可以看出600ms时十个礼物的dom已经渲染完成,这与上图中的570ms非常接近,所以该页面首屏的dom渲染时间就是 details
第五个值的时间。
chrome
因此,只要我们能判断出哪一个 detail
是首屏渲染中最后一个完成的,即可计算出首屏渲染中dom结构渲染完成耗时。
接下来我们需要对上面收集到的 details
进行分析。
.then(function (details) {
var result;
details.forEach(function (detail) {
for (var i = 0; i < detail.roots.length; i++) {
if (isInFirstScreen(detail.roots[i])) {
result = detail.time;
// 这里之所以break,是因为每一个detail里的所有节点都是在同一时间内变化的,所以无需再往下遍历
break;
}
}
});
console.log(result);
});
// 判断对应target是否在首屏中
function isInFirstScreen(target) {
if (!target || !target.getBoundingClientRect) return false;
var rect = target.getBoundingClientRect(),
screenHeight = window.innerHeight,
screenWidth = window.innerWidth;
return rect.left >= 0
&& rect.left < screenWidth
&& rect.top >= 0
&& rect.top < screenHeight;
}
因为在上一步中收集 detail
时,我们对dom节点做了聚合,只收集了最外层的dom节点,所以收集到的dom节点数量往往不多,我们可以直接遍历所有的节点,查看其是否在首屏中。最终返回的 result
,就是我们想要的首屏dom结构渲染完成耗时啦。
首屏dom结构渲染时间并不等于首屏渲染时间,当首屏中有图片时,往往图片加载完成之后,用户才能看到完整的页面。比如上图中 “600ms” 时,虽然页面的dom结构已经渲染完成,但页面显示并不完整。
接下来我们开始计算首屏中图片加载完成的时间,得益于浏览器提供的 performance API
,这个计算过程非常简单高效。
.then(function (details) {
var result;
details.forEach(function (detail) {
for (var i = 0; i < detail.roots.length; i++) {
if (isInFirstScreen(detail.roots[i])) {
result = detail.time;
// 这里之所以break,是因为每一个detail里的所有节点都是在同一时间内变化的,所以无需再往下遍历
break;
}
}
});
// 遍历当前请求的图片中,如果有开始请求时间在首屏dom渲染期间的,则表明该图片是首屏渲染中的一部分,
// 所以dom渲染时间和图片返回时间中大的为首屏渲染时间
window.performance.getEntriesByType('resource').forEach(function (resource) {
if (resource.initiatorType === 'img'
&& resource.fetchStart < result
&& resource.responseEnd > result
) {
result = resource.responseEnd;
}
});
// 最终结果
console.log(result);
});
// 判断对应target是否在首屏中
function isInFirstScreen(target) {
if (!target || !target.getBoundingClientRect) return false;
var rect = target.getBoundingClientRect(),
screenHeight = window.innerHeight,
screenWidth = window.innerWidth;
return rect.left >= 0
&& rect.left < screenWidth
&& rect.top >= 0
&& rect.top < screenHeight;
}
可以看到,我们增加了一步对浏览器请求资源的遍历,如果某张图片的 fetchStart
是在dom结构渲染期间,则认为该图片为首屏中的图片,如果其加载完成时间比dom结构渲染完成时间晚,则认为其是首屏渲染的最后一步,然后以此逻辑遍历所有图片,更新首屏渲染完成时间。
到这里该算法就完成了,我们来验证一下其准确度吧,将上面的代码copy到 head
标签中执行。
result
<center>控制台打印</center>
chrome
<center>chrome的截图</center>
我们的计算结果1513ms,与控制台的1.48s非常接近,这意味着我们的算法非常有效!
不过,对于其他页面是否一样有效呢?本人测试是有效的,使用该算法计算了手头上的几个项目,其计算结果都与chrome控制台截图结果非常接近。
不过毕竟样本有限,如果小伙伴们感兴趣的话,也可以拿自己的项目测试一下呀。
完整的代码地址:https://github.com/Cainankun/first-screen-rendering-time/blob/master/index.js