背景
在 Web 监控场景中,除了常见的如 FP、FCP、LCP 等都属于页面加载时的性能指标,而页面运行时同样存在一些性能问题导致不好的用户体验,比如丢帧卡顿、内存泄露页面崩溃等。为了能及时发现、定位到这些问题,因此探索运行时的性能监控方案。
本人通常每周都会写文章,但直接粘贴搬运到这里总有很多错乱,逐一检查调整很是繁琐,若文章对你有所帮助,劳烦点赞关注支持一下,我也有动力更新更多内容。若内容存在谬误,请帮忙指正。
下一篇准备更新《使用 Chrome DevTools Performance 进行长任务归因的详细步骤》
性能指标
通常公司内部都有大数据平台,并提供自定义上报和预警功能完成数据收集、分析和预警工作。我们要做的就是确定该收集上报哪些数据,因此下面是对一些常见的运行时性能指标的逐一分析。
FPS
这里的FPS(Frames Per Second)指网页的每秒渲染次数。当 FPS = 60 时表示页面每秒刷新60次,即每 16.7ms 渲染一次,此时用户感知到的页面渲染是流畅的。当 FPS 低于此数值,页面开始丢帧,FPS 越低用户感受到卡顿越明显。
收集与上报
可借助 PerformanceFrameTimg API 获取到当前页面的每帧渲染时间,从而可以计算出 FPS,但由于 PerformanceFrameTiming
存在非常严重的兼容性问题,因此最好的办法是使用requestAnimationFrame()
方法来计算 FPS。其回调方法会在页面下一次重绘前被执行,因此通过计算每秒内 requestAnimationFrame()
的执行次数可计算出页面帧数,并暂时收集起来:
var lastTime = performance.now(); // 上一个1s周期的时间戳
var frame = 0; // 记录帧数
var lastFameTime = performance.now(); // 上一帧发生的时间戳
var loop = function(time) {
var now = performance.now();
var fs = now - lastFameTime;
lastFameTime = now;
var fps = Math.round(1000 / fs); // 当前帧的 fps 理论值 = 1s / 相邻两帧的时间间隔
frame++;
// 如果当前帧在下一个1s周期内
if (now > 1000 + lastTime) {
var fs = (now - lastTime) / frame; // 一个周期内每帧的平均时间
var fps = Math.round(1000 / fs);
frame = 0;
lastTime = now;
};
console.log('fps', fps);
window.requestAnimationFrame(loop);
}
loop();
这样一来每次都会获得一组 FPS 值👇🏻,每秒可以做一次数据聚合分析:
[57,56,55,23,60,59,46,56,57,54,44,59,31,58,56,42,52,51,54,...]
理论上来说每次 FPS 小于 60 就是一次丢帧,但实际会很多因素造成 FPS 波动,并且略低于 60 并不会导致明显卡顿。因此可以将「连续 n 次的 FPS 低于某阈值 m」才定义为一次卡顿,比如连续3次 FPS 低于20认为是一次卡顿,并进行上报。
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;
}
推荐方案
- 持续每隔 1s 记录一次页面的 FPS 数值
- 每次记录 FPS 时候进行判断,如果连续 N 次页面的 FPS 数值小于 M,则认为页面发生了卡顿。
存在的问题
由于计算 fps、检查 fps 数据判断是否卡顿的操作本身就会导致一些性能问题,同时上报的卡顿 fps 数据过于单一(仅包含 fps 值和时间点),难以定位到实际导致卡顿的原因。
Longtask
长任务。《RAIL Model》以用户对性能延迟感知划分出来几个延迟时间阈值,其中,100 ms内完成用户输入的响应,会让用户感觉交互是即时的。如果空闲任务耗时较长将影响用户输入的页面响应。给实际输入处理预留50ms的时间是比较稳妥的,这就意味着空闲任务执行耗时需要在50ms内完成。因此通常以50ms设为阈值,将执行时间超过50毫秒并会阻塞主线程的任务定义为长任务。
通常情况下,长耗时的事件回调、代价高昂的回流与重绘、浏览器在超过50毫秒的事件循环的相邻循环之间所做的工作都会导致长任务,并因此导致交互响应的延迟。
收集与上报
var observer = new PerformanceObserver(function(list) {
var perfEntries = list.getEntries();
for (var i = 0; i < perfEntries.length; i++) {
var enrty = perfEntries[i];
// Process long task notifications: report back for analytics and monitoring
console.log(enrty.name);
console.log(enrty.enrtyType);
console.log(enrty.startTime);
console.log(enrty.duration);
}
});
// register observer for long task notifications
observer.observe({ entryTypes: ["longtask"] });
// Long script execution after this will result in queueing and receiving "longtask" entries in the observer.
存在的问题
- API 存在一定的兼容性问题。但好在我们检测代码导致的运行时问题,覆盖到了使用量较大的主流浏览器及其版本,还是能监控发现问题。
- 由于现有浏览器 API 的限制,并不能直接定位出抛出长任务的原因。 我解决这个问题的方式是设置一个上报结算算法,它会记录一段时间内发生的接口/资源请求、用户事件,如果在时间阈值内没有检测到长任务触发则会废弃记录。有则将这些记录作为额外的载荷信息随监控一起上报。这样一来在数据平台上就可以查阅到此次长任务之前或期间发生了些什么。由此得到更多信息去复现“事故”现场,然后通过 Chrome DevTools Performance 精准归因(推荐一些我的另一篇# Chrome DevTools Performance 功能详解)。
Memory
内存信息。浏览器自动管理页面的内存。每当页面创建一个对象时,浏览器都会“在后台”分配一块内存来存储该对象。由于内存是有限资源,浏览器会执行垃圾回收以检测何时不再需要某个对象并释放内存。如果存在内存泄露,那么内存使用量会随着时间持续增长,网页也就会变得越来越臃肿、缓慢。
内存泄露的原因
浏览器将“对象被需要(an object is needed)” 的概念等效于“对象可访问 (an object is reachable)”,即只有某对象无法通过其变量和其他可访问对象的字段访问时,浏览器才认为可以安全地将其回收。由于两者概念之间的差异使得实际不被需要的对象不一定能被检测到,因此导致了内存泄露问题的存在,比如:
const object = {a: new Array(1000), b: new Array(2000)};
setInterval(() => console.log(object.a), 1000);
这里 b 其实是一个不再需要的大数组,但浏览器不会回收它,因为它仍然可以通过object.b
访问。因此这个大数组 b 的内存被泄漏。这种问题在 Web 中非常普遍,比如忘记清理的事件监听器或定时器,数组中持续累计对象等等。
performance.memory
获取与上报
首先想到的是performance.memory
API,通过它可以获取到 JS Heap 信息(单位均为 byte):
{
jsHeapSizeLimit: 4294705152,
totalJSHeapSize: 23855790,
usedJSHeapSize: 21907574,
}
-
jsHeapSizeLimit
:表示当前页面最多可以获得的 JavaScript 堆大小; -
totalJSHeapSize
:表示当前页面已经分配的 JavaScript 堆大小; -
usedJsHeapSize
:表示当前页面 JavaScript 已经使用的堆大小。
以 Chrome 为例,Chrome 在分配内存的时候,会一次性向系统申请一块内存,然后在 JavaScript 需要的时候直接提供使用,因而 totalJSHeapSize
总是大于 usedJsHeapSize
的。如果 JavaScript 需要的内存多于已经申请的量,就会继续申请一块,直到达到 jsHeapSizeLimit
的上限,触发页面崩溃。根据 Chrome 的进程模型,在浏览器打开非常多 Tab 的时候,会出现多个 Tab 共享一个进程的情况。因此如果共享的几个页面中有一个内存大户,可能会导致一批 Tab 全部崩溃。
因此,理论上可以每秒上报一次 jsHeapSizeLimit
和 totalJSHeapSize
数据,通过这两个字段可以用于监控当前的页面是否有耗尽内存的危险;同时如果内存一直在涨却不见回落,则意味着潜在的内存泄漏危险。
存在的问题
-
它是一个旧的API,但却是一个非标准且永远不会被标准化的API。目前只有 Chrome / Opera 实现,因此存在很严重的兼容性问题。由于非标准,不同浏览器的API 很自由,从而导致数据差异性较大。
-
此 API 存在较严重的安全问题,各浏览器出于安全方面的应对都会有一些保护机制,通常API 并不会给出非常准确的数据。而且还会对给出的数据会额外加上一些干扰(详细了解可查看这个 Proposal、这个改动),甚至在某些场景下(如频繁获取)直接返回固定的虚假数据。
-
仅能获取 JS Heap 信息,但这通常仅占渲染器内存的25~40%。因为其不包含 DOM 、Canvas、解码的图像/视频等内存信息。
performance.measureUserAgentSpecificMemory()
performance.measureUserAgentSpecificMemory()
是一个新的API,可用于估计网页的内存使用情况,从而可用于检测是否存在内存泄漏。它解决了 performance.memory
API 存在的一些问题。并且它会在垃圾收集期间执行内存测量,因此数据更加准确。
以 Chrome 中的实现为例,Chrome 会在垃圾收集期间执行内存测量,这意味着 API 不会立即 resolve promise,而是需要等待下一次垃圾回收。API 在超时后会强制进行垃圾回收,当前设置为 20 秒。
获取与上报
推荐用法是定义一个全局内存监视器,它对整个网页的内存使用情况进行采样,然后上报。最简单的方法是定期采样,比如每 M 分钟采样一次。然而,这会给数据带来偏差,因为样本之间可能会出现内存峰值。下面示例展示了如何使用泊松过程进行无偏内存测量,该过程可保证样本在任何时间点出现的可能性均等。
function scheduleMeasurement() {
if (!performance.measureUserAgentSpecificMemory) {
console.log(
'performance.measureUserAgentSpecificMemory() is not available.',
);
return;
}
const interval = measurementInterval();
console.log(
'Scheduling memory measurement in ' +
Math.round(interval / 1000) +
' seconds.',
);
setTimeout(performMeasurement, interval);
}
// 页面加载完成后开始测量
window.onload = function () {
scheduleMeasurement();
};
以毫秒为单位计算随机间隔,这样平均每五分钟进行一次测量:
function measurementInterval() {
const MEAN_INTERVAL_IN_MS = 5 * 60 * 1000;
return -Math.log(Math.random()) * MEAN_INTERVAL_IN_MS;
}
最后,异步performMeasurement()
函数调用该 API 记录结果,并安排下一次测量:
async function performMeasurement() {
// 1. 调用 performance.measureUserAgentSpecificMemory().
let result;
try {
result = await performance.measureUserAgentSpecificMemory();
} catch (error) {
if (error instanceof DOMException && error.name === 'SecurityError') {
console.log('The context is not secure.');
return;
}
throw error;
}
// 2. 记录结果或上报
console.log('Memory usage:', result);
// 3. 安排下一次测量
scheduleMeasurement();
}
返回的结果如下:
// Console output:
{
bytes: 60000000,
breakdown: [
{
bytes: 40000000,
attribution: [
{
url: "https://foo.com",
scope: "Window",
},
]
types: ["JS"]
},
{
bytes: 0,
attribution: [],
types: []
},
{
bytes: 20000000,
attribution: [
{
url: "https://foo.com/iframe",
container: {
id: "iframe-id-attribute",
src: "redirect.html?target=iframe.html",
},
},
],
types: ["JS"]
},
]
}
说明(详细了解可查看 Measure Memory API):
-
bytes
:总内存使用估计值;这个值高度依赖于浏览器实现方式,因此无法跨浏览器进行比较。同一浏览器的不同版本之间也会发生变化。 -
breakdown
和attribution
:分别表示内存使用的分配信息和内存归因,某些浏览器可能会返回一个 空的breakdown
或attribution
。
存在的问题
-
由于是一个新的 API,存在较大兼容性问题,Firefox 和 Safari 还不支持。
-
不同浏览器或同一浏览器的不同版本之间数据存在差异,不好做横向比较。
-
为了安全考虑,API 要求启用站点隔离,需要通过设置 COOP+COEP headers 来加入跨域隔离,否则获取不到真实数据。
其他
其实尝试了解了如 CPU/GPU 使用率等指标,但被一一否接,主要原因都是缺少 API 支持。以CPU为例,只能通过执行脚本进行 CPU 速度进行测量,可能带来额外性能问题的同时数据也不准确。同时由于测量数据是基于整个系统,而不是当前页面,因此很难保证是受页面的影响。
最终推荐方案
FPS 存在一定的性能问题,且 FPS 数据只是表象,监控往外制造一些性能问题。而 Longtask 通常情况下是导致 FPS 降低的原因,因此 Longtask 监控会更直接有效;而 Memory 在兼容性和数据可用性上存在较大问题。因此综合上述对比下来,Longtask 的可行性会更高。