把压缩代码翻译回人话:Source Map 里到底装了什么

上周三下午,报警群里弹出一条:

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:压缩前出现过的标识符。上面那个例子里,nametoUpperCaseconsolelog 全在里面;
  • sourcesContent:可选,把源码原文整份塞进来,这样 DevTools 不用再回头去请求源文件,离线也能看;
  • mappings:真正干活的字段,也是整份文件里唯一看不懂的那个。

顺手记了三个数字:源码 121 字节,压缩产物 115 字节,map 167 字节。map 比代码本身还大。 这跟大多数人的直觉是反的——我们总觉得它是个附属品,其实它经常是构建产物里最占地方的那部分。

# mappings:一串看起来像乱码的东西

AAAA,SAASA,MAAMC,GAEb,OADgB,UAAYA,GACbC,cAGjBC,QAAQC,IAAIJ,MAAM

两个分隔符,规则很简单:

  • ; 分隔生成文件的行(上面只有一行,所以一个分号都没有);
  • , 分隔一行里的(segment),每一段记录一次「生成位置 ↔ 原始位置」的对应。

每一段由 1 个、4 个或 5 个数字组成,顺序是:

  1. 生成文件的列(相对上一个段)
  2. 源文件索引(相对上一个段)
  3. 原始文件行(相对上一个段)
  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)greetg,源文件第 9 列也是 greetg。对得上。

再看第三段 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) 里的 namenames[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 官方库对照验证。