文章主要记录学习web性能优化方面的内容。

首屏时间采集方法

我们在观测首屏时间时候,一定结合目前实际情况,比如用户网络分布,手机型号分布,来模拟真实用户的体验,而不是使用Chrome DevTools 观察首屏时间,或者在最理想的情况下去测试。

所以就要考虑一些问题:

  • 如何了解用户的首评时间?
  • 大部分用户的首屏分布时间又是怎么样的?
  • 性能差的用户首屏时间是多少?

如果要对首屏时间优化,那么就需要对首屏的指标进行采集。

手动采集

手动采集,即通过埋点方式进行。手动在代码中写FMP.Start(),在首屏结束位置打上 FMP.End(),利用 FMP.End()-FMP.Start() 获取到首屏时间。缺点是会和代码耦合在一起,业务变动,代码也会变动。另外有时候一旦业务忙起来,性能优化方案就会被置后。而且手动采集并不精确,每个人对首屏时间理解有偏差

自动采集

业内主流以自动采集为主。通过引入一段代码来实现自动化采集,但是又分为服务端渲染SSR采集和SPA采集

SSR采集

服务端渲染流程为:HTTP请求-解析加载HTML文档-加载样式和脚本-完成页面渲染
解析加载HTML文档就是首屏时间,采集这个时间,可以用DOMContentLoaded这个时间来计算。
image.png
DOMContentLoaded 12.29s,代表 HTML 元素加载解析完成用了 12.29s,这个就是服务端渲染的首屏加载时间
采集方法是当页面中HTML 元素被加载和解析完成,DOMContentLoaded 事件会被触发。
Performance API记录下来domContentLoadedEventEnd这个时间点,在减去初始进入页面的时间,就是 DOMContentLoaded 的时间,也就是我们要采集的首屏时间。
首屏时间=DOMContentLoaded 时间=domContentLoadedEventEnd-fetchStart。

SPA采集

不同于SSR采集,SPA并不能简单使用performance API,会存在一些问题。
阿里云在2018 年 6 月的 GMTC 大会上分享的一个首屏采集指标,使用 Performance API 接口采集的首屏时间是 1106ms,实际的首屏时间是 1976ms,出现差异的原因是,和SSR加载流程中的区别。

SPA应用刚开始会加载一个index.html文件,这个文件加载完成,就会触发DOMContentLoaded 事件,然后页面会加载脚本资源并且通过AJAX请求数据,等数据拿到JS渲染完成页面,这才是首屏渲染完成。所以Performance 接口获得的是不准确的。SPA无法基于DOMContentLoaded做首屏指标采集

MutationObserver

通过监听DOM变化,来实现采集首屏
https://juejin.cn/post/7035647196510814221#heading-7
https://github.com/zxyue25/blog/issues/1
https://segmentfault.com/a/1190000041419030
计算方法是首先设定元素权重计算分数,递归遍历DOM元素和子元素,计算的规则为:*(1 + 层数 * 0.5)*
比如以下DOM

1
2
3
4
5
6
<body>
<div>
<div>1</div>
<div>2</div>
</div>
</body>

计算分数为1.5 + 2 + 2.5 + 2.5 = 8.5(分)
image.png

遍历元素,将无用标签排除,如果超过可视区域,返回分数0,每一层增加 0.5 的权重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

获取 DOM 变化最大时对应的时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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) {

}
}

如果包含图片,以上方法计算是不准确的,上述逻辑是对DOM元素来做的,图片是异步加载,要保证图片出来才算首屏。
包含图片页面的首屏计算 demo。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<!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>

获取页面所有的图片路径。在这里,图片类型分两种,一种是带 IMG 标签的,一种是带 DIV 标签的。前者可以直接通过 src 值得到图片路径,后者可以使用 window.getComputedStyle(dom) 方式获取它的样式集合。

白屏、卡顿、网络环境采集

白屏指标采集

白屏是指从输入内容回车(跳转,刷新)等,到页面开始出现的第一个字符出现的时间

浏览器

只要有DOM元素出现,就不再算是白屏。所以采集思路为

  • 白屏时间 = 页面开始展示时间点 - 开始请求时间点

用浏览器的 Performance API计算的话

  • 白屏时间 FP = performance.timing.domLoading-performance.timing.navigationStart

    webview

    webview会比在普通浏览器内多一步启动浏览器内核,也就是webview初始化过程。这个时间必须通过手动采集获得。在App中,程序在 App 创建 WebView 时埋一个点,然后在建立网络链接埋点。
    Webview 初始化的时间 = 创建 WebView -建立网络链接开始时间

    卡顿指标采集

    是否卡顿,就是计算FPS,一般来讲,60帧以上会很流畅,30帧-60帧算比较流畅。但是这里同样不能采集平均值,比如前60帧渲染很快(10ms 渲染 1 帧),后面的 3 帧渲染很慢( 20ms 渲染 1 帧),平均为95FPS,虽然高于60,但是实际上效果还是卡顿的。卡顿与否取决的单帧渲染耗时。
    FPS标准是连续 3 帧不低于 20 FPS,且保持恒定。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    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。

    网络环境采集

    如果是webview端内,可以通过App提供的接口实现。

但是在端外,PC,手机端就不可以了。
这个时候一般采用拿两张不同尺寸的图片,通过计算结果判断当前网络状况

  • 页面加载向服务器发起图片请求,请求一张11像素的图片和33像素的图片。图片请求之初标记一个时间,图片onLoad事件之后标记一个时间,这个时间差就是加载时间。
  • 然后用文件体积/时间,得到两张图片的加载速度,再求出两张图片加载速度的平均值,这个就是网络速度

2G(750-1400ms)、3G (230-750ms)、4G或者WiFi(0-230ms)
知道了用户的网络分布,就可以针对性做优化,如果大多数用户停留在4G,就要多关注4G下的网络优化方案

image.png

性能SDK和上报策略设计

如何设计一个SDK,可以让他同时接入到PC,App下的H5,移动端等多个站点?
首先要把之前的首屏,白屏、卡顿采集的脚本封装在一起,并让脚本自动初始化和运行。

SDK接入设计

我们最终使用方式要支持ES Module和UMD,可以通过NPM包引入

1
2
3
npm install @common/Perf -S
import { perfInit } from '@common';
perfInit ();

也可以

1
2
3
4
5
6
<script type="text/javascript">https://s1.static.com/common/perf/static/js/1.0.0/perf.min.js</script>
try {
perfInit ();
} catch (err) {
console.warn(err);
}
  • FMP API包含首屏采集的分数计算部分 API(calculateScore)、变化率计算的 API(calFinallScore)和首屏图片时间计算 API(fmpImg)
  • 白屏和卡顿可以封装成FP API和 BLOCK API
  • Extension API 加载瀑布流数据,DNS,TCP时间等,这些数据通过performance接口获得

最终可以封装成Perf API,在调用FMP,FP,BLOCK,ExtensionAPI来完成
image.png
做一个SDK,还需要维护文档,写代码示例,另外可以在地址栏后面增加调试参数可以看到当前性能分析的页面。定位基础问题,比如SDK初始化失败,采集数据异常等

SDK运行设计

运行设计主要考虑兼容性策略,容错机制,测试方案

兼容性

要考虑不同的各类页面和各个平台,3C页面,中后台页面,PC端,小程序,移动端等,中后台页面。
主要适用原生JS,避免有的使用Vue或者React有区别,另外就是不同终端,比如小程序,设计一个适配层来抹平差异,可以将小程序自己的采集API标准化,小程序叫做minaFMP,可以统一适配成FMP

容错性

SDK自身的错误,可以通过try catch捕捉,然后做上报。这里要避免SDK自身的错误影响到别的业务的正常运行,同时还要做好自测和单元测试,QA 的测试等。

测试方案

每次开发完成并且代码review之后,可以根据实际用户的浏览器和机型分布比例,确定top10%用户,根据这些机型和浏览器进行测试。

升级SDK时候,无论功能大小,都要进行冒烟测试。

上报策略设计

主要是前端采集完成数据之后,是直接上传还是做过滤处理,全量上传数据还是抽样,是H5接口上报还是native上报等等

日志数据过滤

数据采集之后,先对异常数据进行过滤。

  • 计算错误的数据,负值,非数值数据
  • 合法异常值,极大值,极小值,比如15s以上的首屏时间

这些异常数据,会被上报到错误异常平台

数据抽样策略

全量还是抽样,要根据App或者网站日活来决定。日活跃10万以下可以全量上传,日活跃千万,就需要数据抽样,因为会浪费用户大量的流量和带宽,也会导致统计服务器负载增加。
另外可以在服务端下发数据抽样率,动态控制客户端向服务端上报数据的量。

上报机制选择

主要是根据当前网络情况和App忙碌程度来进行上报。

网络环境

强网络环境(4G/WIFI)直接上报,弱网络(2G/3G)下,将日志存储到本地,等到网络比较好时候在上报,可以从native获取网络情况。

App

一般在App处于空闲时候直接上报,否则等到晚上或者凌晨再进行上报

批量上报

默认消息数达到30条等或者设置的值才上报,或者App启动时上报

最后,在上报之前,就是请求指标转换为请求参数环节,SDK内部需要做一次校验。
异常数据上报到数据异常平台,正常范围结合采样率来看是否需要上报
image.png