用 source map 把压缩后的 JS 报错堆栈还原到源码行号
本页目录
线上报错通常长这样:at e (app.BhjnrRq7.js:1:68)。函数叫 e,整个 bundle 只有一行,第 68 列对你来说毫无意义。但同一次构建产出的 source map 知道这个位置来自哪里。本文讲三件事:怎样保留 map 又不把它公开;怎样在浏览器里把一整段堆栈逐帧映射回自己的代码;以及怎样在脚本或 agent 里用 REA 做同样的位置查询。
适合谁读:排查自家线上前端或 Node.js 代码的开发者,想看懂报错堆栈,又不想把 map 交给第三方。
你会做的事:
- 弄明白压缩后的堆栈为什么总指向第 1 行
- 构建只留在自己手里的 hidden source map
- 用本地工具逐帧还原一段真实的报错堆栈
- 处理 map 和 bundle 对不上的情况
- 用 REA 的
trace-web-source-location做同样的查询
文中用的都是专门为本文写的小示例。请用在你自己的代码,或已获授权调试的代码上。
为什么堆栈显示第 1 行第 68 列
压缩工具会把各个模块拼成少数几行很长的代码,并把局部函数改成单个字母。引擎报告的是它实际执行的那份代码里的位置,所以堆栈里给出的是 bundle 的坐标:文件、行、列,都从 1 开始数。source map 就是一张表,把这些生成位置对应到原始的 文件:行:列,每个位置还可以附带一个原始名称。还原堆栈,就是拿每一帧去这张表里查一次。
查得对不对,取决于两点:
- 必须是同一次构建。map 要来自抛错的那一次构建。昨天部署的 map,会把同一个列号映射到别的地方去。
- 列号的起点。堆栈里的列号从 1 开始,source map 格式里的列号从 0 开始。直接接收堆栈文本的工具会替你换算;接收原始位置的 API 一般要的是从 0 开始的列号。
第 1 步:构建 hidden source map
示例是一个只有两个文件的 TypeScript 应用。src/price.ts 负责校验一行商品:
export function lineTotal(item: LineItem): number {
if (!Number.isInteger(item.quantity) || item.quantity < 1) {
throw new RangeError(`Invalid quantity for ${item.sku}: ${item.quantity}`);
}
return item.unitCents * item.quantity;
}
src/main.ts 用 items.reduce((sum, item) => sum + lineTotal(item), 0) 计算购物车总价,再由 formatCart 格式化。用 Vite 构建,并打开 hidden map:
// vite.config.js
export default {
build: { minify: true, sourcemap: 'hidden' },
};
为什么用 hidden:Vite 仍然会写出 assets/app.BhjnrRq7.js.map,但 bundle 里没有 sourceMappingURL 注释,浏览器和访客都不会被引向它。把 map 和构建的版本号或 commit 一起存起来,不放进部署目录,以后就能还原报错,又不会公开源码。webpack 里对应的设置是 devtool: 'hidden-source-map',esbuild 是 --sourcemap=external。为什么不能把 map 部署出去,见 发布前自查 source map 泄露。
真实的压缩输出只有一行:
function e(e){if(!Number.isInteger(e.quantity)||e.quantity<1)throw RangeError(`Invalid quantity for ${e.sku}: ${e.quantity}`);return e.unitCents*e.quantity}function t(t){return t.reduce((t,n)=>t+e(n),0)}function n(e){return`$${(t(e)/100).toFixed(2)}`}export{t as cartTotal,n as formatCart};
在 Node.js 22 里用数量 0 调用 formatCart,得到下面这段堆栈(目录缩写成了 /work):
RangeError: Invalid quantity for TEE-07: 0
at e (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:68)
at file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:196
at Array.reduce (<anonymous>)
at t (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:180)
at n (file:///work/price-app/dist/assets/app.BhjnrRq7.js:1:229)
at file:///work/price-app/run.mjs:6:3
浏览器里的堆栈格式一样,只是地址换成了 https://。
第 2 步:在浏览器里还原堆栈
打开 JS bundle 与 source map 还原工具,拖入 .map 文件(手上有 bundle 的话也一起拖进去),再把整段堆栈粘贴到查询框。工具能识别 Chrome 和 Node 的写法(at fn (url:line:col))、Firefox 和 Safari 的写法(fn@url:line:col),也能识别单独的 行:列。解析、解码和查询都在你电脑上的 Web Worker 里完成,map 不会被上传。这一点很重要,因为 map 里装着你的源码。
上面这段堆栈的还原结果如下:
| 堆栈里的帧 | 原始位置 | 该位置记录的名称 |
|---|---|---|
at e (…app.BhjnrRq7.js:1:68) |
src/price.ts:9:15 |
无 |
at …app.BhjnrRq7.js:1:196 |
src/main.ts:4:44 |
lineTotal |
at t (…app.BhjnrRq7.js:1:180) |
src/main.ts:4:16 |
无 |
at n (…app.BhjnrRq7.js:1:229) |
src/main.ts:8:17 |
cartTotal |
at …run.mjs:6:3 |
标为其他文件 |
每条结果都附带几行原始代码,并标出目标行。第一帧对应的是 lineTotal 里的 throw new RangeError(...)。如果上传了 bundle,还会显示第 68 列附近的生成代码。Array.reduce (<anonymous>) 这一行没有位置信息,直接跳过;run.mjs 那一帧指向的不是这个 bundle,所以不会拿这份 map 去映射。
名称这一列怎么读:它是 map 在这个确切位置上记录的标识符,而不是这一帧所在函数的名字。第 2 帧指向的是调用 e(n) 的地方,所以记录的名称是被调用的函数 lineTotal;第 4 帧指向调用 t(e) 的地方,记录为 cartTotal。要知道帧位于哪个函数里,看代码片段:第 1 帧在 lineTotal 里,第 2、3 帧在 cartTotal 里,第 4 帧在 formatCart 里。
查询规则:对每个位置,工具取不晚于它的最大映射位置(可以跨行往前找),并返回该位置上的全部映射段。REA 用的也是这条规则,下一节会用到。如果这个位置之前根本没有映射,这一帧标为未映射;如果那里的映射段没有原始位置,就标为仅有生成位置。两种情况都不会去猜。
第 3 步:map 对不上怎么办
结果不对,大多是因为文件配错了。按这个顺序检查:
- 文件哈希。
app.BhjnrRq7.js这种带内容哈希的文件名每次构建都会变。堆栈里的文件名应该和 map 的file字段(工具报告里能看到)一致。 - 行数。工具报告会列出 bundle 的行数和 map 的生成代码行数。如果堆栈提到一个单行 bundle 的第 3 行,说明这两个文件不是一套。
- 格式化过的副本。从 DevTools 里美化后的 bundle 抄来的位置,和原始文件对不上。要用报错里原始的位置。
- 改过名的 bundle。如果没有任何一帧指向上传的 bundle(比如 CDN 改写了文件名),工具会把所有帧都拿这份 map 去映射。这时要看代码片段确认结果说得通。
- 没有内容。没带
sourcesContent的 map 仍能给出文件、行和列,只是没有代码片段。到同一个 commit 的代码里打开那个文件、那一行即可。
第 4 步:用 REA 自动化
还原一段粘贴来的堆栈,用浏览器工具最快。要在脚本里或让 agent 来做,可以用 REA(morluto 维护的开源项目)的 trace-web-source-location:它把一个已抓取脚本里的生成位置,通过你指定的本地 map 映射回去。REA 6.4.0 没有直接接收整段堆栈的命令,所以要每一帧查一次。安装方法见 安装与接入。
这个命令针对的是 REA 从页面抓取下来的脚本,这样堆栈和 map 都能对应到确切的字节。对你自己的站点或本地预览:
rea inspect-web-page http://127.0.0.1:9222 TARGET_ID \
--include-script-sources --json > capture.json
rea export-web-scripts /absolute/path/capture.json /absolute/path/exported --json
rea trace-web-source-location \
/absolute/path/exported/manifest.json 0 \
/absolute/path/maps/app.BhjnrRq7.js.map \
'https://your-site.example/assets/app.BhjnrRq7.js.map' \
1 67 --json
六个参数依次是:manifest、该 manifest 里从 0 开始的脚本序号、本地 map 路径、一个只用来解析 map 里 sources 的 URL(不会去请求它)、从 1 开始的行号、从 0 开始的 UTF-16 列号。所以堆栈里的 1:68 要写成 1 67。浏览器怎么启动、目标 ID 怎么拿,见 用 REA 追踪网站 API 调用。
按 6.4.0 的输出 schema,结果里有这些内容:
lookup.matched_generated_position,以及matches里同一位置上的全部匹配。每个匹配要么是generated-only,要么是mapped,带reported_source、resolved_url、original_position和name。content.state:map 带sourcesContent时为embedded,并附上原文;不带时为unavailable。original_position.content_position说明这个位置有没有在原文里核对过。source_map.association: "caller-selected"、source_authenticity: "unknown"、execution: "unknown"。REA 会校验你给它的字节,但不会声称这份 map 就是和这个脚本一起部署的,也不会声称这一行真的执行过。
6.4.0 的限制:map 最大 4 MiB,解码时限 20 秒,只支持普通 map 和内联 section 的索引 map;section 指向其他 URL 的索引 map 不支持。通过 MCP 调用时,操作名是 trace_web_source_location,agent 可以逐帧循环调用,并为每条结论给出依据。
接入错误上报流程
无论用哪个工具,保住 map 私密性的做法都一样:
- 用 hidden map 构建,并给这次构建打上版本号或 commit 哈希。
- 把
.map文件按版本存到私有存储,或者上传到支持 map 上传的错误监控服务。 - 只部署
.js文件。 - 收到报错时,按版本取出对应的 map,用本地工具或 REA 还原堆栈。
对你自己运行的 Node.js 服务,node --enable-source-maps 能在运行时还原堆栈,前提是 bundle 的 sourceMappingURL 指向一个可读的 map。hidden map 没有注释可循,所以要按上面的方法离线还原。
常见问题
map 需要上传到什么地方吗?
不需要。浏览器工具在本地解码,REA 从磁盘读取。只有你决定信任某个服务、愿意把源码交给它时,才上传 map。
为什么还原结果里没有原始函数名?
map 只在部分位置记录名称,通常是标识符所在的位置。帧的位置可能落在 throw、运算符或者一次调用上。看代码片段就能知道它在哪个函数里。
别人网站的堆栈也能还原吗?
只有在你获得授权调试、并且拿到了它的 map 时才可以。不要从无权分析的网站下载 map。
下一步
- 别让 map 进入 npm 包和部署目录:/zh/audit-source-map-leaks-before-publish/
- 用 REA 的应用图读懂整个 bundle:/zh/recover-javascript-source-from-bundle/
- 还原 Electron 应用 renderer 里的报错:/zh/unpack-asar-file/
- 本文用到的工具:JS bundle 与 source map 还原工具