前端异常监控:从 window.onerror 到完整的错误追踪方案

15 阅读1分钟

前端异常监控:从 window.onerror 到完整的错误追踪方案

为什么用户已经遇到“白屏”“按钮无响应”,而你的监控系统却毫无记录?

为什么控制台没有报错,但页面就是无法正常工作?

很可能不是没有错误,而是你的监控根本没有覆盖到这些场景。

在前端领域,异常并不只有一种形式,而捕获方式也完全不同。如果只依赖 window.onerror,你实际上只看到了问题的一个切面。

为什么你以为的"已经监控了"其实是裸奔

大部分项目的异常监控起点都差不多:在 main.js 里加一个 window.onerror,觉得万事大吉。

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 只覆盖了四分之一的场景,这不是裸奔是什么?

一个真实的漏网案例

当时有一个数据导出功能,代码大概长这样:

async function handleExport() {
  const data = await fetchReportData(filters) // 接口偶尔超时
  const blob = generateExcel(data)            // data 为 undefined 时直接炸
  downloadFile(blob, 'report.xlsx')
}

接口超时的时候 fetchReportData reject 了,但外面没有 try/catchwindow.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 秒发一批,减少请求数。

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. 字符串,没有堆栈、没有行列号。解决方案需要两步配合,缺一不可:

<script src="https://cdn.example.com/app.js" crossorigin="anonymous"></script>

CDN 服务器响应头也要加上 Access-Control-Allow-Origin: *。我们之前只加了 crossorigin 属性没配 CDN 的响应头,折腾了好几天才定位到原因。

错误风暴

有一次某个接口挂了,前端一个轮询逻辑每 500ms 调一次这个接口,每次都报错。1 分钟内产生了上万条错误上报,不仅监控服务扛不住,用户的浏览器也因为频繁的网络请求变卡了。这件事之后我们加了熔断机制:1 分钟内超过 50 个错误就触发熔断,上报一条特殊的"熔断触发"事件让后台知道数据不完整,5 分钟后自动恢复。

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 记录,绝对不能再进入上报逻辑:

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 的配置如下:

// 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 流水线里用一个简单的上传脚本代替插件:

# 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 还原、加面包屑、加采样策略,一步一步来。先搭起来再迭代,比事后补救强一万倍。