前端异常监控:从 window.onerror 到完整的错误追踪方案
为什么用户已经遇到"白屏""按钮无响应",而你的监控系统却毫无记录?
为什么控制台没有报错,但页面就是无法正常工作?
很可能不是没有错误,而是你的监控根本没有覆盖到这些场景。
在前端领域,异常并不只有一种形式,而捕获方式也完全不同。如果只依赖 window.onerror,你实际上只看到了问题的一个切面。
为什么你以为的"已经监控了"其实是裸奔
大部分项目的异常监控起点都差不多:在 main.js 里加一个 window.onerror,觉得万事大吉。
js
window.onerror = function (msg, url, line, col, error) {
console.log('捕获到错误:', msg)
}
前端异常的四大类型
线上遇到的异常大致分四类,每一类的捕获方式都不一样:
第一类:JS 运行时错误。 变量未定义、类型错误、语法错误。window.onerror 能搞定大部分,但有个前提------跨域脚本的错误只会给你一个 Script error.,什么有用信息都没有。
第二类:Promise 异常。 async/await 没包 try/catch、.then() 链里抛的错。这类错误 window.onerror 完全无感知,只会触发 unhandledrejection 事件。我们重构后白屏的元凶就在这里------迁移到 Vue 3 Composition API 后,大量逻辑变成了 async 函数,但全局的 rejection 处理没有人加。
第三类:资源加载错误。 图片 404、CDN 上的 JS 文件挂了、CSS 加载失败。这类错误不会冒泡到 window.onerror,只能通过 window.addEventListener('error', ...) 在捕获阶段拦截。
第四类:框架层错误。 Vue 的组件渲染异常、React 的生命周期崩溃。框架通常有自己的错误边界机制,比如 Vue 的 app.config.errorHandler 和 React 的 ErrorBoundary,不走原生的 onerror。
一行 window.onerror 只覆盖了四分之一的场景,这不是裸奔是什么?
一个真实的漏网案例
当时有一个数据导出功能,代码大概长这样:
js
async function handleExport() {
const data = await fetchReportData(filters) // 接口偶尔超时
const blob = generateExcel(data) // data 为 undefined 时直接炸
downloadFile(blob, 'report.xlsx')
}
接口超时的时候 fetchReportData reject 了,但外面没有 try/catch。window.onerror 一脸无辜------它确实没收到任何通知。用户点了导出按钮,什么都没发生,工单就来了。如果项目初期就把 unhandledrejection 加上,至少能在监控后台看到这个错误,而不是等用户来报。
数据上报策略:别让监控本身成为性能瓶颈
上报方式的选择
常见的上报方式有四种,各有取舍:
- XMLHttpRequest 最传统,但页面卸载时请求会被取消,
beforeunload里的错误容易丢失。 - Image beacon(1x1 像素图片) 简单可靠不受跨域限制,但只能 GET,URL 长度有限制,复杂的错误信息塞不下。
- navigator.sendBeacon 异步非阻塞,页面卸载时也能保证发送,支持 POST------推荐作为首选方案。
- fetch + keepalive 和 sendBeacon 类似,但 API 更灵活,可以设置自定义 header。
我们最终用的是 sendBeacon 优先、fetch + keepalive 兜底、Image beacon 降级的方案。因为 sendBeacon 在极少数浏览器环境下会失败(比如某些 WebView),需要一个降级链路。
采样和聚合
线上日活几十万的项目,如果每个错误都实时上报,监控服务器先扛不住。我们踩过这个坑------上线第一天,错误上报的 QPS 把监控服务的日志盘打满了。采样策略分三层:相同错误 10 秒内去重只报一次;高频错误(超过 100 次的)只采样 10%;已知的第三方脚本错误直接忽略。在此基础上再做批量上报------攒 3 秒发一批,减少请求数。
js
const reportQueue = []
let timer = null
function queueReport(errorData) {
reportQueue.push(errorData)
if (!timer) {
timer = setTimeout(() => {
if (reportQueue.length > 0) {
navigator.sendBeacon('/api/monitor/report', JSON.stringify(reportQueue))
reportQueue.length = 0
}
timer = null
}, 3000)
}
}
window.addEventListener('beforeunload', () => {
if (reportQueue.length > 0) {
navigator.sendBeacon('/api/monitor/report', JSON.stringify(reportQueue))
reportQueue.length = 0
}
})
批量上报策略上线后,监控服务的请求量降了 80%。beforeunload 里的兜底发送也很关键------不加的话,用户 3 秒内关掉页面,攒着的错误就全丢了。
踩坑清单和边界情况
做了三个月的错误监控,踩的坑比写的业务代码还多。挑几个最典型的聊聊:
Script error. 跨域问题
CDN 上的 JS 文件如果和页面不同源,window.onerror 只能拿到一个 Script error. 字符串,没有堆栈、没有行列号。解决方案需要两步配合,缺一不可:
html
<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>
CDN 服务器响应头也要加上 Access-Control-Allow-Origin: *。我们之前只加了 crossorigin 属性没配 CDN 的响应头,折腾了好几天才定位到原因。
错误风暴
有一次某个接口挂了,前端一个轮询逻辑每 500ms 调一次这个接口,每次都报错。1 分钟内产生了上万条错误上报,不仅监控服务扛不住,用户的浏览器也因为频繁的网络请求变卡了。这件事之后我们加了熔断机制:1 分钟内超过 50 个错误就触发熔断,上报一条特殊的"熔断触发"事件让后台知道数据不完整,5 分钟后自动恢复。
js
let errorCount = 0
let circuitBreakerOpen = false
// 滑动窗口:每分钟重置一次错误计数,避免正常低频错误长期累加触发熔断
setInterval(() => {
if (!circuitBreakerOpen) {
errorCount = 0
}
}, 60 * 1000)
function reportWithCircuitBreaker(errorData) {
if (circuitBreakerOpen) return
errorCount++
if (errorCount > 50) {
circuitBreakerOpen = true
navigator.sendBeacon('/api/monitor/report', JSON.stringify({
type: 'circuit_breaker',
message: `Error storm detected: ${errorCount} errors in 1 min`,
}))
setTimeout(() => {
circuitBreakerOpen = false
errorCount = 0
}, 5 * 60 * 1000)
}
queueReport(errorData)
}
监控代码本身出错
这个最尴尬。有一次我们在格式化错误堆栈时调用了一个未做空值判断的方法,监控模块自己抛了异常,这个异常又被全局的 onerror 捕获后送回监控模块处理,形成了死循环,直接把用户浏览器标签页卡死了。
所以监控代码内部一定要有自己的 try/catch,而且要和业务错误上报走不同的路径。具体做法是给监控模块的每个核心函数都包一层防护,出了错只用 console.warn 记录,绝对不能再进入上报逻辑:
js
function safeExecute(fn, fallback) {
try {
return fn()
} catch (e) {
// 监控内部错误走独立路径,仅 console 输出,不进入上报队列
console.warn('[Monitor Internal Error]', e)
// 如果需要感知监控自身的健康状态,可以走一个独立的轻量上报端点
try {
navigator.sendBeacon('/api/monitor/self-check', JSON.stringify({
type: 'monitor_internal_error',
message: e.message,
timestamp: Date.now(),
}))
} catch (_) {
// 兜底上报也失败了,彻底放弃,不能再套娃
}
return fallback
}
}
// 使用示例:在错误采集入口包一层
window.onerror = function (msg, url, line, col, error) {
safeExecute(() => {
const formatted = formatError(error) // 这一步可能出错
reportWithCircuitBreaker(formatted)
}, undefined)
}
关键原则是隔离 :监控代码的异常和业务异常必须走两条完全独立的通道。我们后来还加了一个计数器,如果 safeExecute 在 1 分钟内连续触发超过 5 次内部错误,就自动禁用整个监控模块并上报一条降级通知,避免有缺陷的监控代码持续影响用户体验。
Source Map 还原:让线上堆栈变得可读
线上代码都是压缩混淆过的,捕获到的错误堆栈类似 a.js:1:28432,根本没法定位问题。Source Map 还原是让监控体系真正可用的关键一环。
核心思路是:构建时生成 Source Map 文件并上传到监控服务端,线上收到错误后在服务端做堆栈还原,绝对不要把 Source Map 部署到生产环境,否则等于把源码公开了。
Webpack 的配置如下:
js
// webpack.prod.js
const { sentryWebpackPlugin } = require('@sentry/webpack-plugin')
module.exports = {
devtool: 'hidden-source-map', // 生成 .map 文件但不在 bundle 中引用
plugins: [
// 如果用自建服务,可以替换为自定义上传插件
sentryWebpackPlugin({
org: 'your-org',
project: 'your-project',
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
assets: './dist/**', // 上传 dist 目录下的所有 .map 文件
filesToDeleteAfterUpload: './dist/**/*.map', // 上传后删除本地 .map,防止部署到线上
},
release: {
name: process.env.GIT_COMMIT_SHA, // 用 commit hash 作为 release 标识
},
}),
],
}
如果是自建监控服务而不用 Sentry,可以在 CI/CD 流水线里用一个简单的上传脚本代替插件:
bash
# CI 流水线中,构建完成后上传 Source Map
for file in dist/js/*.map; do
curl -X POST "${MONITOR_API}/sourcemap/upload" \
-F "file=@${file}" \
-F "release=${GIT_COMMIT_SHA}" \
-H "Authorization: Bearer ${UPLOAD_TOKEN}"
done
# 上传完成后删除 .map 文件,确保不会被部署
rm -f dist/js/*.map
服务端收到错误上报后,根据错误信息中的文件名和 release 版本号匹配对应的 Source Map 文件,用 source-map 这个 npm 包做位置还原,就能把 a.js:1:28432 还原成 src/views/Dashboard.vue:142:8,直接定位到源码行。
三个月自建的效果和教训
整个监控体系上线后,和之前"裸奔"状态对比:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 错误发现方式 | 等用户提工单 | 自动告警,平均 2 分钟内触达 |
| 问题定位耗时 | 平均 4 小时 | 平均 30 分钟 |
| 错误覆盖率 | 仅 JS 运行时错误(约 25%) | 四类异常全覆盖 |
| 周均客服工单数 | 30+ | 降到 5 个以内 |
| 告警响应率 | 无告警 | 85%(优化告警策略后) |
最大的教训是:不要等出了问题才想起做监控。 哪怕项目初期只花半天时间把四类异常的捕获加上、配一个最简单的告警,也比事后手忙脚乱地补好得多。监控应该是项目脚手架的一部分,和 ESLint、CI 流水线一样,从第一天就在。
第二个教训是监控的目的不是收集数据,而是缩短从"用户遇到问题"到"开发定位问题"的时间。我们前期过于关注"捕获率",堆了大量的上报数据,但告警规则没配好、Source Map 还原不稳定、错误列表没有按影响面排序。结果监控后台每天几千条错误,团队看都不看。后来花了两周重新梳理告警策略------只对新增错误和影响超过 100 个用户的错误发告警,响应率从 10% 升到了 85%。
前端异常监控的本质就是一条信息管道:采集、传输、存储、分析、行动。任何一个环节断了,整条链路就废了。从 window.onerror 到完整的错误追踪方案,技术上并没有多复杂,复杂的是把每个环节都做到可靠,而且不给业务添乱。
如果你的项目现在还只有一行 window.onerror,不用一步到位。先把 unhandledrejection 加上,五分钟搞定,能多覆盖 40% 的错误。然后加 Source Map 还原、加面包屑、加采样策略,一步一步来。先搭起来再迭代,比事后补救强一万倍。