线上报错只剩一行 Script error.:sourcemap 与错误采集的三个断点
三成错误只留一句话,剩下的堆栈指向压缩文件的第 48 万列。修好 crossorigin 只是第一步,sourcemap 的三个断点、Vue3 的单独一层和上报去重都得补齐。
前端错误监控上线第一个月,我最常看到的报错长这样:
Script error.
at <anonymous>:0:0
这类记录占了全部错误的 31%。剩下 69% 里,又有一半是 TypeError: Cannot read properties of undefined (reading 'xxx'),堆栈指向 index-8f3a2c.js:1:482913——一个压缩后的文件,第一行,第 48 万列。你拿着这个坐标,除了知道「用户点某个按钮的时候炸了」,什么都干不了。
监控的价值不在于「知道出错了」,而在于「知道哪一行出错」。只有一行 Script error. 的监控,和没有监控差不多,只是多了一个需要每天点掉的看板。
这篇文章是把这个从 31% 压到 2% 的过程,中间踩了三个断点,每一个都足以让整条链路失效。
一、Script error. 的来源:同源策略与 crossorigin
Script error. 不是浏览器在敷衍你,它是安全策略的产物:当脚本来自不同源(不同域名、端口、协议),且没有声明 CORS,浏览器会把错误的详细信息全部抹掉,只留下这一句,防止恶意脚本通过错误信息探测页面内容。
我们的静态资源在 CDN 上(static.example-cdn.com),页面在主域(www.example.com),天然跨域。修复要两侧同时改,缺一不可。
第一侧是 <script> 标签加 crossorigin:
<!-- 不加 crossorigin,浏览器就不会把错误详情交给 onerror -->
<script type="module" crossorigin src="/assets/index-8f3a2c.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-4b7e91.js">
crossorigin 不带值时等价于 crossorigin="anonymous",请求不带凭证,CDN 返回 Access-Control-Allow-Origin: * 即可。如果资源需要带 cookie(比如私有 CDN),要写 crossorigin="use-credentials",且 CDN 必须返回具体的源而不能是 *。
第二侧是 CDN 的响应头:
# CDN 回源配置:静态资源允许跨域,且必须允许 Range(sourcemap 分块加载会用到)
location ~* \.(js|css|map)$ {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Expose-Headers "Content-Length, Content-Range" always;
add_header Timing-Allow-Origin "*" always;
expires 1y;
}
后两个头容易被忘。Access-Control-Expose-Headers 不配,前端拿不到 Content-Length,某些 sourcemap 加载器会退化;Timing-Allow-Origin 不配,PerformanceResourceTiming 里的耗时全是 0,你的资源加载监控就是一片空白。
改完这两侧,Script error. 的比例从 31% 掉到 4%。剩下的 4% 不是跨域问题,是别的原因,后面再讲。
二、sourcemap 不是上传就完事:三个断点
拿到 index-8f3a2c.js:1:482913 之后,下一步是把压缩坐标还原成源码位置。这中间有三个断点,任何一处断了,坐标就还原不出来。
断点一:产物里到底有没有 sourcemap。 Vite 默认 build.sourcemap: false,生产构建根本不生成 .map。我们之前的配置就是这样,所以就算错误信息完整,也没有可用于还原的文件。
断点二:sourcemap 有没有跟着产物一起上传到 CDN。 有些团队为了「不泄露源码」,构建后删掉 .map 再上传。删了当然还原不了。正确做法是生成但不公开:.map 上传到一个需要鉴权的内部地址,或者干脆只上传到错误监控平台,CDN 上不部署。
断点三://# sourceMappingURL= 指向的地址能不能访问。 压缩文件末尾那行注释指向 .map,如果 CDN 把 .map 拦了(返回 403 或一个 HTML 错误页),加载器解析失败,坐标还原同样失败。这一点最隐蔽,因为浏览器控制台不报错,只有监控平台那边的还原率莫名很低。
我们最终采用的方案是:构建生成 sourcemap: 'hidden',产物里不带 sourceMappingURL 注释,.map 单独打包上传到监控平台,不部署到 CDN。
// vite.config.ts
export default defineConfig({
build: {
// hidden:生成 .map 但不在产物里写 sourceMappingURL 注释
sourcemap: 'hidden',
rollupOptions: {
output: {
// 文件名带内容哈希,便于缓存与版本对齐
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
});
hidden 和 true 的区别只有一行注释,但对安全要求高的团队很重要:true 会在产物末尾写 //# sourceMappingURL=index-8f3a2c.js.map,任何人打开 DevTools 都能顺着地址把源码拖下来;hidden 不写注释,只有拿到 .map 文件的人才能还原。
三、把压缩坐标还原回来
有了 .map 和坐标,还原本身是纯粹的算术,source-map 这个库几行就能做。我写了个小脚本,把监控平台导出的错误列表批量还原,用来做每周的归类统计。
// scripts/reduce-stack.mjs
import { readFileSync } from 'node:fs';
import { SourceMapConsumer } from 'source-map';
const [mapFile, line, column] = process.argv.slice(2);
const raw = JSON.parse(readFileSync(mapFile, 'utf8'));
const consumer = await new SourceMapConsumer(raw);
const pos = consumer.originalPositionFor({
line: Number(line),
column: Number(column),
bias: SourceMapConsumer.GREATEST_LOWER_BOUND,
});
console.log(`${pos.source}:${pos.line}:${pos.column} (${pos.name || 'anonymous'})`);
consumer.destroy();
node scripts/reduce-stack.mjs dist/assets/index-8f3a2c.js.map 1 482913
# src/views/order/OrderDetail.vue:187:24 (render)
从 1:482913 到 OrderDetail.vue:187:24,这一跳是整个监控链路的价值所在。有了它,才能回答「哪个页面、哪个组件、哪一行」这三个问题。
bias 参数值得一提。压缩后的列号经常落在两个 mapping 之间,GREATEST_LOWER_BOUND 取「小于等于该列号的最近一个映射」,是绝大多数场景下正确的选择;LEAST_UPPER_BOUND 取「大于等于」,会在某些边界上指到下一行的语句。默认值是 GREATEST_LOWER_BOUND,我显式写出来是为了让后来者知道这里有个选择。
四、Vue3 的错误要单独接一层
window.onerror 能抓到未捕获的异常,但抓不到 Vue 组件内部被框架捕获的错误。Vue 3 在组件渲染、生命周期、事件处理里出错时会调用 app.config.errorHandler,如果没注册,它会打到控制台但不一定进 window.onerror(生产构建下行为还不一致)。
所以要在入口处显式接一层:
import { createApp } from 'vue';
import App from './App.vue';
import { reportError } from './utils/monitor';
const app = createApp(App);
app.config.errorHandler = (err, instance, info) => {
// info 形如 "render function" / "setup function" / "native event handler"
reportError(err, {
hook: info,
component: instance?.$options?.name || instance?.$options?.__name || 'anonymous',
route: window.location.pathname,
});
};
// 未捕获的 Promise 拒绝单独接,它不会进 onerror
window.addEventListener('unhandledrejection', (e) => {
reportError(e.reason, { kind: 'unhandledrejection' });
});
app.mount('#app');
info 这个参数信息量很大。render function 说明是模板渲染时炸的,通常和数据结构有关;native event handler 说明是点击回调里炸的,通常和用户操作路径有关。把这两个分开统计,能立刻看出问题集中在哪一侧。
我们接上之后发现,TypeError: Cannot read properties of undefined 里有 62% 来自 render function,而且集中在三个列表页。进一步看是接口返回的 data.list 在空结果时返回了 null 而不是 [],模板里 v-for 直接崩。这个改动只花了两行(后端补默认值 + 前端 ?? [] 兜底),但如果没有 info 这个维度,我们大概会一直以为是「偶发的数据问题」。
五、剩下的 4% 是什么
Script error. 从 31% 降到 4% 之后,我抽样看了那 4% 的记录,来源有三类:
| 来源 | 占比 | 特征 | 处理方式 |
|---|---|---|---|
| 浏览器插件注入的脚本 | 约一半 | URL 是 chrome-extension:// |
采集时直接过滤 |
| 第三方 SDK 内部错误 | 约三成 | 堆栈全在 vendor 里且无 map | 联系 SDK 方,或单独降噪 |
| 用户环境异常(老浏览器、代理改写) | 剩余 | UA 是极老版本 | 按 UA 白名单忽略 |
第一类必须过滤。插件注入的脚本是跨域的、不受你控制的,它报的错你一行都改不了,留在看板上只会稀释真实问题的占比。过滤方式很简单,采集时判断堆栈里有没有 chrome-extension:// 或 moz-extension://。
第二类要找 SDK 方要 map。有些 SDK 会公开发布 sourcemap,有些不会,后者你只能把它归到「非我方代码」的桶里单独看趋势。
第三类是真实但无法修的问题,比如用户用的是两年前的浏览器,某个 API 不存在。按 UA 判断,进单独的桶,别混进主看板。
六、采样与去重:不要让监控自己变成故障
这套东西上线后我犯过一个错:全量采集。一个渲染异常在首页触发,两万用户同时打开,一分钟内涌进来两万条一模一样的错误,把监控平台打挂了,连带把上报接口打挂——而那个上报接口和我们业务在同一个网关下。
三件事必须做。
一是按错误指纹去重。指纹用「错误信息 + 顶层堆栈位置」算哈希,同一个指纹在一个时间窗口内只上报前 N 次,后续只累加计数。
二是采样。正常错误按 10% 采样,Script error. 这种低价值的按 1%,首屏关键路径的错误不采样。
三是上报接口独立域名,且做失败静默。
// 简单的指纹 + 限流上报
const seen = new Map();
const WINDOW = 60_000;
const MAX_PER_WINDOW = 5;
function fingerprint(err) {
const stack = (err.stack || '').split('\n').slice(0, 2).join('|');
return `${err.name}:${err.message}:${stack}`.slice(0, 300);
}
function reportError(err, extra = {}) {
const fp = fingerprint(err);
const now = Date.now();
const rec = seen.get(fp) || { ts: now, n: 0 };
if (now - rec.ts > WINDOW) {
seen.set(fp, { ts: now, n: 1 });
} else if (++rec.n > MAX_PER_WINDOW) {
return; // 窗口内超限,直接丢弃,只保留计数
} else {
seen.set(fp, rec);
}
navigator.sendBeacon('/monitor/error', JSON.stringify({ fp, extra, ua: navigator.userAgent }));
}
sendBeacon 比 fetch 合适,因为它在页面卸载时也会发出,而 fetch 需要 keepalive: true 才勉强可靠。代价是 sendBeacon 不支持自定义头,数据处理只能放 body,服务端要按 JSON 解析。
小结
Script error.是同源策略的结果,<script crossorigin>与 CDN 的Access-Control-Allow-Origin必须两侧同时改。Access-Control-Expose-Headers和Timing-Allow-Origin常被漏掉,后者不配会让资源耗时全是 0。- sourcemap 有三个断点:产物里有没有、有没有传到可访问的位置、
sourceMappingURL能不能取到。hidden模式兼顾还原与源码保护。 originalPositionFor的bias用GREATEST_LOWER_BOUND,压缩列号落在 mapping 之间时才是对的。- Vue 3 要单独接
app.config.errorHandler,info字段能区分渲染错误和事件回调错误,是定位的关键维度。 - 上报必须去重、采样、独立域名,并且失败静默,否则监控本身会成为一次故障。
正在加载评论…