大家好,我是风骨,在前端领域能让我们深入探究的方向有很多,比如今天的主题:前端监控。
在大型项目中,前端监控 是不可或缺的一部分。它的优势可以体现在以下场景:
- 稳定性:尽早发现程序运行错误并及时修复;
- 用户体验:性能监控分析,持续优化改善网站使用体验;
- 业务扩展:常见的数据埋点,如统计 PV 页面浏览量。
其中 稳定性 和 用户体验 是我们完成前端基建必不可少的组成部分。
而要实现一套完整的前端监控,需要经历以下过程:
其中:
SDK
,负责处理客户端(浏览器)程序在运行期间的监控日志收集和上报;日志服务器
,负责接收SDK
上报的Log
日志,进行清洗过滤存入数据库;可视化平台
,则是以可视化形式,直观展示上报过来的数据。
其中,日志服务器
使用 Node.js 技术栈实现 server 端逻辑,可视化平台
可以基于统计数据实现报表展示。
下面,我们重点从 0 到 1 一步步来构建一个 Web 监控 SDK。文章大纲如下:
毛遂自荐 :笔者最近在看工作机会,各位小伙伴 如果有适合的内推岗位,期待帮我引荐一下(可以在 掘金 上私信 或是 添加微信: iamcegz
),感谢!个人简介如下:
男,27 岁,计算机专业,工作年限 6 年,Base 北京,擅长 React 技术栈 和 前端工程化建设。
一、设计 SDK
SDK 承担了前端监控和数据上报 的工作,通常我们会以 <script> js
脚本文件的形式接入到业务项目中。
下面,首要工作是建立一个 SDK 项目仓库,采用打包工具构建出 JS 脚本文件。
1、搭建 Rollup 构建环境
构建工具我们选用 Rollup
,它非常适合构建一些工具库、组件库。下面我们初始化一个 monitor-sdk
目录,并完成 Rollup 打包配置。
bash
mkdir monitor-sdk
cd monitor-sdk
npm init -y
npm install rollup rollup-plugin-terser @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-babel typescript @babel/preset-typescript @babel/preset-env @babel/core @babel/cli -D
tsc --init
touch index.ts
touch rollup.config.js
js
// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import { babel } from "@rollup/plugin-babel";
import { terser } from "rollup-plugin-terser";
import path from "path";
const extensions = [".ts", ".tsx", ".js", ".jsx"];
export default () => ({
input: path.resolve(__dirname, "index.ts"),
output: {
file: path.resolve(__dirname, "dist/myMonitor.js"),
format: "umd",
name: "myMonitor",
},
plugins: [
resolve({
extensions, // 指定 import 模块后缀解析规则
}),
commonjs(),
babel({
extensions,
presets: [
"@babel/preset-env",
[
"@babel/preset-typescript",
{
isTSX: true,
allExtensions: true,
},
],
],
babelHelpers: "bundled",
}),
terser(),
],
});
最后,我们在 package.json
中加入 build
命令,运行 npm run build
完成 dist/myMonitor.js 构建。
json
"scripts": {
"build": "rollup -c rollup.config.js -w"
}
PS:另外我们可以配置 ESLint、Prettier 等前端规范工具,由于非本文中心主题,跳过详细描述。
2、异常监控
在前端,程序发生异常的种类有很多,比如:JS 代码执行错误、Promise 未被处理的错误、React/Vue 组件 render 错误、静态资源加载错误、请求 API 出错等。
以上这些异常场景,都需要我们进行监控并上报到服务器。
2.1、JS 代码执行错误
首先,我们模拟代码错误:访问一个未定义的对象的属性
。
html
// examples/jsError.html
<body>
<div id="container">
<input type="button" value="点击抛出错误" onclick="errorClick()" />
</div>
<script>
function errorClick() {
// 模拟代码错误:访问一个未定义的对象的属性
window.someVal.error = "error";
}
</script>
</body>
当点击按钮后,会发生报错:
在 JS 中未被 try/catch 捕获的代码错误,可以通过 window.addEventListener('error')
全局监听到。
在监控到错误以后,我们需要对错误进行 数据建模,拿到能够描述和定位此错误的有用信息。
异常日志数据建模可以遵循以下类型结构:ErrorLog
ts
// interface/index.ts
export interface ErrorLog {
// type 监控类型:error(代码错误)
type: "error";
// 错误类型:jsError(JS 代码错误)
errorType: "jsError" | ...;
// 错误信息
message: string;
// 错误发生的文件
filename: string;
// 错误发生的行列信息
position: string;
// 错误堆栈信息
stack: string;
// 错误发生在 DOM 到顶层元素的链路信息(使用选择器表示,如:body div#container input)
selector?: string;
}
有了上面的分析,我们来实现 JS 代码执行错误监控。
新建 module/jsError.ts
文件,注册 window.addEventListener('error')
监听错误,并对错误信息进行数据建模得到上报 log 数据。
ts
// index.ts
import injectJSError from "./modules/jsError";
injectJSError();
// modules/jsError.ts
import { ErrorLog } from "../interface";
import { formatStack } from "../utils";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
export default function injectJSError() {
// 1、监听全局未被 try/catch 捕获的错误
window.addEventListener(
"error",
(event) => {
console.log("js error event: ", event);
const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件
// 1.1、数据建模存储
const errorLog: ErrorLog = {
type: "error",
errorType: "jsError",
message: event.message,
filename: event.filename,
position: `${event.lineno}:${event.colno}`,
stack: formatStack(event.error.stack),
selector: lastEvent ? getSelector() : "",
};
console.log("js error log: ", errorLog);
// 1.2、上报数据(TODO...)
},
// !!! 使用事件捕获进行监听
true
);
}
其中 errorType
标识是一个 JS 代码错误,message
、filename
、position
信息都可以从 event
错误事件对象上获取。
这里重点介绍一下 stack
和 selector
的信息来源。
stack
统计函数调用错误栈信息:
首先,访问 event.error.stack
得到的是发生错误的执行调用栈信息(String
):
我们可以稍做加工一下,得到一个更直观的调用栈的信息,加工后展示如下:
formatStack
的实现:
ts
// utils/index.ts
export function formatStack(stack: string) {
return stack
.split("\n")
.slice(1)
.map(item => item.replace(/^\s+at\s+/g, ""))
.join("\n");
}
selector
统计 DOM 节点层级树信息:
getLastEvent()
函数用来返回最近一个交互事件,当错误发生来自于用户与页面交互时,lastEvent
将有值是事件对象。它的实现通过在全局绑定相关交互事件:
ts
let lastEvent: Event | null;
let lastEventPath: any[];
["click", "touchstart", "mousedown", "keydown"].forEach(eventType => {
// 埋点方式:无痕埋点 -> 全部埋点
document.addEventListener(
eventType,
event => {
lastEvent = event;
// 新版浏览器中 event.path 已被废弃,改用 event.composedPath()
lastEventPath = event.path || event.composedPath();
},
{
capture: true, // 以捕获形式监听(因为默认元素的事件都是冒泡形式,如果出现阻止默认事件,在这里将监听不到)
passive: true,
},
);
});
// 获取最近一次的事件调用栈
export function getLastEventPath() {
return lastEventPath;
}
// 获取最近一次的事件
export default function getLastEvent() {
return lastEvent;
}
当错误发生时,事件对象 event.composedPath()
可以拿到 DOM 树层级信息:
最后,getSelector()
方法对则是对 DOM 树层级进行进行加工,得到一个包含 DOM 选择器的层级字符串。
ts
// getSelector.ts 获取当前事件链路上的元素选择器
import { getLastEventPath } from "./getLastEvent";
function getSelectorByPath(path: any[]) {
return path
.reverse() // 翻转 Path 中的元素
.filter(element => {
// 过滤掉 window、document 和 html
return element !== window && element !== document && element !== document.documentElement;
})
.map(element => {
if (element.id) {
return `${element.nodeName.toLowerCase()}#${element.id}`; // 返回 标签名#id
} else if (element.className && typeof element.className === "string") {
return `${element.nodeName.toLowerCase()}#${element.className}`; // 返回 标签名.class
} else {
return element.nodeName.toLowerCase(); // 返回 标签名
}
})
.join(" ");
}
export default function getSelector() {
const path = getLastEventPath();
if (Array.isArray(path)) {
return getSelectorByPath(path);
}
}
现在,在上述示例中引入 monitor sdk js
,点击按钮模拟 JS 执行错误,就可以监控到 errorLog
日志:
有了错误日志,接下来便是考虑数据上报。
2.2、数据上报方式
在前端,可供数据上报至服务器的方式有三种:
ajax
请求;img GIF
图片 GET 请求方式上报,优点:速度快,没有跨域问题;navigator.sendBeacon()
方法通过 HTTP 将少量数据异步传输到 Web 服务器。
ajax
方式我们再熟悉不过了,不论是 xhr
对象还是 fetch
函数,都可以将数据传递到服务器。
navigator.sendBeacon()
也是一种不错的方式,它的最大优势在于 异步,确保数据在页面卸载过程中仍然能够被发送,而不会被中断。
PS: 有关
navigator.sendBeacon()
的使用可以查阅文档:developer.mozilla.org/zh-CN/docs/...
重点介绍一下 img GIF
的上报方式。
简单来说,我们可以在服务器上放置一个非常小的 gif 图片(1kb),将日志数据作为图片 url queryString
参数,访问这个图片来达成数据上报至服务器。
下面来看看具体实现。
首先,创建一个 Tracker 类
,并提供一个 send
方法,通过 img
标签访问服务器上的图片来完成日志上传。
ts
// utils/tracker.ts
import { MonitorLog, MonitorTypeLog } from "../interface";
import getLogBaseData from "./getLogBaseData";
class Tracker {
url: string;
constructor() {
// 上报日志服务器地址(服务器上的 gif 图片)
this.url = "http://localhost:8080/send/monitor.gif";
}
send(data: MonitorTypeLog) {
// 获取基础日志数据
const baseData = getLogBaseData();
const log: MonitorLog = {
baseLog: baseData,
...data,
};
console.log("send log", log);
// 进行数据上报
const img = new window.Image();
img.src = `${this.url}?data=${encodeURIComponent(JSON.stringify(log))}`;
}
}
export default new Tracker();
这里 baseData
表示日志上报可以携带一些设备的基础信息,比如浏览器型号、版本等。此外,你还可以加入一些业务信息如 用户身份 进行上报。
设备信息来自于 navigator.userAgent
,可以使用 ua-parser-js
三方库来完成设备参数解析。
ts
// utils/getLogBaseData.ts
import { UAParser } from "ua-parser-js";
import { BaseLog } from "../interface";
// 获取设备信息
const { browser, device, os } = UAParser(navigator.userAgent);
/**
* getLogBaseData 获取日志基本信息
*/
export default function getLogBaseData(): BaseLog {
return {
title: document.title,
url: location.href,
userAgent: navigator.userAgent,
browser: `${browser.name} ${browser.version}`,
device: `${device.model} ${device.vendor}`,
os: `${os.name} ${os.version}`,
};
}
最后,在监控到报错时,调用 tracker.send()
将错误日志上报到服务器。
diff
// jsError.ts
export default function injectJSError() {
// 1、监听全局未被 try/catch 捕获的错误
window.addEventListener(
"error",
event => {
const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件
// 1.1、数据建模存储
const errorLog: ErrorLog = {...}
+ // 1.2、上报数据
+ tracker.send(errorLog);
},
// !!! 使用捕获
true,
);
}
接下来,就是在服务端的处理,通过解析图片请求,拿到请求上的 queryString
参数。相关部分可以查看下文 「日志服务器 - 接收上报数据」。
2.3、Promise 未处理的错误
Promise 错误一般是指:在 Promise execute、then
方法中代码执行出错,或 Promise execute reject()
变成失败态,且没有被 .catch
函数进行捕获处理。
如下,是 Promise 抛出错误的代码示例:
html
// examples/promiseError.html
<body>
<div id="container">
<input type="button" value="点击抛出 Promise 错误" onclick="promiseErrorClick()" />
</div>
<script>
function promiseErrorClick() {
new Promise(function (resolve, reject) {
// 抛出错误方式 1:
window.someVal.error = "error";
// 抛出错误方式 2:
reject("错误原因:模拟一个 Promise 错误。");
});
}
</script>
</body>
然而,Promise 未处理的错误不会被 window.addEventListener('error')
所监听,需要使用另一个监听事件 unhandledrejection
来完成。
同时,unhandledrejection
的事件对象 event.reason
在这两个错误场景下表现有所不同:
- 代码执行错误 :
event.reason
是一个对象,reason.stack
包含调用栈信息,可以从中拿到错误文件、行和列等信息; - reject() 变更 Promise 为失败状态 :
event.reason
是一个字符串,值为传递给reason()
函数的失败原因参数。
因此,在数据建模时,需要依据 event.reason
进行区分:
ts
// modules/jsError.ts
...
export default function injectJSError() {
// 1、监听全局未被 try/catch 捕获的错误
window.addEventListener("error", ...);
// 2、监听未被捕获的 Promise 错误
window.addEventListener(
"unhandledrejection",
event => {
console.log("Promise error event: ", event);
const lastEvent = getLastEvent(); // 监听到错误后,获取到最后一个交互事件
let message;
const reason = event.reason; // Promise 失败的原因
let filename;
let line = 0;
let column = 0;
let stack = "";
if (typeof event.reason === "string") {
// 情况 1、是 Promise reject 抛出的错误(没有办法获取 stack 等信息)
message = reason;
} else if (typeof reason === "object") {
message = reason.message;
// 情况 2、是 Promise 中 JS 代码执行出错
if (reason.stack) {
// 从错误信息中匹配到关键信息。stack 示例:at http://localhost:8080/examples/promiseError.html:29:32
const matchResult = reason.stack.match(/at\s+(.+):(\d+):(\d+)/);
filename = matchResult[1];
line = matchResult[2];
column = matchResult[3];
stack = formatStack(reason.stack);
}
}
// 2.1、数据建模存储
const errorLog: ErrorLog = {
type: "error",
errorType: "promiseError", // 错误类型 - Promise 代码错误
message,
filename,
position: `${line}:${column}`,
stack,
selector: lastEvent ? getSelector() : "",
};
// 2.2、上报数据
tracker.send(errorLog);
},
true,
);
}
对于 Promise 中的代码执行错误
,收集到的错误日志信息和「JS 代码执行错误」基本一致:
而对于 Promise reject 变更为失败态
,收集到的错误日志信息如下:
2.4、资源加载错误
资源一般是指 JS、CSS、图片
,当访问这些资源出错或 404 找不到时,我们可以监控进行上报。
如下,我们随意访问一个不存在的资源,模拟资源加载 404。
html
// examples/loadResourceError.html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>资源加载出错.</title>
<script src="../dist/myMonitor.js"></script>
</head>
<body>
<!-- <img src="/someError.png" /> -->
<!-- <link rel="stylesheet" href="/someError.css" /> -->
<script src="/someError.js"></script>
</body>
</html>
加载资源,需要使用指定的 HTML 标签元素(script、link、img
),比如加载 JS 文件需要使用 script
标签。
除了通过 标签 onerror
事件监听错误外,还可以通过 window.addEventListener('error')
在全局统一监听资源加载错误。
我们在 jsError.ts
中的 error
事件监听处理函数中,加入监控资源加载出错逻辑。
js
// modules/jsError.ts
export default function injectJSError() {
window.addEventListener(
"error",
event => {
// 监听 JS/CSS 资源文件加载错误
const target = event.target as HTMLScriptElement | HTMLImageElement | HTMLLinkElement | null;
let filename;
if (target && (filename = (target as HTMLScriptElement | HTMLImageElement).src || (target as HTMLLinkElement).href)) {
// 1、数据建模存储
const log: ErrorLog = {
type: "error",
errorType: "loadResourceError", // 错误类型 - JS/CSS 资源加载错误
message: `${filename} resource loading fail.`,
filename, // 报错的文件
tagName: target.tagName, // 资源标签名称
selector: getSelector(event.target as HTMLElement), // body script
};
// 2、上报数据
tracker.send(log);
}
// 监听 JS 代码执行出错
else { ... }
},
true,
);
}
对于 script 和 img
元素,可通过 src
属性来识别,而 link
则可以用 href
属性来识别。
最后在数据建模获取 selector
时,传递 target
元素来获取节点树上的层级信息。
diff
export default function getSelector(ele?: HTMLElement) {
const path = getLastEventPath();
if (Array.isArray(path)) {
return getSelectorByPath(path);
+ } else if (ele) {
+ return getSelectorByEle(ele);
+ }
}
+ function getSelectorByEle(ele: HTMLElement) {
+ let node: HTMLElement | null = ele;
+ const path: string[] = [];
+ while (node && filterTopLevelNode(node)) {
+ path.unshift(getEleSelector(node));
+ node = node.parentElement;
+ }
+ return path.join(" ");
+ }
+ function filterTopLevelNode(element: Window | Document | HTMLElement) {
+ // 过滤掉 window、document 和 html
+ return element !== window && element !== document && element !== + document.documentElement;
+ }
+ function getEleSelector(element: HTMLElement) {
+ if (element.id) {
+ return `${element.nodeName.toLowerCase()}#${element.id}`; // 返回 标签名#id
+ } else if (element.className && typeof element.className === "string") {
+ return `${element.nodeName.toLowerCase()}#${element.className}`; // 返回 标签名.class
+ } else {
+ return element.nodeName.toLowerCase(); // 返回 标签名
+ }
+ }
2.5、API 请求报错
在客户端(浏览器),可以通过 xhr/fetch
请求服务端接口。为了保证前后端交互的稳定性,双方都可以去做 API 请求监控。
以 xhr ajax
为例,要监控 API 请求的状态,推荐的做法是采用 「重写 XMLHttpRequest
构造函数原型上的方法」 来实现。
以下是一个简单的模拟 xhr 请求服务器 API 出现 500 错误的示例,对应前后端代码如下:
js
// 前端:
window.onload = () => {
// 访问一个报错的 api,模拟 500 错误
const xhr = new XMLHttpRequest();
xhr.open("get", "http://localhost:3000/error?name=test", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function () {
// 在 onload 事件中检查 xhr.status 是否在 200-299 的范围内。如 404 或 500 错误,都会在这里进行监控上报。
console.log("onload: ", xhr);
};
xhr.onerror = function (error) {
// onerror 事件主要用于处理 跨域问题、网络错误 等无法正常连接到服务器的情况,例如本地无网络。
console.log("error: ", error, xhr);
};
xhr.send();
};
// 服务端:
const http = require("http");
const server = http.createServer((req, res) => {
// 允许跨域
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
// 处理预检请求
res.writeHead(204);
res.end();
} else {
if (req.url.startsWith("/error")) {
res.writeHead(500, { "Content-Type": "text/plain" });
res.end("Server Error, code is 500.");
} else {
// 处理正常请求
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello, world!");
}
}
});
server.listen(3000, () => console.log("Server running on port 3000"));
重写 XMLHttpRequest
原型方法来完成 API 请求监控,分以下步骤:
- 重写
XMLHttpRequest.prototype.setRequestHeader
方法,保存设置的请求头信息; - 重写
XMLHttpRequest.prototype.open
方法,保存 API 请求的method
和url
; - 重点!重写
XMLHttpRequest.prototype.send
方法,当判定请求出错时(如:跨域、404、500),收集请求信息进行数据上报。
另外,我们还需要配置一个白名单 whiteList
,跳过无需监控的请求,如「监控日志上报服务器请求」。
核心功能实现如下:
js
// modules/xhr.ts
import { ErrorLog } from "../interface";
import { parseQueryString } from "../utils";
import tracker from "../utils/tracker";
// 不需要监控的接口白名单
const whiteList = [
"http://localhost:8080/send/monitor.gif", // 日志服务接口
];
// 增强 XHR:通过重写 XHR 主要方法,实现拦截和增强 XHR
export default function injectXHR() {
const XMLHttpRequest = window.XMLHttpRequest;
// 1、重写 setRequestHeader 方法增强功能 - 记录 request headers 数据
const oldSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function (key, value) {
if (!this.requestHeaders) (this.requestHeaders = {});
this.requestHeaders[key] = value;
return oldSetRequestHeader.apply(this, arguments);
};
// 2、重写 open 方法增强功能 - 记录请求方式和 url
const oldOpen = XMLHttpRequest.prototype.open; // 记录老的 open 方法
XMLHttpRequest.prototype.open = function (method, url, async) {
// 跳过 白名单接口 防止出现死循环。
if (whiteList.indexOf(url) === -1) {
this.logData = { method: method.toUpperCase(), url }; // 存储数据
}
return oldOpen.apply(this, arguments);
};
// 3、重写 send 方法增强功能 - 监控上报数据
const oldSend = XMLHttpRequest.prototype.send; // 记录老的 send 方法
XMLHttpRequest.prototype.send = function (body) {
if (this.logData) {
// 在 send 发送之前,记录请求开始时间
const startTime = Date.now();
const handler = (type: "load" | "error") => {
return () => {
const duration = Date.now() - startTime; // 持续的时间
const status = this.status; // 200 | 400 | 500
const statusText = this.statusText; // OK | Server Error
const { url, method } = this.logData;
const params = ['GET', 'DELETE'].indexOf(method) > -1 ? parseQueryString(url) : body;
// 当请求发生错误时,上报数据(忽略无网络的错误,处理像 跨域错误、404、500 等错误)
if ((type === "error" && window.navigator.onLine) || (type === "load" && status >= 400)) {
const log: ErrorLog = {
type: "error",
errorType: "xhrError", // 错误类型是 xhr
message: statusText, // 错误信息
xhrData: {
eventType: type, // load | error
url, // api 路径
method, // 请求方式
header: this.requestHeaders, // 请求头
params, // 请求参数
duration, // 请求时长
status,
response: this.response ? JSON.stringify(this.response) : "", // 请求结果
},
};
console.log("XHR log: ", log);
tracker.send(log);
}
};
};
// 服务端返回 status 为 500 也会进入 load,需要进一步判断 status
this.addEventListener("load", handler("load"), false);
this.addEventListener("error", handler("error"), false);
}
return oldSend.apply(this, arguments);
};
}
3、白屏监控
白屏通常是由 代码执行错误
引起,导致框架(如 React)渲染流程中断。所以白屏可以结合【异常监控】一起上报,可以关联到导致白屏的错误原因。
我们思考一下:如何判定当前页面是白屏呢?
可以采用【页面采样识别检测】:通过在页面上确定多个采样点,使用 elementFromPoint
方法获取采样点的元素,判断采样点元素是否为有效元素(比如非 body、#root
等根节点)。
关于采样点的定义,以页面为中心的 水平 和 垂直线上,来定义多个采样点。
白屏检测的实现如下:
ts
// modules/xhr.ts
export default function checkWhiteScreen() {
// 最顶层的空白元素(判断是白屏的依据)
const wrapperElements = ["html", "body", "#root"];
let emptyPoints = 0; // 记录空白的点的个数
function getSelector(element: Element) {
let selector;
if (element.id) {
selector = `#${element.id}`;
} else if (element.className && typeof element.className === "string") {
// prettier-ignore
selector = "." + element.className.split(" ").filter(item => !!item).join(".");
} else {
selector = element.nodeName.toLowerCase();
}
return selector;
}
function isWrapper(element: Element) {
const selector = getSelector(element);
if (wrapperElements.indexOf(selector) > -1) {
emptyPoints++; // 是空白点
}
}
for (let i = 1; i <= 9; i++) {
// 在高度一半的位置,横坐标均分取 9 个点,查看这 9 个点上的元素
const xElements = document.elementFromPoint(
(window.innerWidth / 10) * i,
window.innerHeight / 2,
);
// 在宽度一半的位置,纵坐标均分取 9 个点,查看这 9 个点上的元素
const yElements = document.elementFromPoint(
window.innerWidth / 2,
(window.innerHeight / 10) * i,
);
// 判断点的位置,是否是空白元素
isWrapper(xElements!);
isWrapper(yElements!);
}
// 定义阈值,比如 当所有的点(18个)都是空白点,那么就认为是空白页面,有一个点上有元素,就认为不是空白页面。
if (emptyPoints === 18) {
return true;
}
return false;
}
当发生 JS 执行错误后,进行白屏检测并一起上报数据。
diff
// modules/jsError.ts
...
+ import checkWhiteScreen from "../utils/checkWhiteScreen";
export default function injectJSError() {
window.addEventListener(
"error",
event => {
...
+ const isWhiteScreen = checkWhiteScreen(); // 检查是否白屏
// 1.1、数据建模存储
const errorLog: ErrorLog = {
// kind: "stability", // 监控指标的大类
type: "error",
errorType: "jsError",
message: event.message,
filename: event.filename,
position: `${event.lineno}:${event.colno}`,
stack: formatStack(event.error.stack),
selector: lastEvent ? getSelector() : "",
+ isWhiteScreen,
};
console.log("js error log: ", errorLog);
// 1.2、上报数据
tracker.send(errorLog);
},
// !!! 使用捕获
true,
);
...
}
4、统计页面加载时间
页面加载时间的指标信息有很多,一般会重点分析 DOM 树构建完成的时间(DOMContentLoaded
) 和 页面完整的加载时间(load
) 。
浏览器 PerformanceNavigationTiming
对象提供了关于页面加载性能的详细信息。(对应旧版本的 performance.timing 对象
)
在统计加载时间前,我们先了解两个时间的执行时机:
DOMContentLoaded
,是一个 DOM 事件,当浏览器完成 HTML 文档的解析,构建完成 DOM 树后触发,但不包含图片、CSS、JavaScript 等外部资源的加载。onLoad
,是一个 JS 事件,它在页面的所有资源(包括 HTML、CSS、图片、JavaScript 等)完全加载完成后触发。
如何统计这两个加载时间呢?需要用到以下加载性能信息:
fetchStart
:浏览器开始发起 HTTP 请求文档的时间;domContentLoadedEventStart
:DOM 树构建完成后触发 DOMContentLoaded 事件的时间;loadEventStart
:页面所有资源(包括图片)加载完成后触发 window.onload 事件发生的时间。
如下示例,在 DOM 树构建完成后,请求一个图片资源:
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<img src="https://picsum.photos/200/300" alt="" />
</body>
</html>
对应的先得到 DOMContentLoaded
时间,等待图片加载完成后,得到 onLoad
时间。
监听统计加载时间实现如下:
js
// modules/timing.ts
import { TimingLog } from "../interface";
import tracker from "../utils/tracker";
export default function injectTiming() {
window.addEventListener("load", () => {
let DOMContentLoadedTime = 0,
loadTime = 0;
// 新版浏览器 API:PerformanceNavigationTiming 提供了关于页面加载性能的详细信息,替代旧的 performance.timing
if (performance.getEntriesByType) {
const perfEntries = performance.getEntriesByType("navigation");
if (perfEntries.length > 0) {
const navigationEntry = perfEntries[0];
const { domContentLoadedEventStart, loadEventStart, fetchStart } =
navigationEntry as PerformanceNavigationTiming;
// DOM 树构建完成后触发 DOMContentLoaded 事件
DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;
// 页面完整的加载时间
loadTime = loadEventStart - fetchStart;
}
}
// 旧版浏览器降级使用 performance.timing
else {
const { fetchStart, domContentLoadedEventStart, loadEventStart } = performance.timing;
DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;
loadTime = loadEventStart - fetchStart;
}
// 1、数据建模存储
const log: TimingLog = {
type: "timing",
DOMContentLoadedTime,
loadTime,
};
// 2、上报数据
tracker.send(log);
});
}
加载时间统计结果大致如下:
PS:页面加载性能详细信息参考资料:PerformanceNavigationTiming
5、性能指标
性能指标,类似算法里面的【时间复杂度】,用来衡量一个网站的响应速度。通过监控和分析性能指标,来改变页面性能,提升用户体验。
常见的性能指标有:
FP, First Paint(首次绘制 - 首次像素绘制)
:包括了任何用户自定义的背景绘制,它是首先将像素绘制到屏幕的时刻;FCP, First Content Paint(首次内容绘制)
:是浏览器将第一个有内容的 DOM 渲染到屏幕的时间,可能是文本、图像、SVG等,这其实就是白屏时间;FMP, First Meaningful Paint(首次有意义内容绘制)
:页面有意义的内容(由我们指定)渲染的时间;LCP, (Largest Contentful Paint)(最大内容渲染)
:LCP 指标代表的是视窗最大可见图片或者文本块
的渲染时间。(以百度首页为例,LCP 对应的元素是百度 Logo);
5.1、FP 和 FCP
如下示例,我们在 0.5 后给 div 设置背景色进行首次像素绘制(FP),1s 后在页面呈现有效内容(FCP)。
html
<body>
<div id="root"></div>
<script>
// 0.5 后进行首次像素绘制
setTimeout(() => {
root.style.backgroundColor = "gray";
root.style.height = "100px";
}, 500);
// 1s 后在页面呈现有效内容
setTimeout(() => {
root.innerHTML = "content";
}, 1000);
</script>
</body>
FP 和 FCP 指标都可以通过 PerformanceObserver API
观察 type: paint
来计算。
ts
// modules/paint.ts
export default function injectPaint() {
if (PerformanceObserver) {
let FP, FCP;
// 1、监控性能指标 FP(First Paint) 和 FCP(First Contentful Paint)
const observerFPAndFCP = new PerformanceObserver(function (entryList) {
const perfEntries = entryList.getEntries();
for (const perfEntry of perfEntries) {
if (perfEntry.name === "first-paint") {
FP = perfEntry;
console.log("首次像素绘制 时间:", FP?.startTime);
} else if (perfEntry.name === "first-contentful-paint") {
FCP = perfEntry;
console.log("首次内容绘制 时间:", FCP?.startTime);
observerFPAndFCP.disconnect(); // 得到 FCP 后,断开观察,不再观察了
}
}
});
// 观察 paint 相关性能指标
observerFPAndFCP.observe({ entryTypes: ["paint"] });
}
}
5.2、FMP
这里我们先认识一个 HTML 属性:
elementtiming
属性用于标记页面中特定元素(如图片、视频、文本块等),以便通过 PerformanceObserver
的 element
类型监控这些元素的加载和渲染时间。
PerformanceObserver
的element
类型主要支持监控以下元素类型:
- 图片(
<img>
) :结合设置elementtiming
属性来监控其渲染时间;- 文本块(
<p>
、<div>
(验证发现<span>
不行)) :这类元素内容是纯文本,结合设置elementtiming
属性来监控其渲染时间;
如下示例,我们在 1.5s 后向页面添加 有意义(属性标识) 的元素。
html
<script>
// 1.5s 后向页面添加 有意义(属性标识) 的元素
setTimeout(() => {
const ele = document.createElement("div");
ele.innerHTML = "meaningful ele.";
ele.setAttribute("elementtiming", "meaningful ele"); // 设置 root 元素为「最有意义的元素」
document.body.appendChild(ele);
}, 1500);
</script>
我们使用 PerformanceObserver API
观察 type: element
来计算 FMP。
ts
// modules/paint.ts
export default function injectPaint() {
if (PerformanceObserver) {
let FP, FCP, FMP;
...
// 2、监控性能指标:FMP(First Meaningful Paint)
const observerFMP = new PerformanceObserver(entryList => {
const perfEntries = entryList.getEntries();
FMP = perfEntries[0];
console.log("首次有意义元素绘制 时间:", FMP?.startTime);
observerFMP.disconnect(); // 断开观察,不再观察了
});
observerFMP.observe({ entryTypes: ["element"] });
}
}
5.3、LCP
LCP 可通过 PerformanceObserver API
观察 type: largest-contentful-paint
来监听统计。
需要注意的是,LCP 可能会在页面加载过程中多次触发,尤其是在最大内容元素发生变化时。
如下示例,由于 div2 的内容大于之前的 div1 内容,LCP 的统计会触发两次:
js
const div1 = document.createElement("div");
div1.innerHTML = "这是一段很长的文本";
document.body.appendChild(div1);
setTimeout(() => {
const div2 = document.createElement("div");
div2.innerHTML = "这是一段很长很长很长的文本";
document.body.appendChild(div2);
}, 500);
那么什么时候停止 LCP 监控呢?我们可以在需要上报性能指标时(比如 load
事件),停止对 LCP 的监控,这时收集到的 LCP 就是此刻页面中最大的内容。
ts
// modules/paint.ts
export default function injectPaint() {
if (PerformanceObserver) {
let FP, FCP, FMP, LCP;
...
// 3、创建性能观察者,观察 LCP
const observerLCP = new PerformanceObserver(entryList => {
const perfEntries = entryList.getEntries();
LCP = perfEntries[0];
console.log("最大内容绘制 时间:", LCP?.startTime, perfEntries);
});
// 观察页面中最大内容的绘制
observerLCP.observe({ entryTypes: ["largest-contentful-paint"] });
// TODO... 在上报性能指标数据的时候,停止观察。
observerLCP.disconnect();
}
}
5.4、上报性能指标
收集到 FP、FCP、FMP、LCP 性能指标以后,便可以上报数据。上报时机比如可以是页面 load 以后等待 3s 进行上报。
js
// modules/paint.ts
export default function injectPaint() {
if (PerformanceObserver) {
let FP: PerformanceEntry | null = null;
let FCP: PerformanceEntry | null = null;
let FMP: PerformanceEntry | null = null;
let LCP: PerformanceEntry | null = null;
...
// 上送性能指标
window.addEventListener("load", () => {
setTimeout(() => {
// 在上报性能指标数据的时候,停止 LCP 的观察。
observerLCP.disconnect();
const log: PaintLog = {
type: "paint",
FP: FP?.startTime, // FP
FCP: FCP?.startTime, // FCP
FMP: FMP?.startTime, // FMP
LCP: LCP?.startTime, // LCP
};
console.log("paint log: ", log);
tracker.send(log);
}, 3000);
});
}
}
6、卡顿监控
在 JS 同步执行复杂的计算、大量的 DOM 操作等工作的时候,主线程会被占用,其他操作(比如用户交互、动画渲染)都会被阻塞。
PerformanceObserver 是一个强大的 API,可以用来监听各种性能事件,包括长任务(longtask)。当主线程上的任务执行时间超过 100 毫秒时(设定一个阈值),可以认为这是一个长任务,可能会导致页面卡顿。
下面我们使用 while 循环模拟一个 200ms 长任务:
html
// examples/longTask.html
<body>
<button id="longTaskBtn">长任务</button>
<script>
function longTask() {
const start = Date.now();
while (Date.now() < 200 + start) {}
}
longTaskBtn.addEventListener("click", longTask);
</script>
</body>
新建 module/longTask.ts
文件,监听 长任务(longtask) 作为卡顿现象进行数据上报。
ts
// module/longTask.ts
import { PerformanceLog } from "../interface";
import getLastEvent from "../utils/getLastEvent";
import getSelector from "../utils/getSelector";
import tracker from "../utils/tracker";
export default function injectLongTask() {
if (PerformanceObserver) {
const observerLongTask = new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
// 执行时长大于 100 ms
if (entry.duration > 100) {
const lastEvent = getLastEvent();
const log: LongTaskLog = {
type: "longTask",
startTime: entry.startTime, // 开始时间
duration: entry.duration, // 持续时间
selector: lastEvent ? getSelector() : "",
eventType: lastEvent?.type,
};
tracker.send(log);
}
});
});
observerLongTask.observe({ entryTypes: ["longtask"] });
}
}
二、重难点实现
1、Source Map 源代码定位
在前端项目中,为了节省构建资源体积,不暴露业务逻辑,都会选择将代码进行混淆和压缩。在优化性能,在提升用户体验的同时,也为异常的处理带来了麻烦。
Source Map 是一个源代码信息文件,里面存储着代码压缩混淆前后的对应关系。我们输入混淆后的行列号,就能够获得对应的原始代码的行列号,结合源代码文件便可定位到真实的报错位置。
但在生产环境下,我们不建议将 Source Map 放到网站上,对于具有一定规模和保密性的项目,这样等于将页面逻辑直接暴露给了网站使用者。
在这种情况下,监控 SDK 收集和上传的错误信息也是混淆和压缩后的,并不利于我们定位异常。那有没有其他办法来定位错误呢?
一般来说 Source Map 的应用都是在监控系统中,开发者构建完应用后,通过插件(例如自定义 webpack plugin)将 Source Map 文件(含源代码)上传至监控平台中 。(例如 Sentry
监控平台也是这样做的)
一旦客户端上报错误后,我们就可以通过 source-map 这个库来还原错误信息在源代码中的位置,方便开发者快速定位线上问题。
2、错误信息聚合
为了避免异常错误列表被大量的重复上报给占满,需要将具有相同特征的错误上报,归类为同一种异常,并且在统计平台只对用户暴露这种聚合后的异常。
一个错误信息的结构如下:
name
: 异常的 Type,例如TypeError, SyntaxError, DOMError
;Message
:异常的相关信息,通常是异常原因,例如a is not defined.
;Stack
异常的上下文堆栈信息,通常为字符串,例如errirClick、HTMLInputElement.onclick
;
经过监控 SDK 收集到的 error log 如下:
错误信息聚合的思路 - 为每个错误生成唯一的标识符(uid):
我们可以使用 name + message + stack
作为聚合依据,生成一个 hash 值
作为这个错误的唯一性的 ID
。
如果后续捕获到的错误生成的 hash 值
与之前某个错误的 hash 值
相同,则认为是重复错误,不再进行数据入口或者是统计错误发生次数 + 1。
3、数据加工和清洗
数据加工和清洗发生在服务端。在对数据进行入库前,进行数据加工和清洗的意义在于:
数据加工
可以将一些异构数据转换为统一的格式,提取出关键指标和有价值的信息,以及扩展一些额外信息(如:在服务端才能获取的IP地址
)数据清洗
可以剔除无效、重复或冗余的数据,减少存储空间的浪费,同时提高数据处理的效率。
4、异常报警
在出现程序执行出错异常后,需要及时反馈通知到开发人员,及时跟进和处理问题。
错误报警可以从两个维度进行:对新增异常的报警 和 对错误指标的数量(达到某个阈值)的报警。
常见的报警的方式:邮件报警、接入办公即时通信应用(自研产品、企业钉钉)。
文末
以上内容是作者结合当前所掌握的知识,整理出来的一套前端监控实现思路,后续也会继续根据应用场景,对本文内容进行扩充。感谢读者的阅读!
参考:
字节前端监控实践