关于首屏
首屏时间是指从转向该页面到屏幕中该页面所有内容都可见时的时间。已经有太多的关于首屏时间的计算,在本文中并不重复阐述这些已经被提出或者实现的方案,而旨在探索与讨论更多的首屏自动化采集方案,扩大思考范围,你我思想之间互相碰撞往往可以激起更多的稀奇古怪的解决方案,这也正是我写这篇文章的目的。
通过浏览器调试工具,我们可以清晰的看出页面资源加载时序图:
先是html页面加载,token进行词法、语法解析后开始加载静态资源并执行相关脚本,开始构建DOM树、render树和CSSOM数,最后加载图片,用户看到完整的网页。
虽然浏览器有着各自的优化的解决方案,但是大多数情况下图片往往是最后加载完毕,这不仅仅是由于图片的大小相对较大,而且图片的加载与否与DOM结构有着很大的关系。DOM是否构建完毕,render树中是否渲染以及其他的图片加载策略有关系可能都会影响图片加载时序。因此在首屏时间的计算中,我们是以最终首屏图片的加载时间为节点计算的。
首屏计算
原则1 首屏计算模块不应该耦合业务线
一般而言,首屏计算作为一个抽离出的js脚本单独引用,这个模块尽量不暴露API给开发者使用,所有的采集端任务都由该模块完成。这句话可能听起来像一句废话,但还是有很多情况可能需要业务人员来进行首屏渲染时间的判断的,下面将针对这个情形举一个实际的场景:
随着MVVM模式的兴起,前端异步渲染逐渐流行起来,前端编码逐渐由面向jQuery编程转向为面向Vue编程。可是使用Vue编写的业务代码在本地打包后仅仅是一个bundle,此时的HTML文件中只是一个 的占位符而已,那么首屏时间计算模块该如何准确的计算首屏时间呢?因此首屏时间计算模块必须知道首屏的DOM结构渲染完毕的时间节点,在这个节点时刻进行计算首屏范围内的图片加载时间。 可是如何获取首屏DOM结构渲染完毕的时间节点呢?这就需要业务开发人员制定。在更新vue实例的data属性后,通知首屏计算模块此时DOM接口已渲染完毕,开始计算首屏时间。
MVVM开发模式下,首屏时间的计算已经耦合了业务代码,虽然可以在保证首屏时间的准确性,但却给开发者带来了一些可观判断逻辑,而这些判断往往会困扰新入职的同志们,因此我们的目标之一就是解决需要手动打点进行首屏时间计算的现状。
原则2 性能与准确性的权衡
业界有个通过canvas截屏并通过轮询对比不同时间点截屏图片之间某几个随机像素点,从而判断首屏是否加载完毕。这种方式虽然科学,但是估计没有几个公司会采用这种方案。通过canvas截屏这个操作对硬件的要求可能就比较高,而且需要进行额外的像素运算,因此性能肯定很差。其实这种场景在工程领域经常出现,工程不同于科学那般严谨,我们只需要找到给定条件的最优解即可,做工程也就是在做trade off。因此这种对比方案我们也必须摒弃。
实现
再次强调,由开发者打点首屏DOM渲染完毕进行首屏时间计算的方式是相对准确的方式,因此我们后续讨论的自动化计算首屏时间的准确性都是基于此标准进行对比说明,因为自动化计算肯定是没有人工干预准确的,这一点毫无疑问。
轮训采集大法
仍然是轮训,不同的是在每次轮询中执行一些操作:
- 获取首屏的所有图片(包括IMG标签与css相关属性)
- 绑定首屏图片的onload和onerror事件,每次轮询不会重复绑定已绑定的图片
- 相同图片不需重复绑定事件侦听,否则会与 2 中的每次轮询混淆
- 图片的事件处理函数执行打点信息并统计图片加载状态,同时比对时间戳得到最迟加载的时间
具体的实现中,需要特别注意首屏出现的相同图片的情况。笔者起初在获取首屏图片中简单计算图片的url数组,存储重复图片的个数,并且与该图片的加载状态绑定在一起。如首屏中出现了3张相同的图片,那么在该图片onload或onerror中对已加载图片的数量做 加3 处理,否则导致最终的 已加载图片总数 与 首屏图片总数 不相等的情况发生。这种实现导致逻辑非常的差,且实现复杂。后通过存储图片所在的DOM对象数组实现更为简单的图片状态判断,更加已读。
伪代码如下:
// totalCounter为轮询的总时间
// DemandCounter是规定的轮询总时间,为3000ms
// imgsLoadedCount则为首屏已加载的图片数量
// lastImageLoadedStamp为最后加载的图片时间戳
function checkFirstScreenDomReady(){
if(totalCounter >= DemandCounter){
// ...
var stamps = Object.keys(pools),
len = stamps.length,
i = 0,
it;
finalImgCount = pools[stamps[len - 1]].imgLen;
pollEnd = true;
for(;i<len;i++){
it = pools[stamps[i]];
if(it.imgLen == finalImgCount && it.imgsLoadedCount >= finalImgCount){
self.onRecord = true;
_perfQueue._firstScreenLoadEnd = lastImageLoadedStamp;
firstScreen.firstScreenLoadEnd = lastImageLoadedStamp;
firstScreen.duaring = lastImageLoadedStamp - performance.timing.navigationStart;
reportData(firstScreen);
return;
}
}
return;
}
var imgEls = getImage();
imgEls.forEach(function(el) {
if(!imgLoadedHash.get(el)){
var img = new Image();
imgLoadedHash.put(el,{
loaded: true,
});
img.onload = OnLoad;
img.onerror = OnError;
img.src = el.__src;
}
});
pools[totalCounter+''] = {
imgLen: imgEls.length,
stamp: Date.now(),
imgsLoadedCount: imgsLoadedCount
};
totalCounter += timeout;
}
watch dog采集
利用Mutation Observer API进行侦听 内容框的DOM事件,判断首屏DOM结构是否完备;如果构建完毕则侦听首屏范围内的图片加载事件,计算首屏时间。
watch dog需要知晓合适首屏DOM构建完毕。这需要首屏计算模块主动插入一个打点标签 ,将业务代码放置在标签内部(这个步骤最好放在发布阶段,由脚手架操作)。通过mutation 侦听 .j_collector_container 容器的DOM子孙节点变化。如在observe事件处理函数中,计算 .j_collector_container 高度,如果大于屏幕高度则意味着首屏的DOM结构已渲染完毕,开始计算首屏时间。
在计算 .j_collector_container 高度时,最好采用限流策略,防止短时间内计算多次容器的布局信息,这也是无可奈何之举。
此处的伪代码如下:
// 记录首屏DOM元素的位置信息
var firstScreenDomReady = false;
var callback = function(records){
if(firstScreenDomReady)
return;
// 此处需做throttle 处理
for(var i=0,len=records.length;i<len;i++){
// 判断首屏DOM渲染完毕的策略:
// 判断collectWrapper元素高度是否大于首屏
var cr = collectWrapper.getBoundingClientRect(),
screenHeight = win.innerHeight;
if(cr.top + cr.height >= screenHeight){
firstScreenDomReady = true;
recordFirstScreenLoad();
return;
}
}
};
var mo = new MutationObserver(callback);
var option = {
'childList': true,
'subtree': true
};
var collectWrapper = document.querySelector('.j_collector_wrapper');
if(collectWrapper.getBoundingClientRect().height < win.innerHeight){
mo.observe(collectWrapper, option);
}else{
setTimeout(function(){
recordFirstScreenLoad();
});
}
总结
不管采用哪种方式,计算出来的首屏时间都不是准确的。而且在每种实现中都需要通过JS引擎与渲染引擎的bridge进行通信执行耗时的操作,如getBoundingClientRect和访问offsetTop属性导致relayout。不过这也是没有办法的办法,在浏览器不提供相关首屏API的前提下我们只有这么做。
另外,对比这三种实现(开发者手动打点、轮训、watch dog采集),针对一个复杂的电商首屏做了性能测试,该页面首屏部分有7个非常复杂的子组件,得到如下结果:
结果也符合我们的预期。