这是我参与「第五届青训营」伴学笔记创作活动的第 12 天,欢迎各位大佬批评指正。
前端监控是什么
在一些业务场景中,可能由于各种因素的问题导致页面长时间无法加载,或者是页面卡顿、资源错误等诸多问题,那么怎么样快速准确定位到问题的根源再进一步排除、修复问题呢?这就需要用到SDK技术。
前端监控主要是为了获取页面加载过程中每个部分性能开销数据,便于快速定位页面卡顿、白屏或是异常错误等,主要是监测性能指标、异常事件和用户行为三个部分。本文主要针对前两个进行介绍。
性能指标监测
早期的页面是纯静态的,但随着web应用的爆发,页面的交互也越来越复杂。因此在2010 年 8 月W3C成立了web性能工作组,致力于制定衡量web应用性能的方法和API。
1. 早期的性能标准模型:
2. 以用户为中心的性能指标:
传统指标专注于容易衡量的技术细节,但是很难反映出用户真正关心的是什么。因此专注于用户视角下的浏览体验的性能指标诞生了。大概包括:
| 用户体验 | 指标 |
|---|---|
| 发生了什么 | FP (First Paint), FCP (First Contentful Paint) |
| 内容有用吗 | FMP (First Meaningful Paint), SI (Speed Index), LCP(Largest Contentful) |
| 内容可用吗 | TTI (Time to Interactive), TBT(Total Blocking Time) |
| 是否令人愉悦 | FID (First Input Delay), CLS(Cumulative Layout Shift) |
-
TTI:测量页面从开始加载到主要子资源完成渲染,并且能够快速、可靠响应用户输入的所需时间。(是反映页面的可用性重要指标,TTI值越小,代表用户能够更早操作页面,体验也就越好。)
-
SI:衡量页面可视区域加载速度,帮助监测页面加载体验差异。比如下图A和B加载速度一样,但是明显A用户体验更好。
-
LCP:最大内容在可视区域内变为可见的时间点。比如一篇文章中一大段文字或者产品页面上的一张图片,用于理解页面内容最有用的元素。(更加容易理解,并且能够给出和FMP相似的结果,容易计算和上报;更加常用)。
-
TBT:量化主线程在空闲之前的繁忙程度,有助于理解在加载期间,页面无法响应用户输入的时间有多久。(一般认为如果一个任务在主线程上运行时间超过50ms,那就是一个
长任务。超过50ms后的任务耗时都算作阻塞时间。)而一个页面的TBT是从FCP到TTI之间所有长任务阻塞时间总和。 比如下图的TBT为60ms。 -
CLS:量化了页面加载期间,视口中元素的移动程度。比如当我们点击按钮的时候,突然增加一块内容使得按钮偏移了原本的位置,这种位移会降低用户体验。
异常事件监控
静态资源错误
加载页面所需的HTML、CSS和JS文件或各类多媒体文件拉取时,发生了预期之外的错误,如网络异常等,导致静态资源无法加载。
因此主要监控HTTP协议状态码。即当状态码 >= 400,认为发生了异常事件。
JS 错误
在页面运行时发生了JS错误会严重影响页面的正常渲染和交互,是前端监控重中之重。
白屏异常
因为白屏没有标准化的监听方法,通常可以借助判断DOM树的结构来粗略判断白屏是否发生。
可能导致白屏因素包括:
- 发生
JS错误导致关键资源渲染失败。 - 请求异常或静态资源加载失败。
- 长时间的
JS主线程繁忙阻塞渲染任务。
SDK 实现
1. Performance和PerformanceOberver
/**
* fp/fcp --> paint
* lcp --> largest-contentful-paint
* fip --> first-input
*/
const entryTypes = ['paint', 'largest-contentful-paint', 'first-input'];
// 1. 通过window.performance 对象拿到 fp, fcp, fip.
window.performance.getEntriesByType('paint');
window.performance.getEntriesByType('first-input');
// 2. 通过PerformanceObserver 监听(获取的值比Performance多)
const p = new PerformanceOberver(list => {
for(const entry of list.getEntries()) {
console.log(entry);
}
});
p.observe({ entryTypes });
// 封装 monitor:1.起名字;2.监听能力;3.主动开启;4.上报能力。
function createPerMonitor(report:({name:string, data:any}) => void) {
const name = 'performance';
const entryTypes = ['paint', 'largest-contentful-paint', 'first-input'];
function start() {
const p = new PerformanceOberver(list => {
for(const entry of list.getEntries()) {
report({name,data:entry});
}
});
p.observe({ entryTypes });
return {name, start}
}
2. JS 错误监控
- 利用
window.addEventListener()方法监听error事件可以拿到 js 错误。 unhandledrejection用于监听 promise 状态 reject 之后但是没有处理函数时会被触发。
// 1. js错误
window.addEventListener('error', (e) => {
// e.error不为空时为js错误
if(e.error) {
//处理逻辑
}
});
// 2.promise-reject
window.addEventListener('unhandledrejection', (e) => {
//处理逻辑
});
// 封装monitor
function createJsErrorMonitor(report:({name:string, data:any}) => void) {
const name = 'js-error';
function start() {
window.addEventListener('error', (e) => {
// e.error不为空时为js错误
if(e.error) {
report({name, data:{type:e.type, message:e.message}});
}
});
window.addEventListener('unhandledrejection', (e) => {
report({name, data:{type:e.type, reason:e.reason}});
});
}
return {name,start};
}
3. 静态资源错误监控
注意,静态资源错误与js错误监听方法一致,但是要注意区分:e.target || e.srcElement,如果为空,则return排除掉。(srcElement是较老写法。目前已经废弃,但是为了兼容仍然写上。)
// 1. 静态资源错误
window.addEventListener('error', (e) => {
const target = e.target||e.srcElement;
//如果target不存在则排除
if(!target) {
return;
}
//判断是link还是script错误
if(target instanceOf HTMLElement) {
// link或者script
let url;
if(target.tagName.toLocaleLowerCase() === 'link') {
// 通过href获取资源链接
url = target.getAttribute('href');
} else {
// 通过src获取资源链接
url = target.getAttribute('src');
}
}
//要在捕获阶段监听
},true);
// 封装monitor
function createResourceMonitor(report:({name:string, data:any}) => void) {
const name = 'resource';
function start() {
window.addEventListener('error', (e) => {
const target = e.target||e.srcElement;
//如果target不存在则排除
if(!target) {
return;
}
//判断是link还是script错误
if(target instanceOf HTMLElement) {
// link或者script
let url;
if(target.tagName.toLocaleLowerCase() === 'link') {
// 通过href获取资源链接
url = target.getAttribute('href');
} else {
// 通过src获取资源链接
url = target.getAttribute('src');
}
return ({name, data:{url}});
}
},true);
}
return {name,start};
}
4. 请求错误监控
请求错误一般监听xhr和fetch发生错误。
PS:
hook函数,在原有函数中注入自己函数的一些逻辑。
//hook函数
function hookMethod(obj:any, key:string, hookFunc:Function) {
return (...params:any[]) => {
obj[key] = hookFunc(obj[key], ...params);
}
}
// hook xhr对象的open方法
hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
function (method: string, url: string) {
this.payload = {
method,
url,
};
// 执行原函数
origin.apply(this, [method, url]);
}
)();
// hook xhr 对象的 send 方法监听到错误的请求
hookMethod(XMLHttpRequest.prototype, 'send', (origin: Function) =>
function (...params: any[]) {
this.addEventListener("readystatechange", function () {
if (this.readyState === 4 && this.status >= 400) {
this.payload.status = this.status;
console.log(this.payload);
}
});
origin.apply(this, params);
}
)();
测试
//const xhr = new XMLHttpRequest();
//xhr.open("post", "111.cc");
//xhr.send();
// 封装成一个 monitor
function createXhrMonitor(report: ({ name: string, data: any }) => void) {
const name = "xhr-error";
function hookMethod(
obj: any,
key: string,
hookFunc: Function,
) {
return (...params: any[]) => {
obj[key] = hookFunc(obj[key], ...params)
}
}
function start() {
hookMethod(XMLHttpRequest.prototype, 'open', (origin: Function) =>
function (this, method: string, url: string) {
this.payload = {
method,
url,
};
origin.apply(this, [method, url]);
}
)();
hookMethod(XMLHttpRequest.prototype, 'send', (origin: Function) =>
function (this, ...params: any[]) {
this.addEventListener("readystatechange", function () {
if (this.readyState === 4 && this.status >= 400) {
this.payload.status = this.status;
report({ name, data: this.payload });
}
});
origin.apply(this, ...params);
}
)();
}
return { name, start }
}
5. 按需加载监控
封装一个简易通用的SDK。利用Navigator.sendBeacon()方法,可用来将统计数据发送给服务器。
function createSdk(url: string) {
const monitors: Array<{ name: string, start: Function }> = [];
const sdk = {
url,
report,
loadMonitor,
monitors,
start,
}
function report({ name: string, data: any }) {
// 注意:数据发送前需要先序列化为字符串
navigator.sendBeacon(url, JSON.stringify({ name: string, data: any }));
}
function loadMonitor({ name: string, start: Function }) {
monitors.push({ name: string, start: Function });
// 实现链式调用
return sdk;
}
function start() {
monitors.forEach(m => m.start());
}
return sdk;
}