线上 JS 报错最常见的样子,是一串看不懂的堆栈:a()@app.min.js:1:12345。压缩、混淆、合并之后,源码行号荡然无存,你根本不知道是哪一行逻辑炸了。前端错误监控的价值,就是把这些「线上才出现的异常」自动收集回来;而 Source Map 则负责把压缩后的堆栈还原成可读的源码位置,让一次线上故障从「猜」变成「点开就能看到第几行」。本文从采集、还原到自建收集器,给你一条能直接落地的链路。
一、为什么必须做前端错误监控
后端有日志、有 APM,前端却长期是监控盲区:用户浏览器里的报错,服务端永远收不到。等客服转述「页面白屏了」,你再去复现,往往已经错过现场。前端错误监控要解决三件事:把错误抓得到、报得回、读得懂。它和前端性能优化是同一枚硬币的两面——一个看「慢」,一个看「崩」,缺一不可。
1.1 三类必须捕获的错误
(1)JS 运行时异常:TypeError、ReferenceError 等同步错误;(2)资源加载失败:脚本、图片、接口 404/超时;(3)未处理的 Promise 拒绝:async/await 漏掉 catch 的静默失败,最容易被忽略。第三类尤其危险:它不会让控制台变红,却可能让整页交互失效。
二、捕获全局错误:onerror 与 unhandledrejection
浏览器原生提供了两个入口,几乎零成本就能接入。下面是最小可用采集器,建议放在业务脚本之前加载,确保它能兜住后续所有代码抛出的错误:
// monitor.js —— 全局错误采集(放在业务脚本之前加载)
(function () {
function report(payload) {
// 用 sendBeacon,页面卸载也能发出去,且不阻塞主线程
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/fe-error', JSON.stringify(payload));
} else {
fetch('/api/fe-error', { method: 'POST', body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' }, keepalive: true });
}
}
// 1) 同步运行时错误
window.addEventListener('error', function (e) {
if (e.message) {
report({ type: 'js', msg: e.message,
stack: e.error && e.error.stack,
file: e.filename, line: e.lineno, col: e.colno,
ua: navigator.userAgent, t: Date.now() });
}
}, true); // 注意 useCapture=true 才能捕获资源加载错误
// 2) 未处理的 Promise 拒绝
window.addEventListener('unhandledrejection', function (e) {
report({ type: 'promise', msg: String(e.reason),
stack: e.reason && e.reason.stack, ua: navigator.userAgent, t: Date.now() });
});
})();
关键点:error 事件用捕获阶段(true)才能兜住 <img>、<script> 等资源加载失败;unhandledrejection 专门接住 Promise 漏网之鱼。上报优先用 sendBeacon / fetch(keepalive),避免页面跳转时请求被取消。
2.1 上报协议字段设计
| 字段 | 类型 | 说明 |
|---|---|---|
| type | string | js / promise / resource,区分错误来源 |
| msg | string | 错误信息,聚合去重的主键之一 |
| stack | string | 原始堆栈,配合 Source Map 还原 |
| file/line/col | string/number | 压缩后的文件与行列,还原前的坐标 |
| release | string | 构建版本号,还原时用于匹配 .map |
| ua / t | string/number | 用户代理与上报时间戳,辅助聚类 |
三、Source Map 是什么,为什么能还原堆栈
打包工具(Vite / Webpack / Rspack)在压缩 JS 时,会同时产出一份 .map 文件,里面用 mappings 字段记录了「压缩后每个字符」到「源码哪一行哪一列」的映射,并携带 sources 与 sourcesContent(源码原文)。只要拿到这份映射,就能把 app.min.js:1:12345 反查回 src/utils/request.ts:42:7。
构建时务必产出 sourcemap(生产构建不要关闭)。需要注意权衡:.map 文件体积往往接近甚至大于 JS 本身,只保留最近若干个发布版本的 map 即可,更早的版本意义不大且占用存储。
// vite.config.ts —— 生产构建保留 sourcemap
export default defineConfig({
build: {
sourcemap: true, // 关键:产出 .map
minify: 'esbuild',
},
});
// webpack.config.js
module.exports = {
devtool: 'hidden-source-map', // 比 source-map 更隐蔽:不往产物里塞 //# sourceMappingURL
};
四、把 Source Map 上传到监控端(别发到公网)
最危险的误区,是把 .map 文件跟着静态包一起部署——任何人都能下载你的源码原文。正确做法:构建后只把 .map 上传到监控平台(Sentry / 自建收集器),生产静态目录里只留混淆后的 JS。
// 方式 A:Sentry CLI 上传(推荐,自动关联 release 版本)
npx sentry-cli sourcemaps inject ./dist # 给 JS 注入 x-sourceMappingURL 注释
npx sentry-cli sourcemaps upload ./dist # 上传 .map 到 Sentry,不上公网
// 方式 B:自建收集器,CI 阶段 curl 上传
for f in $(find dist -name '*.map'); do
curl -F "file=@$f" -F "release=$CI_COMMIT_SHA" \
-u "monitor:${UPLOAD_TOKEN}" https://monitor.internal/api/sourcemap
done
4.1 安全红线
生产 dist/ 目录绝不放 .map;用 hidden-source-map 或构建后脚本删除公网可见的 map 文件;上传通道走内网或带鉴权的私有域名。源码原文属于高敏感资产,泄露等同于把服务器架构图交给外人。
五、服务端还原:用 source-map 库解析堆栈
拿到压缩堆栈的坐标后,在服务端用 source-map 这个库做还原。下面是一段可直接跑的 Node 还原逻辑:
// resolve.js —— 用 source-map 把压缩坐标还原成源码位置
const { SourceMapConsumer } = require('source-map');
const fs = require('fs');
async function resolve(mapPath, line, column) {
const raw = JSON.parse(fs.readFileSync(mapPath, 'utf-8'));
const consumer = await new SourceMapConsumer(raw);
const pos = consumer.originalPositionFor({ line, column });
console.log(`源码位置:${pos.source}:${pos.line}:${pos.column}`);
consumer.destroy();
}
resolve('./dist/app.min.js.map', 1, 12345);
5.1 还原流程示意
浏览器报错(压缩坐标)→ 上报到收集器 → 收集器按 release 版本号找到对应 .map → originalPositionFor 反查 → 存储「源码文件:行:列」。整套链路的核心是 release 版本号要对齐:上报时带上的版本,必须和上传的那份 sourcemap 是同一构建产物。理解浏览器调度有助于判断「为什么这段代码会在用户机器上报错」,建议配合 浏览器事件循环 一起读。
六、错误聚合与告警:别被噪音淹没
原始报错量极大,10 个用户撞上同一个 bug 会刷出 10 条。需要按指纹(fingerprint)聚合:通常用 msg + 首个堆栈帧 做哈希,把相同根因合并成一条,计数 +1。
6.1 指纹去重示例
// 用「错误信息 + 首个堆栈行」生成稳定指纹,避免同一 bug 刷屏
function fingerprint(err) {
const firstFrame = (err.stack || '').split('\n')[1] || '';
return require('crypto')
.createHash('md5')
.update(err.msg + '|' + firstFrame)
.digest('hex')
.slice(0, 12);
}
聚合之后,才能谈告警:单条错误 5 分钟内超过阈值(比如 50 次)才触发,避免偶发抖动吵到人。错误和性能要一起看——把错误率和 前端性能监控(Web Vitals) 放同一面板,能更快判断「是崩了,还是只是慢」。
七、一个最小可运行的自建收集器
不想接 Sentry,也可以用几十行 Node 起一个收集端点,先跑起来:
// server.js —— 极简前端错误收集器(Express)
const express = require('express');
const app = express();
app.use(express.json({ limit: '1mb' }));
const store = []; // 生产请换数据库
app.post('/api/fe-error', (req, res) => {
const e = req.body;
store.push({ ...e, fp: fingerprint(e), at: new Date() });
res.sendStatus(202);
});
app.get('/api/fe-errors', (req, res) => {
const agg = {};
for (const it of store) agg[it.fp] = (agg[it.fp] || 0) + 1;
res.json(Object.entries(agg).map(([fp, n]) => ({ fp, count: n })));
});
app.listen(3000, () => console.log('fe-error collector on :3000'));
真实环境里把 store 换成数据库,并接上 §四 的 sourcemap 还原接口,就构成一个能定位源码的最小监控闭环。
八、上线前检查清单
| 检查项 | 是否到位 |
|---|---|
| 全局 error / unhandledrejection 已采集 | 必做 |
| 上报用 sendBeacon / keepalive,卸载不丢 | 必做 |
| 构建产出 sourcemap(hidden-source-map) | 必做 |
| map 仅上传私有监控端,公网目录已删 | 必做 |
| release 版本号:上报与上传对齐 | 必做 |
| 指纹聚合 + 阈值告警,避免噪音 | 建议 |
| 错误面板与性能监控联动 | 建议 |
九、Source Map 对不上的三大根因
还原失败十有八九是下面三类原因,排错时按顺序查:
9.1 release 版本不一致
上报带的是 v1.3.0,上传的 map 却是 v1.2.9 的构建产物,自然对不上。务必让 CI 在构建时把同一个 commit SHA 同时写进上报 SDK 和 map 上传参数。
9.2 devtool 配置不匹配
用了 eval / cheap-module-source-map 这类不带完整 sourcesContent 的模式,还原时只能拿到文件名拿不到源码行,或者坐标整体偏移。生产统一用 hidden-source-map 最稳。
9.3 二次压缩或 CDN 改写
构建后又被 Nginx / CDN 做了 gzip 之外的二次压缩(如 brotli 不影响,但某些混淆器二次处理会破坏 mappings),或静态资源被加了 hash 但 map 里的 sources 路径没同步。遇到诡异偏移,先确认 map 与线上 JS 来自同一份构建产物。
十、隐私与采样:别把用户数据报出去
上报 payload 里很容易混进手机号、token、Cookie。上线前必须做脱敏,并在高流量时采样上报,既省带宽又降噪。下面是一段简单的字段脱敏:
// 上报前剔除敏感字段,再做采样
function sanitize(e) {
const s = JSON.stringify(e);
return JSON.parse(s
.replace(/1[3-9]\d{9}/g, '[phone]') // 手机号
.replace(/Bearer\s+\S+/g, '[token]') // 鉴权头
.replace(/password["'=:\s]+\S+/gi, '[pwd]'));
}
function shouldSample(rate = 0.1) { // 默认上报 10%
return Math.random() < rate;
}
if (shouldSample()) report(sanitize(payload));
脱敏和采样不是可选项,而是合规底线:用户数据不出浏览器、错误里不含 PII,是前端监控能长期运行的前提。
前端错误监控不是锦上添花,而是把「用户替你发现的 bug」变成「自己能复现的堆栈」。最小落地路线:先把 §二 的采集器接上,一周内在面板里看到第一批真实报错;再补 §四 的 sourcemap 还原,让堆栈从 app.min.js:1:12345 变成 request.ts:42;最后加 §六 的聚合告警,把噪音压下去。配合 浏览器事件循环 与前端性能监控 的理解,下一次线上白屏,你点开面板就知道是哪一行。




