| 错误类型 | 捕获方式 | 备注 |
|---|---|---|
| JS 运行时错误 | window.onerror |
同步代码、未捕获的异常 |
| Promise 拒绝 | unhandledrejection |
async/await 未 catch |
| 资源加载失败 |
error 事件(捕获阶段) |
图片、脚本、样式加载失败 |
| 语法错误 |
error 事件 + try-catch |
在捕获阶段可捕获 |
| 接口异常 | 拦截 XMLHttpRequest / fetch
|
需额外封装 |
window.onerror
window.onerror = function(message, source, lineno, colno, error) {
const report = {
type: 'js_error',
message,
source,
lineno,
colno,
stack: error?.stack || '',
userAgent: navigator.userAgent,
url: location.href,
timestamp: Date.now(),
};
sendReport(report);
return true; // 阻止默认行为
};
unhandledrejection
window.addEventListener('unhandledrejection', (event) => {
const report = {
type: 'promise_rejection',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || '',
userAgent: navigator.userAgent,
url: location.href,
timestamp: Date.now(),
};
sendReport(report);
});
error 事件(捕获阶段)window.addEventListener('error', (event) => {
const target = event.target;
if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK')) {
const report = {
type: 'resource_error',
tag: target.tagName,
src: target.src || target.href || '',
userAgent: navigator.userAgent,
url: location.href,
timestamp: Date.now(),
};
sendReport(report);
}
}, true); // 必须使用捕获阶段
const originalFetch = window.fetch;
window.fetch = function(...args) {
return originalFetch.apply(this, args).catch((error) => {
const report = {
type: 'fetch_error',
url: args[0],
method: args[1]?.method || 'GET',
message: error.message,
stack: error.stack,
timestamp: Date.now(),
};
sendReport(report);
throw error; // 继续抛出,不影响业务逻辑
});
};
function sendReport(data) {
// 批量上报:累积到一定数量或时间再发送
const queue = [];
queue.push(data);
if (queue.length >= 10) {
flush(queue);
}
}
function flush(queue) {
const data = queue.splice(0, queue.length);
const body = JSON.stringify(data);
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/monitor', body);
} else {
// 降级:使用 fetch(keepalive)
fetch('/api/monitor', {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {});
}
}
class FrontendMonitor {
constructor(options) {
this.endpoint = options.endpoint || '/api/monitor';
this.queue = [];
this.maxQueueSize = options.maxQueueSize || 10;
this.flushInterval = options.flushInterval || 5000;
this.appId = options.appId || 'default';
this.enabled = options.enabled !== false;
if (this.enabled) {
this.init();
}
}
init() {
this.initJSMonitor();
this.initPromiseMonitor();
this.initResourceMonitor();
this.initFetchMonitor();
this.setupFlushTimer();
this.handleBeforeUnload();
}
initJSMonitor() {
window.onerror = (message, source, lineno, colno, error) => {
this.report({
type: 'js_error',
message,
source,
lineno,
colno,
stack: error?.stack || '',
appId: this.appId,
});
return true;
};
}
initPromiseMonitor() {
window.addEventListener('unhandledrejection', (event) => {
this.report({
type: 'promise_rejection',
message: event.reason?.message || String(event.reason),
stack: event.reason?.stack || '',
appId: this.appId,
});
});
}
initResourceMonitor() {
window.addEventListener('error', (event) => {
const target = event.target;
if (target && ['IMG', 'SCRIPT', 'LINK'].includes(target.tagName)) {
this.report({
type: 'resource_error',
tag: target.tagName,
src: target.src || target.href || '',
appId: this.appId,
});
}
}, true);
}
initFetchMonitor() {
const originalFetch = window.fetch;
window.fetch = (...args) => {
return originalFetch.apply(this, args).catch((error) => {
this.report({
type: 'fetch_error',
url: args[0],
method: args[1]?.method || 'GET',
message: error.message,
appId: this.appId,
});
throw error;
});
};
}
report(data) {
const payload = {
...data,
userAgent: navigator.userAgent,
url: location.href,
timestamp: Date.now(),
};
this.queue.push(payload);
if (this.queue.length >= this.maxQueueSize) {
this.flush();
}
}
flush() {
if (this.queue.length === 0) return;
const data = this.queue.splice(0, this.queue.length);
const body = JSON.stringify(data);
if (navigator.sendBeacon) {
navigator.sendBeacon(this.endpoint, body);
} else {
fetch(this.endpoint, {
method: 'POST',
body,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}).catch(() => {});
}
}
setupFlushTimer() {
setInterval(() => this.flush(), this.flushInterval);
}
handleBeforeUnload() {
window.addEventListener('beforeunload', () => this.flush());
}
}
// 使用方式
const monitor = new FrontendMonitor({
endpoint: 'https://your-api.com/monitor',
appId: 'your-app-id',
maxQueueSize: 10,
flushInterval: 5000,
});
线上代码经过压缩混淆,堆栈信息无法直接定位。需要上传 Source Map 并在服务端还原:
.map 文件,上传到内部服务器(不要上传到 CDN)source-map 库还原// 服务端 Node.js
import { SourceMapConsumer } from 'source-map';
async function parseStack(stack, mapFile) {
const consumer = await new SourceMapConsumer(mapFile);
const parsed = stack.map(frame => {
const original = consumer.originalPositionFor({
line: frame.line,
column: frame.column,
});
return original;
});
return parsed;
}