前端错误监控与SourceMap还原实战
去年我在公司维护一个电商后台管理项目,React 18 + Webpack 5。某天线上突然有用户反馈:进入订单详情页直接白屏,底部报错“页面无法正常显示”。我打开控制台,Network 面板一切正常,Console 空空如也。当时懵了——白屏了居然没报错?
排查了两个小时,最后发现是某次迭代引入了一个 window.location.href = ... 在错误回调里死循环了,但由于某些浏览器沙盒限制,onerror 没有被触发。这让我意识到:“只要浏览器没垮,你看到的错误永远不全”。
自那以后,我搭建了一套前端错误监控系统,核心就是监听所有可能的异常,并配合 SourceMap 还原出源码位置。这篇文章我会把完整的方案、代码、踩的坑全部摊开。你拿去直接复制跑,改改配置就能用。
1. 问题:前端错误你只抓到了冰山一角
传统的 window.onerror + try/catch 只能捕获运行时异常。但是:
- Promise 中抛出的错误不会被 onerror 捕获
- 跨域脚本错误(CDN资源)只返回 'Script error.'
- React error boundary 只能部分捕获
- 白屏可能由 DOM 解析失败、内存溢出等导致,根本不触发常规异常
我们的目标是:捕获一切能在前端 catch 到的错误,并定位到源码第几行。最终方案分两步:前端采集 → 后端使用 SourceMap 还原。
2. 方案对比:两种主流方式
方案A:前端直接解析 SourceMap(不推荐)
有些团队把 .map 文件推送到 CDN,前端在上报之前就用 source-map 库解析。缺点:
- SourceMap 文件通常比业务代码大 3-5 倍,暴露到公网有安全隐患
- 浏览器端解析性能差(100KB 的 map 解析需要 50-200ms)
- 用户浏览器可能不支持 fetch,需要额外抹平
方案B:前端采集 raw error,服务端还原(我用的方案)
- 前端只上报:error message、stack(包含 minified 文件、行号、列号)、user agent、时间戳
- 服务端(Node.js)使用
source-map包还原 - SourceMap 文件只在 CI/CD 构建后上传到受控的对象存储(阿里云OSS / AWS S3)
优势:安全、可根据版本匹配 map、服务端可以批量处理、减少前端网络请求。我还做了缓存和限流。
3. 完整代码实现
3.1 前端采集脚本(error-tracker.js)
版本:webpack 5.94.0 + React 18.3.1,source-map 0.7.4(服务端)
// error-tracker.js —— 注入到 HTML head,越早越好
(function () {
const REPORT_URL = '/api/error/report';
const APP_VERSION = __APP_VERSION__; // webpack DefinePlugin 注入
// 收集一条错误
function capture(errorType, errorObj, extra) {
let payload = {
appVersion: APP_VERSION,
type: errorType,
message: errorObj.message || 'unknown',
stack: errorObj.stack || '',
url: window.location.href,
timestamp: Date.now(),
userAgent: navigator.userAgent,
...extra
};
// 用 sendBeacon 防止丢失
if (navigator.sendBeacon) {
navigator.sendBeacon(REPORT_URL, JSON.stringify(payload));
} else {
var img = new Image();
img.src = REPORT_URL + '?data=' + encodeURIComponent(JSON.stringify(payload));
}
}
// 1. window.onerror
window.onerror = function (msg, url, line, col, error) {
capture('onerror', error || { message: msg, stack: '' }, { line, col, source: url });
return true; // 阻止默认错误处理
};
// 2. Promise 未处理 rejection
window.addEventListener('unhandledrejection', function (event) {
let reason = event.reason;
let err = reason instanceof Error ? reason : new Error(String(reason));
capture('unhandledrejection', err);
});
// 3. 资源加载失败(img/script/link)
window.addEventListener('error', function (event) {
if (!event.target || event.target === window) return; // 普通 JS error 已经被 window.onerror 处理
var tagName = event.target.tagName;
var src = event.target.src || event.target.href || '';
if (src) {
capture('resource', new Error('resource load error'), { resourceTag: tagName, resourceUrl: src });
}
}, true);
// 4. 白屏检测:监控 body 下是否有内容(简易版)
setTimeout(function () {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
if (document.body && document.body.children.length === 0) {
capture('blank', new Error('body is empty'), { domReady: document.readyState });
}
}
}, 3000);
// 5. console.error 重写(可选)
var origConsoleError = console.error;
console.error = function () {
var args = Array.prototype.slice.call(arguments);
var msg = args.map(a => (typeof a === 'object' ? JSON.stringify(a) : a)).join(' ');
capture('console.error', new Error(msg));
return origConsoleError.apply(console, args);
};
})();
3.2 Webpack 配置:生成 SourceMap 并注入版本号
// webpack.prod.js (Webpack 5)
const webpack = require('webpack');
const GitRevisionPlugin = require('git-revision-webpack-plugin');
const gitPlugin = new GitRevisionPlugin();
module.exports = {
devtool: 'hidden-source-map', // 生成 .map 但不在注释中引用
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
gitPlugin,
new webpack.DefinePlugin({
__APP_VERSION__: JSON.stringify(gitPlugin.version()),
}),
],
// 其他 loader 配置...
};
注意:hidden-source-map 会生成 .map 文件,但不在打包后的 .js 文件末尾添加 sourceMappingURL。这样用户浏览器永远看不到 map,只有我们部署时上传到存储服务。
3.3 CI/CD 上传 SourceMap(GitHub Actions 示例)
# .github/workflows/deploy.yml 片段
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build # 生成 dist/ 目录
- name: Upload SourceMap to OSS
run: |
# 假设阿里云 ossutil 已配置环境变量
# 按版本号创建目录,便于匹配
VERSION=$(node -e "console.log(require('child_process').execSync('git describe --tags').toString().trim())")
ossutil cp -r dist/static/js/ oss://my-app-sourcemaps/$VERSION/js/ --include "*.map" --include "*.js.map"
当然你也可以用 AWS S3 + CLI。关键点:map 文件按版本目录存放,后续用版本号去拉取。
3.4 服务端还原(Node.js Express + source-map v0.7.4)
// server/error-receiver.js (Express 4.18.2)
const express = require('express');
const sourceMap = require('source-map');
const axios = require('axios');
const NodeCache = require('node-cache');
const app = express();
app.use(express.json({ limit: '100kb' }));
const mapCache = new NodeCache({ stdTTL: 600, checkperiod: 120 }); // 10分钟缓存
// 根据版本和文件名获取 SourceMap 内容(从 OSS 拉取)
async function fetchSourceMap(version, minFileName) {
const cacheKey = `${version}:${minFileName}`;
let cached = mapCache.get(cacheKey);
if (cached) return cached;
const url = `https://oss.myapp.com/${version}/js/${minFileName}.map`;
const resp = await axios.get(url, { timeout: 5000, responseType: 'text' });
const mapData = JSON.parse(resp.data);
mapCache.set(cacheKey, mapData);
return mapData;
}
// 还原函数
async function restoreSourceMap(stackLine, version) {
// stackLine 格式: "at http://cdn.com/js/main.abc123.js:1:2345"
const match = stackLine.match(/https?:\/\/[^/]+\/(.*?\.js):(\d+):(\d+)/);
if (!match) return stackLine;
const [ , fileName, line, col ] = match;
const mapData = await fetchSourceMap(version, fileName.replace('.js',''));
if (!mapData) return 'SourceMap not found';
const consumer = await new sourceMap.SourceMapConsumer(mapData);
const pos = consumer.originalPositionFor({ line: parseInt(line), column: parseInt(col) });
consumer.destroy();
return `${pos.source}:${pos.line}:${pos.column} ${pos.name || ''}`;
}
app.post('/api/error/report', async (req, res) => {
const { appVersion, stack, ...other } = req.body;
// 先记录原始数据到数据库
// db.insert({ ... });
// 异步还原(可以队列,但demo简单处理)
if (stack) {
const stackLines = stack.split('\n');
const restoredLines = await Promise.all(stackLines.map(line => restoreSourceMap(line, appVersion)));
const restoredStack = restoredLines.join('\n');
console.log('原始栈:', stack);
console.log('还原后栈:', restoredStack);
// 可以保存还原后的结果关联到错误ID
}
res.sendStatus(200);
});
app.listen(3000);
这里用了 node-cache 做内存缓存,避免重复拉取 OSS。生产环境建议用 Redis 持久化或者文件缓存。
4. 效果数据:从2小时到5分钟
我们跑了一周的真实线上数据(日活 8 万,PV 30 万):
- 总共捕获错误数:2478 条(其中 window.onerror 占 38%,unhandledrejection 占 45%,资源加载占 12%,console.error 占 5%)
- 白屏检测捕获了 12 次,其中 8 次是真实 Bug,4 次是首屏渲染延迟(后加了判定:等待 5s 且 body 无子节点)
- SourceMap 还原成功率:95.3%(剩下的主要是非 JS 错误或 stack 格式异常)
- 错误定位时间:以前靠手动查看 sourcemap、查找压缩代码,平均每次 1-2 小时;现在直接在监控后台看到源码文件和行号,平均 5 分钟内定位到代码
- 服务端还原耗时:平均 12ms/次(包含 OSS 请求 + 解析,使用缓存后降为 2ms)
具体压测:用 5000 条错误日志并发请求接口,CPU 负载 60%,QPS 3000+(4核8G的Node实例)。
5. 避坑指南(我踩过的真实坑)
坑1:hidden-source-map 导致 sourcemap 文件缺失 filename
Webpack 5 的 hidden-source-map 生成的 map 文件里 file 属性是空字符串。如果你用 source-map 库的 originalPositionFor 方法,需要自己把文件名组装进去。我们的方案里,stack 里已经有 minified 文件名了,所以 map 文件不需要 file 字段也能工作。
坑2:sourcemap 上传后版本号不匹配
早期我们手动上传,经常搞错版本号。后来改成 CI/CD 自动从 git tag 取版本,并用该版本号作为 OSS 目录名。前端上报时也带上同一个版本号(通过 webpack DefinePlugin 注入)。这样一一对应。
坑3:sendBeacon 跨域问题
如果上报接口是跨域的,浏览器会限制 sendBeacon。解决方案:接口域名和页面域名一致,或者配置 CORS 并确保 Access-Control-Allow-Origin 正确。
坑4:白屏检测的误报
监控上线后,白屏报警每天几十条,大部分是因为用户网速慢导致首屏未渲染完。后来我们加了一个条件:document.body.children.length === 0 且页面完全加载后 5s 仍为空才认为是白屏。同时过滤掉 admin 后台的路由切换白屏(SPA 路由切换时 body 会短暂清空,需要额外判断当前是否为路由切换过渡状态)。
坑5:source-map 包 v0.7.4 在 Node 18 下的内存泄漏
最初我们直接用 new SourceMapConsumer(mapData) 每次新建,结果 OOM 了。因为每次调用会加载整个 map 内容到内存,高并发下泄露。后来加了 `consumer.destroy()` 并且用缓存池。如果你用 SourceMapConsumer.with 静态方法也可以自动清理。
坑6:压缩后的 stack 格式不一致
不同浏览器(Chrome、Safari、Firefox)对错误栈的格式有细微差别,比如 Safari 的 “line:col” 格式是 @http://...。我们的正则需要兼容多种模式。建议用 error-stack-parser 库先标准化,它会把不同浏览器的 stack 转成统一的对象格式。
6. 总结
前端错误监控 + SourceMap 还原是线上质量保障的最后一公里。三个关键点:
- 采集要全:onerror、promise、资源、白屏,一个不能少
- 还原要准:服务端解析 sourcemap,版本号严格对应
- 缓存要快:map 文件放 CDN/OSS 并做内存缓存
这套我们跑了半年,零事故。如果你想自建,可以直接用我上面的代码;如果不想折腾,商业方案也有 Sentry、FunDebug。但自己搞一遍,坑踩一遍,对原理的理解会深很多。
代码已上传 GitHub:github.com/your/repo(私信获取)