前端错误监控生产级方案:从 onerror 捕获到 Source Map 精准还原
线上一报错,控制台却只有一行压缩后的 app.4f3a.js:1:1234?这种"能看见错误、却定位不到代码"的体验,几乎每个前端都经历过。
本文带你从 0 搭一套可落地的错误监控链路:浏览器端兜底捕获 → 无阻塞上报 → Node 服务接收 → Source Map 把压缩报错还原成源码行号。所有代码均可直接复制使用。
一、捕获:把错误"接住"
全局错误主要有三类:JS 运行时错误 、未处理的 Promise 拒绝 、资源加载失败(图片/脚本 404)。
js
// error-capture.js
const REPORT_URL = '/api/error';
function report(payload) {
const body = JSON.stringify(payload);
// sendBeacon 不阻塞主线程,页面卸载时也能发出
if (navigator.sendBeacon) {
navigator.sendBeacon(REPORT_URL, new Blob([body], { type: 'application/json' }));
} else {
fetch(REPORT_URL, { method: 'POST', body, keepalive: true });
}
}
// 1. JS 运行时错误 + 资源加载错误(需 useCapture=true)
window.addEventListener('error', (e) => {
// 资源错误没有 error 对象,信息挂在 target 上
if (e.target && (e.target.src || e.target.href)) {
report({ type: 'resource', tag: e.target.tagName, url: e.target.src || e.target.href });
return;
}
report({
type: 'js',
message: e.message,
stack: e.error?.stack,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
});
}, true);
// 2. Promise 未捕获的 rejection
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason;
report({
type: 'promise',
message: reason?.message || String(reason),
stack: reason?.stack,
});
});
注意:
error事件必须在捕获阶段 (useCapture=true)监听,才能拿到<img>、<script>这类资源的加载失败------它们的错误不会冒泡。
二、上报:别让上报本身拖慢页面
用 navigator.sendBeacon 是最稳的做法:它在浏览器空闲时异步发送,不抢主线程,且在 beforeunload、页面跳转时也能成功送达。降级到 fetch(..., { keepalive: true }) 是同样思路的兜底。
建议额外采集这些上下文,排查时非常有用:
js
function enrich() {
return {
ua: navigator.userAgent,
url: location.href,
ts: Date.now(),
uid: localStorage.getItem('uid') || 'anonymous',
};
}
// report({ ...enrich(), type: 'js', message: e.message, stack: e.error?.stack });
三、接收:一个最小 Node 服务
js
// server.js
import express from 'express';
import { mkdirSync, existsSync, readFile } from 'node:fs';
import { join } from 'node:path';
import { SourceMapConsumer } from 'source-map';
const app = express();
app.use(express.json({ limit: '1mb' }));
const MAP_DIR = join(process.cwd(), 'sourcemaps');
mkdirSync(MAP_DIR, { recursive: true });
// 接收前端上报
app.post('/api/error', (req, res) => {
console.log('[ERROR]', new Date().toISOString(), req.body);
res.sendStatus(204);
});
// 接收构建产物生成的 sourcemap(生产环境务必加鉴权/内网)
app.post('/api/sourcemap', (req, res) => {
const { file, content } = req.body;
// 写入 MAP_DIR,供后续还原使用
import('node:fs/promises').then(({ writeFile }) =>
writeFile(join(MAP_DIR, file), content)
);
res.sendStatus(204);
});
// 还原接口:压缩行列号 → 源码位置
app.post('/api/resolve', async (req, res) => {
const { file, line, column } = req.body; // 如 app.4f3a.js
const mapPath = join(MAP_DIR, file + '.map');
if (!existsSync(mapPath)) return res.json(req.body);
const raw = await readFile(mapPath, 'utf8');
const consumer = await new SourceMapConsumer(raw);
// Source Map 列号从 0 开始,错误事件给的是 1-based,需 -1
const original = consumer.originalPositionFor({ line, column: column - 1 });
consumer.destroy();
res.json({ ...req.body, original });
});
app.listen(3000, () => console.log('monitor server on :3000'));
四、还原:让 app.4f3a.js:1:1234 变回 src/utils/request.ts:42
关键点就两个:构建时生成 Source Map ,以及把它安全地上传给监控服务。
js
// vite.config.js
export default {
build: {
sourcemap: true, // 产出 .map 文件
sourcemapExcludeSources: false,
},
};
构建后把 .map 推到服务端(CI 里跑,不要打进用户包):
js
// upload-sourcemap.mjs
import { glob } from 'glob';
import { readFile } from 'node:fs/promises';
const files = await glob('dist/assets/**/*.map');
for (const f of files) {
await fetch('http://localhost:3000/api/sourcemap', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
file: f.split('/').pop(),
content: await readFile(f, 'utf8'),
}),
});
console.log('uploaded', f);
}
之后只要把线上报错里的 line/column 丢给 /api/resolve,就能拿到 source / line / column / name------直接对应你写的源码位置。
五、上线前必看的避坑清单
- Source Map 不要随业务包下发:放进内网/对象存储,靠后端接口按需读取,否则等于把源码免费送人。
- 列号偏移 :
originalPositionFor的列是 0-based,错误事件是 1-based,记得减 1。 - 跨域脚本 :
<script crossorigin>必须加,否则拿不到第三方脚本的错误堆栈。 - 去重与采样:相同堆栈合并计数,高频报错做采样,避免把服务端打挂。
- 敏感信息 :上报前脱敏
url里的 token、表单里的密码字段。
小结
一条完整的链路是:window.onerror / unhandledrejection 兜底 → sendBeacon 无阻塞上报 → Node 接收落库 → 构建期 Source Map 上传 → source-map 还原行号。
搭好这套之后,线上报错不再是"一行看不懂的压缩代码",而是精确的源码位置,平均定位时间能从几十分钟降到几秒。
如果你在用 Sentry / Fundebug 这类平台,底层也是这套机制------理解了原理,配置和排障都会顺手很多。