把压缩代码翻译回人话:Source Map 里到底装了什么
- 作者:Bougie
- 创建于:2026-09-22
上周三下午,报警群里弹出一条:
TypeError: Cannot read properties of undefined (reading 'name')
at o (app.a8f3c2.js:1:38247)
就这些。没有文件名,没有函数名(那个 o 是压缩后的),只有一个列号:38247。
我在群里回了句「我看下」,然后打开了构建产物。第一行有 12 万个字符,我把它拷进编辑器,把光标挪到第 38247 列——屏幕中间是一段毫无特征的代码,前后都是 function(e,t,n){return...} 这种东西。看不出来是谁。

真正救我的是另一个文件:app.a8f3c2.js.map。
# 它其实只是一份 JSON
报警后台那一屏红色的东西,有用的只有最后那个列号。而这个列号要被翻译成人话,全靠一个纯文本文件。

我拿一个几行的例子把它跑了一遍。原始文件 demo.js:
function greet(name) {
const message = "hello, " + name
return message.toUpperCase()
}
console.log(greet("bougie"))
用 terser 压一遍,产物是一行:
function greet(e){return("hello, "+e).toUpperCase()}console.log(greet("bougie"));
同时生成的 demo.min.js.map,去掉换行长这样:
{
"version": 3,
"sources": ["demo.js"],
"names": ["greet", "name", "toUpperCase", "console", "log"],
"mappings": "AAAA,SAASA,MAAMC,GAEb,OADgB,UAAYA,GACbC,cAGjBC,QAAQC,IAAIJ,MAAM"
}
字段不多,值得逐个说一句:
version:目前就是 3。这份格式是 2011 年 Google 定的(为了 Closure Compiler),十几年了没怎么动过;sources:原始文件列表,映射里的「源文件索引」指向的就是它;names:压缩前出现过的标识符。上面那个例子里,name、toUpperCase、console、log全在里面;sourcesContent:可选,把源码原文整份塞进来,这样 DevTools 不用再回头去请求源文件,离线也能看;mappings:真正干活的字段,也是整份文件里唯一看不懂的那个。
顺手记了三个数字:源码 121 字节,压缩产物 115 字节,map 167 字节。map 比代码本身还大。 这跟大多数人的直觉是反的——我们总觉得它是个附属品,其实它经常是构建产物里最占地方的那部分。
# mappings:一串看起来像乱码的东西
AAAA,SAASA,MAAMC,GAEb,OADgB,UAAYA,GACbC,cAGjBC,QAAQC,IAAIJ,MAAM
两个分隔符,规则很简单:
;分隔生成文件的行(上面只有一行,所以一个分号都没有);,分隔一行里的段(segment),每一段记录一次「生成位置 ↔ 原始位置」的对应。
每一段由 1 个、4 个或 5 个数字组成,顺序是:
- 生成文件的列(相对上一个段)
- 源文件索引(相对上一个段)
- 原始文件行(相对上一个段)
- 原始文件列(相对上一个段)
names索引(相对上一个段,可选)

拿第二段 SAASA 拆一遍:
| 字符 | base64 值 | 解出来 | 累加后 | 含义 |
|---|---|---|---|---|
S | 18 | +9 | 9 | 生成列 9 |
A | 0 | +0 | 0 | 源文件 0 |
A | 0 | +0 | 0 | 原始行 0 |
S | 18 | +9 | 9 | 原始列 9 |
A | 0 | +0 | 0 | names[0] |
也就是:压缩产物第 1 行第 9 列,来自 demo.js 第 1 行第 9 列,那个位置的标识符叫 greet。
生成文件第 9 列是 function greet(e) 里 greet 的 g,源文件第 9 列也是 greet 的 g。对得上。
再看第三段 MAAMC:
| 字符 | 解出来 | 累加后 | 含义 |
|---|---|---|---|
M | +6 | 15 | 生成列 15 |
A | +0 | 0 | 源文件 0 |
A | +0 | 0 | 原始行 0 |
M | +6 | 15 | 原始列 15 |
C | +1 | 1 | names[1] |
生成文件第 15 列是 function greet(e) 里的 e——压缩后它叫 e。而源文件第 15 列是 function greet(name) 里的 name,names[1] 也正好是 name。
第 5 个字段就是干这个的:让被改名的变量在报错堆栈里能变回它原来的名字。少这一个字段,你只能知道「在原始文件的第 15 列」,不知道那个东西原本叫什么。
# 为什么全是相对偏移
注意上面每个数字都是「相对上一个段」的增量,不是绝对值。

因为绝对列号会很大——回到开头那条报警,38247。而相邻两个映射点之间的距离通常只有个位数:一段语句里的下一个 token,往前挪 6 列、9 列而已。
数字越小,编码越短。这是整个格式设计里最关键的一个决定:它把一列映射的成本压到了几个字符,所以一份 map 才能塞下几十万个映射点还不至于失控。
代价是解码必须从头开始。你不能随机访问第 5000 个段,得把前面 4999 个都走过一遍。所以 source-map 那些库在消费 map 时都是一次性全量解码,再建索引。
# VLQ,以及那套 base64 字符
每个数字用的是 VLQ(Variable Length Quantity)+ base64:一个字符 6 个 bit,低 5 位存数据,最高位(值 32)是「后面还有」的续行标志。
字符: A B C ... a b ... 0 9 + /
索引: 0 1 2 26 27 52 61 62 63
字符顺序就是标准 base64(A-Za-z0-9+/),但符号不在这里面:VLQ 把符号塞进了最后一个 bit 的最低位。所以解出来的数要右移一位,最低位是 1 就取负。
手写一个解码器不长,二十来行:
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
// 解码一个 base64 VLQ 数字,返回 [值, 下一个字符的下标]
function decodeVLQ(str, i) {
let result = 0
let shift = 0
let digit
do {
digit = B64.indexOf(str[i++])
result += (digit & 31) << shift // 低 5 位是数据
shift += 5
} while (digit & 32) // 第 6 位是「后面还有」
const negative = result & 1 // 最低位是符号
result = result >>> 1
return [negative ? -result : result, i]
}
function decodeMappings(mappings) {
let sourceIndex = 0
let sourceLine = 0
let sourceColumn = 0
let nameIndex = 0
return mappings.split(';').flatMap((line, genLine) => {
let genColumn = 0 // 换行时列归零
return line
.split(',')
.filter(Boolean)
.map((segment) => {
let i = 0
let d
;[d, i] = decodeVLQ(segment, i)
genColumn += d
;[d, i] = decodeVLQ(segment, i)
sourceIndex += d
;[d, i] = decodeVLQ(segment, i)
sourceLine += d
;[d, i] = decodeVLQ(segment, i)
sourceColumn += d
if (i < segment.length) {
;[d, i] = decodeVLQ(segment, i)
nameIndex += d
}
return { genLine: genLine + 1, genColumn, sourceLine: sourceLine + 1, sourceColumn, nameIndex }
})
})
}
拿它跑前面那份 map,前几段是:
gen 1:0 -> src 1:0 name=greet
gen 1:9 -> src 1:9 name=greet
gen 1:15 -> src 1:15 name=name
gen 1:18 -> src 3:2 name=name
我又拿 source-map 官方库对了一遍,12 段里 11 段完全一致(第 4 段不一致是因为两个映射点落到了同一列,库取了先到的那个)。这份格式是可以自己读懂的,它并不需要黑箱。
# 工程上真正要操心的三件事
懂了格式,剩下的都是配置问题。

一、要不要生成,生成哪种。
// vite.config.js
export default {
build: {
sourcemap: 'hidden' // true | 'inline' | 'hidden'
}
}
// webpack
module.exports = {
devtool: 'hidden-source-map'
}
webpack 那串 devtool 值不是随便起的:source-map 会在产物末尾加 //# sourceMappingURL= 注释;hidden-source-map 生成 map 但不加注释;nosources-source-map 保留位置映射但去掉 sourcesContent。开发环境的 eval-cheap-module-source-map 则是另一条路:用 eval 换重建速度,只映射行不映射列。
二、别把 map 放到公网。
这是最容易被忽略的一条。map 里有两样东西不该给外人看:sourcesContent(整份源码)和 sources(有时候是绝对路径,把内网目录结构都带出去了)。
正确做法是 hidden-source-map:产物里不留 sourceMappingURL 注释,浏览器不会去找它;然后把 map 上传到监控平台(Sentry、阿里前端监控这些都支持),平台按版本号把线上报错和 map 配对。用户看不到源码,你能看到原始堆栈。
如果你连平台都不想给源码,还有 nosources-source-map:位置能还原,内容不给。折中得很体面。
三、怎么让浏览器找到它。
两种方式,效果一样:
//# sourceMappingURL=app.a8f3c2.js.map // 写在产物末尾
SourceMap: /app.a8f3c2.js.map // 或者放在 HTTP 响应头里
用响应头的好处是产物一个字节都不用改。注意跨域的话 map 也要带 CORS 头,否则浏览器会静默放弃,DevTools 里表现为「能打开源文件但内容空白」。
# 那几个让人白掉头发的坑
位置对不上,八成是版本不一致。 报错的列号来自 CDN 上的那份 JS,而你手里的 map 是另一次构建的。构建缓存、回滚、CDN 没刷新,任何一个都能造成错位。查这个之前,先确认产物的 hash 和 map 是一对。
链式转换会丢信息。 TS → JS → 压缩是两级转换。第二级(压缩器)只认得第一级的产物,如果第一级的 map 没喂给它,最终 map 就会指到中间那层 JS 而不是你的 .ts。tsc 要给 sourceMap: true,bundler 要配置成读取上游 map。
异步堆栈的落点是「抛出位置」,不是「调用位置」。 Promise 里抛的错误,列号指向的是那个 throw,而你可能更想知道是谁调进来的。这不是 map 的问题,是 window.onerror 拿到的信息本来就不全——需要在捕获时把 error.stack 一起上报。
sourcesContent 和「no sources」提示。 DevTools 提示 "no sources" 通常就是 map 里没有 sourcesContent,而源文件又拿不到。不是 map 坏了。
# 结尾
回到那条报警。
后来查出来是 src/utils/format.js 第 42 行,一个从接口拿的对象少了个字段。从看到报警到定位,一共七分钟——如果我没配 source map,这七分钟会变成一下午,而且大概率以「先加个 try/catch 看看」收场。

38247 这个数字本身没有任何意义。它有意义,是因为存在一份文件,愿意记下「这个位置原来在哪」。
这份格式 2011 年定稿,之后几乎没变过。十几年里前端换了三轮框架、构建工具换了两代,它安安静静躺在产物旁边,被下载、被解析、被对上一个列号,然后被忘掉。
我觉得大部分基础设施的好,都是这种好:你从来不需要想起它,只在它缺席的那天,才发现自己寸步难行。
文中的示例由 esbuild 与 terser 生成,解码结果已与 source-map 官方库对照验证。