rea-reverse

用 source map 把压缩后的 JS 报错堆栈还原到源码行号

基于 REA 6.4.0更新于 约 7 分钟读完
本页目录

线上报错通常长这样:at e (app.BhjnrRq7.js:1:68)。函数叫 e,整个 bundle 只有一行,第 68 列对你来说毫无意义。但同一次构建产出的 source map 知道这个位置来自哪里。本文讲三件事:怎样保留 map 又不把它公开;怎样在浏览器里把一整段堆栈逐帧映射回自己的代码;以及怎样在脚本或 agent 里用 REA 做同样的位置查询。

适合谁读:排查自家线上前端或 Node.js 代码的开发者,想看懂报错堆栈,又不想把 map 交给第三方。

你会做的事:

  1. 弄明白压缩后的堆栈为什么总指向第 1 行
  2. 构建只留在自己手里的 hidden source map
  3. 用本地工具逐帧还原一段真实的报错堆栈
  4. 处理 map 和 bundle 对不上的情况
  5. 用 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 对不上怎么办

结果不对,大多是因为文件配错了。按这个顺序检查:

  1. 文件哈希。app.BhjnrRq7.js 这种带内容哈希的文件名每次构建都会变。堆栈里的文件名应该和 map 的 file 字段(工具报告里能看到)一致。
  2. 行数。工具报告会列出 bundle 的行数和 map 的生成代码行数。如果堆栈提到一个单行 bundle 的第 3 行,说明这两个文件不是一套。
  3. 格式化过的副本。从 DevTools 里美化后的 bundle 抄来的位置,和原始文件对不上。要用报错里原始的位置。
  4. 改过名的 bundle。如果没有任何一帧指向上传的 bundle(比如 CDN 改写了文件名),工具会把所有帧都拿这份 map 去映射。这时要看代码片段确认结果说得通。
  5. 没有内容。没带 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 私密性的做法都一样:

  1. 用 hidden map 构建,并给这次构建打上版本号或 commit 哈希。
  2. 把 .map 文件按版本存到私有存储,或者上传到支持 map 上传的错误监控服务。
  3. 只部署 .js 文件。
  4. 收到报错时,按版本取出对应的 map,用本地工具或 REA 还原堆栈。

对你自己运行的 Node.js 服务,node --enable-source-maps 能在运行时还原堆栈,前提是 bundle 的 sourceMappingURL 指向一个可读的 map。hidden map 没有注释可循,所以要按上面的方法离线还原。

常见问题

map 需要上传到什么地方吗?

不需要。浏览器工具在本地解码,REA 从磁盘读取。只有你决定信任某个服务、愿意把源码交给它时,才上传 map。

为什么还原结果里没有原始函数名?

map 只在部分位置记录名称,通常是标识符所在的位置。帧的位置可能落在 throw、运算符或者一次调用上。看代码片段就能知道它在哪个函数里。

别人网站的堆栈也能还原吗?

只有在你获得授权调试、并且拿到了它的 map 时才可以。不要从无权分析的网站下载 map。

下一步