一、背景
前段时间备战双十一前期
,线上项目的性能问题
引起了我们的重视
公司内部是有统一的性能监控平台
的,我们的项目也都统一接入了监控平台,但是这个时间的计算方式我们是不清楚的,于是花时间深入调研了一番
调研后的结果是,其他时间的计算方式(比如网路请求时间,首包时间...)是比较清晰的,指路,除了首屏时间
,业内没有一个统一的标准
调研后首屏时间的计算方式
还是很硬核的,最近得空记录分享出来~
本篇文章讲一种前端首屏时间的计算方案,偏算法实现,重点是思想,看懂就等于赚到!
二、什么是首屏时间
首屏时间
:也称用户完全可交互时间,即整个页面首屏完全渲染出来,用户完全可以交互,一般首屏时间小于页面完全加载时间,该指标值可以衡量页面访问速度
1、首屏时间 VS 白屏时间
这两个完全不同的概念,白屏时间是小于首屏时间的
白屏时间
:首次渲染时间,指页面出现第一个文字或图像所花费的时间
2、为什么 performance 直接拿不到首屏时间
随着 Vue 和 React 等前端框架盛行,Performance
已无法准确的监控到页面的首屏时间
因为 DOMContentLoaded
的值只能表示空白页(当前页面 body 标签里面没有内容)加载花费的时间
浏览器需要先加载 JS , 然后再通过 JS 来渲染页面内容,这个时候单页面类型首屏才算渲染完成
三、常见计算方式
- 用户自定义打点—最准确的方式(只有用户自己最清楚,什么样的时间才算是首屏加载完成)
- 缺点:侵入业务,成本高
- 粗略的计算首屏时间:
loadEventEnd - fetchStart/startTime
或者domInteractive - fetchStart/startTime
- 通过计算首屏区域内的所有图片加载时间,然后取其最大值
- 利用 MutationObserver 接口,监听 document 对象的节点变化
四、我们的计算方案
利用 MutationObserver 接口,监听 DOM
对象的节点变化
提示:算法比较复杂,文章尽量用通俗易懂的方式表达,分析过程尽量简化,实际情况比这个复杂 首先,假设
页面DOM
最终结构如下,页面dom深度为3
<body>
<div>
<div>
<div>1</div>
<div>2</div>
</div>
<div>3</div>
<div style="display: none;">4</div>
</div>
<ul>
<li>1</li>
<li>2</li>
</ul>
</body>
复制代码
1、初始化 MutationObserver 监听
初始化代码如下
- 如果当前浏览器不支持
MutationObserver
放弃上报 - this.startTime取的
window.performance.getEntriesByType('navigation')[0].startTime
,即开始记录性能时间 this.observerData
数组用来记每次录DOM变化的时间以及变化的得分(变化的剧烈程度)
function mountObserver () {
if (!window.MutationObserver) {
// 不支持 MutationObserver 的话
console.warn('MutationObserver 不支持,首屏时间无法被采集');
return;
}
// 每次 dom 结构改变时,都会调用里面定义的函数
const observer = new window.MutationObserver(() => {
const time = getTimestamp() - this.startTime; // 当前时间 - 性能开始计算时间
const body = document.querySelector('body');
let score = 0;
if (body) {
score = traverseEl(body, 1, false);
this.observerData.push({ score, time });
} else {
this.observerData.push({ score: 0, time });
}
});
// 设置观察目标,接受两个参数: target:观察目标,options:通过对象成员来设置观察选项
// 设为 childList: true, subtree: true 表示用来监听 DOM 节点插入、删除和修改时
observer.observe(document, { childList: true, subtree: true });
this.observer = observer;
if (document.readyState === 'complete') {
// MutationObserver监听的最大时间,10秒,超过 10 秒将强制结束
this.unmountObserver(10000);
} else {
win.addEventListener(
'load',
() => {
this.unmountObserver(10000);
},
false
);
}
}
复制代码
Mutation
第一次监听到DOM变化时,DOM结构
如下,可以看到div标签
渲染出来了
<body>
<div>
<div>
<div>1</div>
<div>2</div>
</div>
<div>3</div>
<div style="display: none;">4</div>
</div>
</body>
复制代码
遍历 body
下的元素,通过方法 traverseEl
计算每次监听到 DOM
变化时得分,算法如下
2、计算 DOM 变化时得分
计算函数 traverseEl
如下
- 从
body
元素开始递归计算,第一次调用为traverseEl(body, 1, false)
- 排除无用的element节点,如
script
、style
、meta
、head
layer
表示当前DOM层数
,每层的得分等于1 + (层数 * 0.5)
+该层children的所有得分
- 如果元素高度超出屏幕可视高度直接返回 0 分,即第一次调用时,如果元素高度已经超过屏幕可视高度了,直接返回 0
/**
* 深度遍历 DOM 树
* 算法分析
* 首次调用为 traverseEl(body, 1, false);
* @param element 节点
* @param layer 层节点编号,从上往下,依次表示层数
* @param identify 表示每个层次得分是否为 0
* @returns {number} 当前DOM变化得分
*/
function traverseEl (element, layer, identify) {
// 窗口可视高度
const height = win.innerHeight || 0;
let score = 0;
const tagName = element.tagName;
if (
tagName !== 'SCRIPT' &&
tagName !== 'STYLE' &&
tagName !== 'META' &&
tagName !== 'HEAD'
) {
const len = element.children ? element.children.length : 0;
if (len > 0) {
for (let children = element.children, i = len - 1; i >= 0; i--) {
score += traverseEl(children[i], layer + 1, score > 0);
}
}
// 如果元素高度超出屏幕可视高度直接返回 0 分
if (score <= 0 && !identify) {
if (
element.getBoundingClientRect &&
element.getBoundingClientRect().top >= height
) {
return 0;
}
}
score += 1 + 0.5 * layer;
}
return score;
}
复制代码
第一次DOM变化计算分数score = traverseEl(body, 1, false)
如下,可以看到此次变化得分是8.5
得分保存到this.observerData
中this.observerData.push({ score, time })
body =》 traverseEl(body, 1, false); score = 8.5;
div =》 traverseEl(div, 2, false); score = 8.5;
div =》 traverseEl(div, 3, false); score = 6;
div =》 traverseEl(div, 4, false); score = 3;
div =》 traverseEl(div, 4, false); score = 3;
div =》 traverseEl(div, 3, false); score = 2.5;
div =》 traverseEl(div, 3, false); score = 0;
复制代码
Mutation
第二次监听到 DOM 变化时,可以看到ul标签
也渲染出来了
<body>
<div>
<div>1</div>
<div>2</div>
<div style="display: none;">3</div>
</div>
<ul>
<li>1</li>
<li>2</li>
</ul>
</body>
复制代码
同样计算分数score = traverseEl(body, 1, false)
,可以看到此次变化得分是10
把得分保存到数组this.observerData
中
body =》 traverseEl(body, 1, false); score = 10;
div =》 traverseEl(div, 2, false); score = 5;
div =》 traverseEl(div, 3, false); score = 2.5;
div =》 traverseEl(div, 3, false); score = 2.5;
div =》 traverseEl(div, 3, false); score = 0;
ul =》 traverseEl(div, 2, false); score = 5;
li =》 traverseEl(div, 3, false); score = 2.5;
li =》 traverseEl(div, 3, false); score = 2.5;
复制代码
到此就拿到了一个 DOM
变化的数组 this.observerData
实际上会多次调用 Mutation 监听,会有重复分数的项
3、去掉 DOM 被删除情况的监听
首先删除掉后一个小于前一个的元素,即去掉 DOM 被删除情况的监听,因为页面渲染过程中如有大量 DOM 节点被删除,由于得分小,则会忽略掉
比如 [3,4,2,3,1,5,3]
,结果为 [3,4,5]
/**
* @param observerData
* @returns {*}
*/
function removeSmallScore (observerData) {
for (let i = 1; i < observerData.length; i++) {
if (observerData[i].score < observerData[i - 1].score) {
observerData.splice(i, 1);
return removeSmallScore(observerData);
}
}
return observerData;
}
复制代码
4、取 DOM变化最大
时间点为首屏时间
依次遍历 observerData
,如果 下一个得分score
与 前一个得分score
差值大于 data.rate
则表示后面有新的 dom 元素渲染到页面中,则取下一个 time
这样处理,可以排除有动画的元素渲染,或者轮播图等,更精准的计算首屏渲染时间
所以不能直接取最后一个元素时间,即 observerData[observerData.length-1].score
function getfirstScreenTime() {
this.observerData = removeSmallScore(this.observerData);
let data = null;
const { observerData } = this;
for (let i = 1; i < observerData.length; i++) {
if (observerData[i].time >= observerData[i - 1].time) {
const scoreDiffer =
observerData[i].score - observerData[i - 1].score;
if (!data || data.rate <= scoreDiffer) {
data = { time: observerData[i].time, rate: scoreDiffer };
}
}
}
if (data && data.time > 0 && data.time < 3600000) {
// 首屏时间
this.firstScreenTime = data.time;
}
}
复制代码
5、异常情况下的处理
页面关闭时如果没有上报,立即上报
window
监听 beforeunload事件(当浏览器窗口关闭或者刷新时,会触发beforeunload事件)this.calcFirstScreenTime
,计算首屏时间状态,分为init
、pending
、和finished
三个状态- 当页面关闭时,如果
this.calcFirstScreenTime = pending
,则触发unmountObserver
立即上报,并且卸载事件
window.addEventListener('beforeunload', this.unmountObserverListener);
const unmountObserverListener = () => {
if (this.calcFirstScreenTime === 'pending') {
this.unmountObserver(0, true);
}
if(!isIE()){
window.removeEventListener('beforeunload', this.unmountObserverListener);
}
};
复制代码
6、销毁 MutationObserver
我们看看 卸载MutationObserver
的时候又做了啥,该方法为 unmountObserver
该方法中会判断是否卸载 if (immediately || this.compare(delayTime))
,如返回 true 则立即卸载,并给出最终计算的时间;如果返回 false ,500 毫秒后轮询 unmountObserver
this.observer.disconnect()
停止观察变动,MutationObserver.disconnect()
/**
* @param delayTime 延迟的时间
* @param immediately 指是否立即卸载
* @returns {number}
*/
function unmountObserver (delayTime, immediately) {
if (this.observer) {
if (immediately || this.compare(delayTime)) {
// MutationObserver停止观察变动
this.observer.disconnect();
this.observer = null;
this.getfirstScreenTime()
this.calcFirstScreenTime = 'finished';
} else {
setTimeout(() => {
this.unmountObserver(delayTime);
}, 500);
}
}
}
// * 如果超过延迟时间 delayTime(默认 10 秒),则返回 true
// * _time - time > 2 * OBSERVE_TIME; 表示当前时间与最后计算得分的时间相比超过了 1000 毫秒,则说明页面 DOM 不再变化,返回 true
function compare (delayTime) {
// 当前所开销的时间
const _time = Date.now() - this.startTime;
// 取最后一个元素时间 time
const { observerData } = this;
const time =
(
observerData &&
observerData.length &&
observerData[observerData.length - 1].time) ||
0;
return _time > delayTime || _time - time > 2 * 500;
}
复制代码
写在最后
以上就是本文首屏时间的计算方案,欢迎探讨~
本文首发于 GitHub