指标采集:首屏时间指标采集具体办法
手动采集办法及优缺点
手动采集,一般是通过埋点的方式进行, 比如在页面开始位置打上 FMP.Start(),在首屏结束位置打上 FMP.End(),利用 FMP.End()-FMP.Start() 获取到首屏时间。
它兼容性强,业务同学知道在这个业务场景下首屏结束点在哪里,可以随情况变动。但手动采集会和业务代码严重耦合,如果首屏采集逻辑调整,业务代码也需要修改;还有,它的覆盖率不足,因为要手动采集,业务一旦忙起来,性能优化方案就会延迟排后。
最后,手动采集的统计结果并不精确,因为依赖于人,每个人对首屏的理解有偏差,经常打错或者忘记打点。
自动化采集优势及办法
自动化采集,即引入一段通用的代码来做首屏时间自动化采集,引入过程中,除了必要的配置不需要做其他事情。
首屏指标自动化采集,需要考虑是服务端模板业务,还是单页面(SPA)应用开发业务,业务场景不同,对应的采集方法也不同。下面我来分别介绍下。
服务端模板业务下的采集办法
服务端模板项目的加载大致流程是这样的:HTTP 请求 → HTML 文档加载解析完成 → 加载样式和脚本文件 → 完成页面渲染。
其中,HTML 文档加载解析完成的时间点,就是首屏时间点,而要采集这个首屏时间,可以用浏览器提供的 DOMContentLoaded 接口来实现。
当页面中的 HTML 元素被加载和解析完成(不需要等待样式表、图片和一些脚本的加载过程),DOMContentLoaded 事件触发。此时我们记录下当前时间 domContentLoadedEventEnd,再减去页面初始进入的时间 fetchStart,就是 DOMContentLoaded 的时间,也就是我们要采集的首屏时间。 即首屏时间=DOMContentLoaded 时间=domContentLoadedEventEnd-fetchStart。
单页面(SPA)应用业务下的采集办法
SPA下,用户请求一个页面时,页面会先加载 index.html,加载完成后,就会触发 DOMContentLoaded 和 load。而这个时候,页面展示的只是个空白页。此时根本不算真正意义的首屏。接下来,页面会加载相关脚本资源并通过 axios 异步请求数据,使用数据渲染页面主题部分,这个时候首屏才渲染完成。
目前主流的统计是参考淘宝首屏统计方案,使用MutationObserver:
MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。
使用 MutationObserver 能监控页面信息的变化,当页面 body 变化最剧烈的时候,我们拿到的时间数据,就是首屏时间。
具体实现是在用户进入页面时,我们可以使用 MutationObserver 监控 DOM 元素 (Document Object Model,文档对象模型)。当 DOM 元素发生变化时,程序会标记变化的元素,记录时间点和分数,存储到数组中。数据的格式类似于 [200ms,18.5] 。而计算分数需要设定元素权重。递归遍历 DOM 元素及其子元素,根据子元素所在层数设定元素权重,比如第一层元素权重是 1,当它被渲染时得 1 分,每增加一层权重增加 0.5,比如第五层元素权重是 3.5,渲染时给出对应分数。因为页面中每个 DOM 元素对于首屏的意义是不同的,越往内层越接近真实的首屏内容,如图片和文字,越往外层越接近 body 等框架层。
function CScor(el, tiers, parentScore) {
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;
}
calFinallScore() {
try {
if (this.sendMark) return;
const time = Date.now() - performance.timing.fetchStart;
var isCheckFmp = time > 30000 || SCORE_ITEMS && SCORE_ITEMS.length > 4 && time - (SCORE_ITEMS && SCORE_ITEMS.length && SCORE_ITEMS[SCORE_ITEMS.length - 1].t || 0) > 2 * CHECK_INTERVAL || (SCORE_ITEMS.length > 10 && window.performance.timing.loadEventEnd !== 0 && SCORE_ITEMS[SCORE_ITEMS.length - 1].score === SCORE_ITEMS[SCORE_ITEMS.length - 9].score);
if (this.observer && isCheckFmp) {
this.observer.disconnect();
window.SCORE_ITEMS_CHART = JSON.parse(JSON.stringify(SCORE_ITEMS));
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
});
}
}
//
this.fmp = record && record.t || 30001;
try {
this.checkImgs(document.body)
let max = Math.max(...this.imgs.map(element => {
if(/^(//)/.test(element)) element = 'https:' + element;
try {
return performance.getEntriesByName(element)[0].responseEnd || 0
} catch (error) {
return 0
}
}))
record && record.t > 0 && record.t < 36e5 ? this.setPerformance({
fmpImg: parseInt(Math.max(record.t , max))
}) : this.setPerformance({});
} catch (error) {
this.setPerformance({});
// console.error(error)
}
} else {
setTimeout(() => {
this.calFinallScore();
}, CHECK_INTERVAL);
}
} catch (error) {
}
}
同时需要考虑图片加载:
<!doctype html>
<body><img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
<img id="imgTest" src="https://www.baidu.com/img/bd_logo1.png?where=super">
<style type=text/css>
background-image:url('https://www.baidu.com/img/dong_8f1d47bcb77d74a1e029d8cbb3b33854.gif);
</style>
</body>
<html>
<script type="text/javascript">
(() => {
const imgs = []
const getImageDomSrc = {
_getImgSrcFromBgImg: function (bgImg) {
var imgSrc;
var matches = bgImg.match(/url(.*?)/g);
if (matches && matches.length) {
var urlStr = matches[matches.length - 1];
var innerUrl = urlStr.replace(/^url(['"]?/, '').replace(/['"]?)$/, '');
if (((/^http/.test(innerUrl) || /^///.test(innerUrl)))) {
imgSrc = innerUrl;
}
}
return imgSrc;
},
getImgSrcFromDom: function (dom, imgFilter) {
if (!(dom.getBoundingClientRect && dom.getBoundingClientRect().top < window.innerHeight))
return false;
imgFilter = [/(.)(png|jpg|jpeg|gif|webp|ico|bmp|tiff|svg)/i]
var src;
if (dom.nodeName.toUpperCase() == 'IMG') {
src = dom.getAttribute('src');
} else {
var computedStyle = window.getComputedStyle(dom);
var bgImg = computedStyle.getPropertyValue('background-image') || computedStyle.getPropertyValue('background');
var tempSrc = this._getImgSrcFromBgImg(bgImg, imgFilter);
if (tempSrc && this._isImg(tempSrc, imgFilter)) {
src = tempSrc;
}
}
return src;
},
_isImg: function (src, imgFilter) {
for (var i = 0, len = imgFilter.length; i < len; i++) {
if (imgFilter[i].test(src)) {
return true;
}
}
return false;
},
traverse(e) {
var _this = this , tName = e.tagName;
if ("SCRIPT" !== tName && "STYLE" !== tName && "META" !== tName && "HEAD" !== tName) {
var el = this.getImgSrcFromDom(e)
if (el && !imgs.includes(el))
imgs.push(el)
var len = e.children ? e.children.length : 0;
if (len > 0)
for (var child = e.children, _len = len - 1; _len >= 0; _len--)
_this.traverse(child[_len]);
}
}
}
getImageDomSrc.traverse(document.body);
window.onload = function () {
var max = Math.max(...imgs.map(element => {
if (/^(//)/.test(element))
element = 'https:' + element;
try {
return performance.getEntriesByName(element)[0].responseEnd || 0
} catch (error) {
return 0
}
}
))
console.log(max);
}
}
)()
</script>
将图片加载时间与使用 MutationObserver 获得的 DOM 首屏时间相比较,哪个更长,哪个就是最终的首屏时间。
白屏指标采集
先来回顾一下前面讲过的浏览器的页面加载过程:
客户端发起请求 -> 下载 HTML 及 JS/CSS 资源 -> 解析 JS 执行 -> JS 请求数据 -> 客户端解析 DOM 并渲染 -> 下载渲染图片-> 完成渲整体染。
在这个过程中,客户端解析 DOM 并渲染之前的时间,都算白屏时间。所以,白屏时间的采集思路如下:白屏时间 = 页面开始展示时间点 - 开始请求时间点。FP = domLoading - navigationStart。
卡顿指标采集
所谓卡顿,简单来说就是页面出现卡住了的不流畅的情况。
难点在于,在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。
在浏览器上,我们没办法拿到单帧渲染耗时的接口,所以这时候,只能拿 FPS 来计算,只要 FPS 保持稳定,且值比较低,就没问题。它的标准是多少呢?连续 3 帧不低于 20 FPS,且保持恒定。
H5 场景下获取 FPS 方案如下:
var fps_compatibility= function () {
return (
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function (callback) {
window.setTimeout(callback, 1000 / 60);
}
);
}();
var fps_config={
lastTime:performance.now(),
lastFameTime : performance.now(),
frame:0
}
var fps_loop = function() {
var _first = performance.now(),_diff = (_first - fps_config.lastFameTime);
fps_config.lastFameTime = _first;
var fps = Math.round(1000/_diff);
fps_config.frame++;
if (_first > 1000 + fps_config.lastTime) {
var fps = Math.round( ( fps_config.frame * 1000 ) / ( _first - fps_config.lastTime ) );
console.log(`time: ${new Date()} fps is:`, fps);
fps_config.frame = 0;
fps_config.lastTime = _first ;
};
fps_compatibility(fps_loop);
}
fps_loop();
function isBlocking(fpsList, below=20, last=3) {
var count = 0
for(var i = 0; i < fpsList.length; i++) {
if (fpsList[i] && fpsList[i] < below) {
count++;
} else {
count = 0
}
if (count >= last) {
return true
}
}
return false
}
利用 requestAnimationFrame 在一秒内执行 60 次(在不卡顿的情况下)这一点,假设页面加载用时 X ms,这期间 requestAnimationFrame 执行了 N 次,则帧率为1000* N/X,也就是FPS。
网络环境采集
除了在APP或者微信小程序,我们没法直接拿到当前网络情况。可以拿到两张不同尺寸图片的加载时间,通过计算结果来判定当前网络环境。
具体来说,我们在每次页面加载时,通过客户端向服务端发送图片请求,比如,请求一张 11 像素的图片和一张 33 像素的图片,然后在图片请求之初打一个时间点,在图片 onLoad 完成后打一个时间点,两个时间点之差,就是图片的加载时间。
接着,我们用文件体积除以加载时间,就能得出两张图片的加载速度,然后把两张图片的加载速度求平均值,这个结果就可以当作网络速度了。
因为每个单页面启动时,都会做一次网速采集,得到一个网络速度,我们可以把这些网络速度做概率分布,就能得出当前网络情况是 2G (750-1400ms)、3G (230-750ms)、4G或者WiFi(0-230ms)。