一个完整的前端监控体系通常围绕以下几个核心维度构建:
| 监控维度 | 核心目标 | 关键指标/关注点 |
|---|---|---|
| 错误监控 | 捕获并分析应用中的各类异常,防止页面白屏或功能失效 | JavaScript执行错误、资源加载失败、异步请求异常等 |
| 性能监控 | 衡量页面加载与渲染效率,优化用户体验 | 首屏时间、可交互时间、资源加载耗时等 |
| 用户行为监控 | 追踪用户交互路径,分析产品使用情况,辅助优化产品设计 | 页面浏览量(PV)/独立访客(UV)、点击流、用户操作流程 |
错误监控分类总览
| 错误类型 | 监控方式 | 关键信息 | 特殊处理 |
|---|---|---|---|
| JS运行时错误 | window.onerror | 错误信息、文件、行列号 | 跨域脚本需CORS |
| 资源加载错误 | 事件捕获 | 资源URL、标签类型 | 需事件捕获阶段监听 |
| Promise拒绝错误 | unhandledrejection | Promise拒绝原因 | 需主动处理避免控制台警告 |
| 跨域脚本错误 | 错误监听+CORS | 基本错误信息 | 需服务器配置CORS头 |
| 框架特定错误 | 错误边界/错误钩子 | 组件栈、状态快照 | 框架特定API |
| 异步代码错误 | 错误包装/Async监听 | 异步上下文信息 | 需特殊错误捕获机制 |
| 控制台错误 | console重写 | 开发者主动记录 | 需保持原始功能 |
1. JS运行时错误
通过监听error事件。
window.addEventListener('error', (event) => {
this.handleRuntimeError(event);
}, true); // 使用捕获阶段确保捕获所有错误
}
2. 资源加载错误
通过监听error事件。判断错误的目标的tagName,如果命中type如img等,则判断为资源加载错误
window.addEventListener('error', (event) => {
// 判断是否为资源加载错误
if (this.isResourceElement(target)) {
this.handleResourceError(event, target);
}
}, true); // 使用捕获阶段确保捕获所有错误
}
isResourceElement(element) {
if (!element || !element.tagName) return false;
const tagName = element.tagName.toLowerCase();
const resourceTypes = {
'img': 'image',
'script': 'script',
'link': 'css',
'audio': 'audio',
'video': 'video'
};
return resourceTypes[tagName] &&
this.options.monitorTypes.includes(resourceTypes[tagName]);
}
3. Promise拒绝错误监控 (异步无法被error捕获)
通过unhandledrejection事件
window.addEventListener('unhandledrejection', (event) => {
this.handlePromiseRejection(event);
// 可选:阻止浏览器默认的未处理拒绝警告
if (this.options.preventDefaultWarning) {
event.preventDefault();
}
});
4. 跨域脚本错误监控
跨域脚本错误的典型特征
window.addEventListener('error', (event) => {
// 检测跨域脚本错误特征
if (this.isCrossOriginError(event)) {
this.handleCrossOriginError(event);
}
});
isCrossOriginError(event) {
// 跨域脚本错误的典型特征
return event.message === 'Script error.' ||
event.message === 'Script error' ||
(!event.filename && event.lineno === 0 && event.colno === 0);
}
5. 框架特定错误监控
React错误边界示例
ErrorBoundary
// 有componentDidCatch, 捕获错误
vue
vue2 Vue.config.errorHandler捕获。 vue3 app.config.errorHandler捕获
// Vue 2.x 错误处理
const Vue2ErrorHandler = {
install(Vue, options = {}) {
const reportUrl = options.reportUrl || '/api/vue-error';
// 全局错误处理器
Vue.config.errorHandler = (err, vm, info) => {
this.handleVueError(err, vm, info, reportUrl);
};
// 全局警告处理器(可选)
Vue.config.warnHandler = (msg, vm, trace) => {
console.warn('Vue警告:', msg, trace);
};
},
handleVueError(err, vm, info, reportUrl) {
const errorInfo = {
type: 'vue_error',
timestamp: Date.now(),
error: {
name: err.name,
message: err.message,
stack: err.stack
},
vueInfo: {
hook: info, // 生命周期钩子名称
component: vm?.$options?.name || 'AnonymousComponent',
file: vm?.$options?.__file || 'unknown'
},
url: window.location.href,
userAgent: navigator.userAgent
};
this.reportError(errorInfo, reportUrl);
},
reportError(errorInfo, reportUrl) {
if (navigator.sendBeacon) {
const data = new Blob([JSON.stringify(errorInfo)], {
type: 'application/json'
});
navigator.sendBeacon(reportUrl, data);
}
}
};
// Vue 3.x 错误处理
const Vue3ErrorHandler = {
install(app, options = {}) {
const reportUrl = options.reportUrl || '/api/vue-error';
app.config.errorHandler = (err, vm, info) => {
this.handleVueError(err, vm, info, reportUrl);
};
},
handleVueError(err, instance, info, reportUrl) {
const errorInfo = {
type: 'vue_error',
timestamp: Date.now(),
error: {
name: err.name,
message: err.message,
stack: err.stack
},
vueInfo: {
hook: info,
component: instance?.type?.name || 'AnonymousComponent'
},
url: window.location.href,
userAgent: navigator.userAgent
};
this.reportError(errorInfo, reportUrl);
},
reportError(errorInfo, reportUrl) {
if (navigator.sendBeacon) {
const data = new Blob([JSON.stringify(errorInfo)], {
type: 'application/json'
});
navigator.sendBeacon(reportUrl, data);
}
}
};
6. 异步代码错误监控
重写,包装异步API
a. 定时器包装(setTimeout/setInterval)
// 原始调用
setTimeout(() => {
throw new Error('异步错误'); // 这个错误会直接导致脚本崩溃
}, 1000);
// 包装后的效果
const originalSetTimeout = window.setTimeout;
window.setTimeout = function(callback, delay, ...args) {
// 包装回调函数
const wrappedCallback = function() {
try {
return callback.apply(this, arguments); // 在try-catch中执行
} catch (error) {
// 捕获错误并上报
reportError(error, { context: 'setTimeout' });
throw error; // 重新抛出,保持原有行为
}
};
return originalSetTimeout(wrappedCallback, delay, ...args);
};
b. 事件监听器包装
生效流程:
// 原始事件监听
button.addEventListener('click', () => {
throw new Error('点击事件错误');
});
// 包装后的效果
const originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function(type, listener, options) {
const wrappedListener = function(event) {
try {
return listener.call(this, event);
} catch (error) {
reportError(error, { context: `event:${type}` });
throw error;
}
};
return originalAddEventListener.call(this, type, wrappedListener, options);
};
c. 异步函数包装
生效流程:
// 原始异步函数
async function fetchData() {
const response = await fetch('/api');
return response.json(); // 可能抛出错误
}
// 包装后的效果
function wrapAsyncFunction(asyncFunc, name) {
return async function(...args) {
try {
return await asyncFunc.apply(this, args);
} catch (error) {
reportError(error, { context: `async:${name}` });
throw error;
}
};
}
// 使用包装函数
const safeFetchData = wrapAsyncFunction(fetchData, 'fetchData');
7. 控制台错误监控
包装console的方法。
wrapConsoleMethod(method) { //method 比如error、warm
// 保存原始方法
this.originalConsole[method] = console[method];
const self = this;
console[method] = function(...args) {
// 调用原始方法
self.originalConsole[method].apply(console, args);
// 监控错误信息
self.handleConsoleLog(method, args);
};
}
为什么使用捕获阶段捕获错误?
资源错误不会冒泡
- 资源加载错误是直接在目标元素上触发的
- 这类错误默认不会冒泡到父元素
- 只有在捕获阶段才能从window向下传播时拦截到
确保最早拦截错误
执行顺序:
- 捕获阶段:window → document → ... → 目标元素
- 目标阶段:在目标元素上触发
- 冒泡阶段:目标元素 → ... → window
错误上报的优化
1 错误信息标准化
错误类型、错误信息、错误堆栈、当前页面url、浏览器信息、时间戳、用户信息
2 上报策略优化
- 批量上报:合并多个错误减少请求数
- 智能防抖:延迟上报避免频繁请求
- 优先级队列:重要错误优先上报
- 自适应采样:根据错误频率动态调整采样率
- 指数退避重试:失败时智能重试
- 本地存储降级:网络异常时本地缓存
- 标签页不活跃的时候发送
3 页面关闭时上传
使用 sendBeacon API(推荐) navigator.sendBeacon。
navigator.sendBeacon()是浏览器提供的一个专门用于在页面卸载时可靠发送数据的API。
降级使用:图片打点降级方案。
img.src 拼接数据。 优点: 兼容性好、不会阻塞、跨域支持 缺点: 数据长度有限(2000)
class PageUnloadReporter {
constructor() {
this.pendingReports = [];
this.initUnloadHandler();
}
initUnloadHandler() {
// 1. beforeunload 事件(最后机会)
window.addEventListener('beforeunload', () => {
this.flushBeforeUnload();
});
// 2. pagehide 事件(更可靠)
window.addEventListener('pagehide', (event) => {
if (event.persisted) {
// 页面被缓存(如iOS应用切换),稍后上报
this.scheduleBackgroundReport();
} else {
// 页面完全卸载,立即上报
this.flushOnUnload();
}
});
// 3. visibilitychange 事件(页面隐藏时)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flushOnHidden();
}
});
}
// 使用 sendBeacon 上报
sendViaBeacon(data) {
if (!navigator.sendBeacon) {
return this.sendViaImage(data); // 降级方案
}
try {
const blob = new Blob([JSON.stringify(data)], {
type: 'application/json'
});
return navigator.sendBeacon(this.reportUrl, blob);
} catch (error) {
console.warn('sendBeacon失败,使用降级方案:', error);
return this.sendViaImage(data);
}
}
// 图片打点降级方案
sendViaImage(data) {
return new Promise((resolve) => {
const img = new Image();
const params = new URLSearchParams();
// 简化数据,适应URL长度限制
const simplifiedData = {
t: data.type?.substr(0, 20),
m: data.message?.substr(0, 100),
ts: Date.now()
};
params.append('data', JSON.stringify(simplifiedData));
img.onload = img.onerror = () => {
resolve(true); // 无论成功失败,都认为已发送
};
img.src = `${this.reportUrl}?${params.toString()}`;
});
}
}
问题思考
1 为什么不能在卸载页面用接口请求?
因为异步请求会被直接取消。 同步请求会阻塞用户体验
2 为什么sendBeacon请求不会被取消
// 普通请求:绑定到页面生命周期
普通HTTP请求 → 页面卸载 → 请求被取消
// sendBeacon请求:独立于页面生命周期
sendBeacon请求 → 页面卸载 → 浏览器进程继续处理请求
3 为什么降级的图片请求不会被取消
get请求简单。 图片请求无阻塞性。浏览器策略会优先尝试完成请求