前端性能监控(一)指标收集

前端性能监控主要分为两种方式,一种叫做合成监控(Synthetic Monitoring,SYN),另一种是真实用户监控(Real User Monitoring,RUM)。合成监控一般会借助工具来做检查例如:lighthouse。而真实用户监控的时候,我们就需要采集一些用户数据。
关于用户数据采集,我们通常会考虑几个问题:

  • 使用标准 API 收集数据
  • 定义合适的指标
  • 上报数据,分析数据

本文我们先主要介绍如何收集数据和定义核心性能指标。

数据采集方式

利用 performance API 我们可以轻松的的获取页面的相关性能数据和衡量一些流程是时间。
例如:

  • 高精度时间 performance.now() 该事件不受系统时间影响,是一个单调递增的时间。这样就可以保证,我们统计某一阶段的耗时情况精确且不受宿主环境影响
  • timing API时序图,他可以帮助我们获取页面各阶段的相关性能时间: a.jpg 具体含义就不介绍了,网上有很多
  • Performance Timeline Level2 提供了更多的扩展功能有兴趣的可以看看

定义合适的指标

有了采集方式,我们需要一个合适的指标来衡量我们页面的性能,这里介绍几个核心指标

起始时间

首先从页面加载性能开始,我们既然要统计各阶段的耗时,就需要一个起始时间。我们来看下几个可以作为起始的时间点:

  • navigationStart:可以理解为输完url,按下回车的那一刹那
  • fetchStart:是指在浏览器发起任何请求之前的时间值。
  • requestStart:代表浏览器发起请求的时间节点,请求的方式可以是请求服务器、缓存、本地资源等。

对于 navigationStart 和 fetchStart,在当前文档为空的情况下,navigationStart = fetchStart。这中间,还有一个 redirect 重定向时间的耗时,大部分情况下,这个差值在10毫秒以内,即不存在重定向。
这里我们通常会把 navigationStart 作为起始时间,例如:
首字节时间(是指在 TCP 协议的三次握手之后,浏览器开始发出 HTTP 请求,然后服务器响应时回复的第一个字节)即为

performance.timing.responseStart - performance.timing.navigationStart

FCP (First Contentful Paint)

浏览器从响应用户输入网址地址,到浏览器开始渲染内容的时间,可以直接的反映网站性能的优劣,在衡量网站性能的过程中更靠近用户。我们可以通过 performance.getEntriesByType(‘paint’)[1] 来获得这个时间:

b.jpg

FMP (First Meaning Paint)

首次有效绘制(主要内容绘制完成时间),这个时间是无法从浏览器的 API 中获得的,需要我们自己来衡量。通常这个时间也是我们用来评估用户感知上的白屏时间。我们计算该时间采用的算法是阿里云提出的一个算法:
c.jpg

详情见 GMTC 大前端监控的最佳实践
然而他并没有提供具体的实现代码,按照他的思路我们来自己实现一套。

页面组成部分

首先我们要确定页面的重要组成部分(也就是可是范围内,用户感知上重要的内容),我们规定这部分节点需要满足如下特点:

  • 节点元素体积大,在首屏内面积占比大
  • 节点是重要的元素组成,比如图片,视频等内容

所以我们设定一个得分系统,来确定每个节点的得分,得分越高说明改部分节点越重要

1
2
3
4
5
6
7
8
9
10
// 节点得分 = 节点首屏可视面积 * 权重 weight
// 权重设计如下
const ELE_WEIGHT = {
SVG: 2,
IMG: 2,
CANVAS: 4,
OBJECT: 4,
EMBED: 4,
VIDEO: 4
};

收集页面信息,计算各部分节点得分

有了标准,我们就要开始收集在页面加载过程中,出现的页面各节点的信息:节点渲染的时间(用于统计最后的 FMP 时间),节点的得分(用于决定影响 FMP 时间的占比)。这里我们通过监听 MutationObserver 来做收集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 观测页面 DOM 变动
this.observer = new MutationObserver(() => {
// 标记顺序计数
this.callbackCount += 1;
const time = performance.now();
const $body: HTMLElement = document.body;
if ($body) {
// 为节点增加 fmp_c=${this.callbackCount} 属性
this.setTag($body, this.callbackCount);
}
// 用来记录每次做标记的时间
this.statusCollector.push({
time,
});
});
// 观察所有子节点
this.observer.observe(document, {
childList: true,
subtree: true
});

这里 setTag 采用深度优先遍历来标记节点,标记条件为:

  1. 该节点在首屏可视区内
  2. 该节点面积不为 0
    如果父节点不满足调节,则不再继续向下遍历,深搜可以帮我们节约大量标记时间。
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
// 标记节点批次,深度优先搜索
private setTag(target: Element, callbackCount: number): void {
const tagName: string = target.tagName;
if (IGNORE_TAG_SET.indexOf(tagName) === -1) {
const $children: HTMLCollection = target.children;
if ($children && $children.length > 0) {
for (let i = $children.length - 1; i >= 0; i--) {
const $child: Element = $children[i];
const hasSetTag = $child.getAttribute('fmp_c') !== null;
// 如果没有标记过,则检测是否满足标记条件
if (!hasSetTag) {
const {
left,
top,
width,
height
} = $child.getBoundingClientRect();
if (WH < top || WW < left || width === 0 || height === 0) {
continue;
}
$child.setAttribute('fmp_c', `${callbackCount}`);
}
this.setTag($child, callbackCount);
}
}
}
}

当页面的 document.readyState 为 complete 或,load 事件触发后,我们停止 MutationObserver 监听,开始计算关键节点和 FMP 时间。

根据标记好的信息,计算各节点的的分情况

这里我们访问我们标记好的节点。节点得分与关键影响因素计算过程如下:

  • 当前节点得分 = 节点可视面积 * 权重 weight;关键影响因素为节点元素本身
  • 计算当前节点下所有子节点的得分,如果得分综合大于当前节点得分。则更新当前节点得分为所有子节点得分,当前节点的关键影响因素记为所有子节点。
  • 依照这个规则,递归出所有标记节点的得分和影响因素。
  • 页面根节点的得分,与关键影响因素即为最后结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private getTreeScore($node: Element): FMP.ICalScore | any {
if ($node) {
let dpss = [];
let $children: HTMLCollection = $node.children;
for (let i = 0; i < $children.length; i++) {
let $child = $children[i];
// 如果没被标记就不用计算了
if (!$child.getAttribute('fmp_c')) {
continue;
}
let s = this.getTreeScore($child);
if (s.st) {
// 子节点记录
dpss.push(s);
}
}
// 计算得分
return this.calcaulteScore($node, dpss);
}
return {};
}

这样我们就有了关键节点了,通过计算关键节点的最晚渲染时间,及为 FMP 时间。

这个时间计算方式如下:

如果关键节点为普通元素(weight === 1),那么元素上的 fmp_c 的标记时间即为该节点的渲染时间。(上文 this.statusCollector 中查找即可)

如果该节点是资源类型 (img,video)等,那我们我们可以取出资源的 url 地址,在 performance.getEntries() 中查到对应资源的加载时间。

对最后得出的所有关键节点取得分平均值,去掉平均值以下的数据;剩余数据即为有效数据,在这些有效数据中,取最晚加载渲染的元素时间即为最后的 FMP 时间

完整代码:FMP 计算实现脚本

FST (Fisrt Screen Time)

首屏时间是指浏览器从响应用户输入网络地址,到首屏内容渲染完成的时间。对于该时间这里我们有如下定义:
如果页面首屏有图片:

首屏时间 = 首屏图片全部加载完毕的时刻 - window.performance.timing.navigationStart

如果首页没有图片:

首屏时间 = 页面处于稳定状态前最后一次 dom 变化的时刻 - window.performance.timing.navigationStart

具体实现方案,我们可以参考这篇文章:如何自动获取首屏时间

TTI (Time to interactive)

TTI 应该衡量的是用户可交互的时间,通常我们将 domInteractive 的时间作为用户可交互时间,这个时间仅仅代表的是 HTML 构建完成(早于 dom ready)时间,此时并不意味着用户真的可以交互。
例如:

  1. 在 react 下,我们认为的用户可交互时间,应当是主页面 componentDidMount 事件触发的数据加载完成后,页面 componentDidUpdate 后的时间点为用户可交互时间。而在这之前,页面其实就只有一个 div#root 空节点。
  2. 在骨架屏下,骨架屏渲染完毕后,也并不意味着页面可交互

所以有的时候,业务自己定义的一个时间点,来上报这部分数据。如果借助自动化监测的话,我觉得靠谱的方案是 Google 提出的 TTI 出现时间:

  • 页面已经渲染了可用的内容,FCP 时间点之后(或者 FMP)
  • 可见元素已经完成了事件注册
  • 页面可在 50 毫秒内响应用户交互。

相关资料:

有一点需要注意的是,这个方案有兼容性问题,需要 chrome 版本大于 58

FPS (Frames Per Second)

FPS 多用于动画流畅度,交互流畅度等。所以实时监控 FPS 的做法,可能并无意义。当页面长时间挂着且几乎没有交互的时候(例如接单页)等,页面 FPS 实时数据均为 60 FPS。这样就导致了上报上来的数据毫无意义。
所以统计 FPS 性能指标,需要结合用户的实际操作才有意义。有一种采集 FPS 的做法,是在可能的交互上都做 FPS 监控,一旦 FPS 连续几个点低语一定的阈值,则上报。
在业务上,我们通常会监听滚动 FPS 这一指标,计算滚动 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
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
81
class ScrollFps {
// 连续滚动的最长间隔,超出算另一个滚动
private MaxScrollTimeGap: number = 200;
// FPS 统计允许的最小样本帧数量,少于此数的样本将被丢弃
private MinFrames: number = 5;
private requestAnimationFrame = window.requestAnimationFrame;
private onCallback: any = null;
private isScrolling: boolean = false;
private scrollRecords: Array<number>= [];
private scrollInfo: any = null;
private scrollEndTime: number = 0;
private fps: number = 0;
private scrollHandler: any;
private win: Window;
private endTimer: number = -1;
constructor(opts?: {win: Window, onCallback: any}) {
this.onCallback = opts.onCallback;
this.win = opts.win || window;
this.scrollHandler = this.handleScroll.bind(this);
this.bindEvent();
}
private bindEvent() {
// 捕获内建所有滚动
this.win.addEventListener('scroll', this.scrollHandler, true);
}
public removeEvent() {
this.win.removeEventListener('scroll', this.scrollHandler, true);
}
handleScroll() {
if (!this.isScrolling) {
this.collectStart();
}
this.scrollEndTime = performance.now();
if (this.endTimer !== -1) {
clearTimeout(this.endTimer);
}
this.endTimer = setTimeout(() => {
this.collectEnd();
}, this.MaxScrollTimeGap);
}
collectStart() {
this.isScrolling = true;
this.scrollEndTime = 0;
this.scrollInfo = {
frames: []
};
this.frame();
}
frame() {
this.scrollInfo.frames.push(performance.now())
if (this.isScrolling) {
requestAnimationFrame(() => this.frame());
}
}
collectEnd() {
this.isScrolling = false;
let fps = this.calcFps();
if (fps) {
this.scrollRecords.push(this.scrollInfo);
}
}
calcFps() {
let frames = this.scrollInfo.frames;
if (this.scrollEndTime) {
frames = frames.filter(it => it < this.scrollEndTime);
}
if (frames.length < this.MinFrames) {
return false
}
const during = (frames[frames.length - 1] - frames[0]) / 1000;
const fps = Math.min(frames.length / during, 60);
Object.assign(this.scrollInfo, {
frames,
during,
fps
});
// 输出滚动 fps 实时数据
// console.log({ frames, during, fps })
return true
}
}

API 指标

API 的指标口径主要为 API 的平均延迟,API 的成功率(网络成功率 & 业务成功率),为了方便上报这部分数据,我们通常会通过重写 xhr.send 和 xhr.open 的当时来实现。

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
const xhr = window.XMLHttpRequest
function interceptorAjax() {
if (!xhr) {
return;
}
let protocol: string = location.protocol;
if (protocol === 'file:') {
return;
}
let _open = xhr.prototype.open;
let _send = xhr.prototype.send;
xhr.prototype.open = function (method: string, url: string) {
this.url = url;
this._startTime = +new Date();
return _open.apply(this, arguments);
}
xhr.prototype.send = function () {
let _dispatchEvent = (event) => {
if (event) {
let duration = +new Date() - this._startTime
event.duration = duration
if (/一些特殊不需要拦截的url/.test(this.url)) {
// ...
} else {
// 做一些 API 请求之类的上报
}
}
}
this.addEventListener('load', _dispatchEvent);
this.addEventListener('error', _dispatchEvent);
this.addEventListener('abort', _dispatchEvent);
return _send.apply(this, arguments)
}
}

通常我们会上报的 API 相关数据有:

  • url:请求的 url
  • status:业务状态码(标识业务的成功率)
  • responseTime:API 请求耗时
  • content:请求附加的额外信息,比如请求错误原因等
  • networkCode:网络状态码 (标识网络成功率)

这种方式仅限于 ajax 请求,如果你用了 fetch 那还是自己写一个拦截吧。

参考资料