关于我
技术随笔

正确使用 Sourcemap

拆解 Sourcemap 的协议构成与 mappings/VLQ 编码规则,再把 Webpack devtool 的 25 种取值还原成七个关键字的组合,讲清每个关键字换来什么、代价是什么,以及开发与生产环境各自该怎么选。

生产环境的代码经过压缩、混淆、合并,早已面目全非。线上一旦报错,堆栈里指向的是 bundle.js 第 1 行第几万列,对定位问题几乎没有帮助。Sourcemap 就是为解决这个矛盾而生的:它记录了产物代码与原始代码之间的位置映射,让浏览器能把压缩后的报错位置还原回你写下的那一行。

本文分两部分:先讲清 Sourcemap 协议本身是什么、mappings 与 VLQ 的编码规则到底怎么运作;再回到 Webpack,把晦涩的 devtool 配置项还原成几个关键字的组合,讲清每种取值的取舍与适用场景。

1. 什么是 Sourcemap

Sourcemap 协议最初由 Google 设计并率先在 Closure Inspector 实现,它能够将经过压缩、混淆、合并的代码还原回未打包状态,帮助开发者在生产环境中精确定位问题发生的行列位置。

发展至今,Sourcemap 已广泛受 Webpack、Rollup、Babel、Less、TypeScript、Chrome、Safari、VS Code 等工具支持。

实现上,Sourcemap 由三部分组成:

  • 开发者编写的原始代码
  • 经过 Webpack、Rollup 等工程化工具压缩、转化、合并后的产物,且产物中必须包含指向 Sourcemap 文件地址的 //# sourceMappingURL=https://xxxx/bundle.js.map 指令
  • 记录原始代码与工程化处理后代码之间位置映射关系的 Map 文件

页面初始运行时只会加载编译构建产物,直到特定事件发生——例如在 Chrome 打开 Devtool 面板时,才会根据 //# sourceMappingURL 内容自动加载 Map 文件,并按 Sourcemap 协议约定的映射规则将代码还原回原始形态。这样既能保证终端用户的性能体验,又能帮助开发者快速还原现场,提升线上问题的定位与调试效率。

协议原始设计文档:https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k

1.1 Map 文件的结构

以 Webpack 为例,设置 devtool = 'source-map' 即可在打包出代码产物 xxx.js 的同时,额外生成同名的 xxx.js.map 文件。Map 文件通常为 JSON 格式,内容如:

{
    "version": 3,
    "sources": [
        "webpack:///./src/index.js"
    ],
    "names": ["name", "console", "log"],
    "mappings": ";;;;;AAAA,IAAMA,IAAI,GAAG,QAAb;AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E",
    "file": "main.js",
    "sourcesContent": [
        "const name = 'tecvan';\n\nconsole.log(name)"
    ],
    "sourceRoot": ""
}

各字段含义分别为:

  • version:指代 Sourcemap 版本,目前最新版本为 3
  • names:字符串数组,记录原始代码中出现的变量名
  • file:字符串,该 Sourcemap 文件对应的编译产物文件名
  • sourcesContent:字符串数组,原始代码的内容
  • sourceRoot:字符串,源文件根目录
  • sources:字符串数组,原始文件路径名,与 sourcesContent 内容一一对应
  • mappings:字符串,记录打包产物与原始代码的位置映射关系

使用时,浏览器会按照 mappings 记录的数值关系,将产物代码映射回 sourcesContent 数组所记录的原始代码文件、行、列位置。这里面最复杂难懂的点就在于 mappings 字段的规则。

1.2 mappings 与 VLQ 编码

Sourcemap 最初版本生成的 .map 文件非常大,体积大约为编译产物的 10 倍;V2 引入 base64 编码等算法将之减少 20%~30%;最新的 V3 又在 V2 基础上引入 VLQ 等算法,体积进一步压缩了 50%。这一系列进化造就了一个效率极高的 Sourcemap 体系,代价则是较为复杂的 mappings 编码规则。

以下面这段代码的编译前后为例,逐层拆解 mappings 是怎么编码的。

编译前:

const name = 'tecvan';
console.log(name)

编译后(devtool = 'source-map'):

/******/ (() => { // webpackBootstrap
var __webpack_exports__ = {};
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
var name = 'tecvan';
console.log(name);
/******/ })()
;
//# sourceMappingURL=main.js.map

此时 Webpack 生成的 mappings 字段为 ;;;;;AAAA,IAAMA,IAAI,GAAG,QAAb;AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E,它包含三层结构。

第一层是; 分割的行映射,每一个 ; 对应产物中的一行到源码的映射。上例分割后:

[
  // 产物第 1-5 行为 Webpack 生成的 runtime,不需要记录映射关系
  '', '', '', '', '',
  // 产物第 6 行的映射信息
  'AAAA,IAAMA,IAAI,GAAG,QAAb',
  // 产物第 7 行的映射信息
  'AAEAC,OAAO,CAACC,GAAR,CAAYF,IAAZ,E'
]

第二层是, 分割的片段映射,每一个 , 对应该行中一个代码片段到源码的映射。上例第 6、7 行继续分割后:

[
  '', '', '', '', '',
  // 产物第 6 行的片段
  ['AAAA', 'IAAMA', 'IAAI', 'GAAG', 'QAAb'],
  // 产物第 7 行的片段
  ['AAEAC', 'OAAO', 'CAACC', 'GAAR', 'CAAYF', 'IAAZ', 'E']
]

第三层是每个片段到源码具体位置的映射。以片段 IAAMA 为例,它由五位组成:

  • 第一位 I:该片段在产物中的列数
  • 第二位 A:源码文件的索引,即对标到 sources 数组的元素下标
  • 第三位 A:片段在源码文件中的行数
  • 第四位 M:片段在源码文件中的列数
  • 第五位 A:该片段对应的名称索引,即对标到 names 数组的元素下标

前两层逻辑比较简单,唯一需要注意的是片段之间是相对偏移关系。例如第 6 行各片段的第一位(列数)为 A,I,I,G,Q,它们表达的实际列数是累加的:A 是第 A 列,第二个 I 是第 A+I 列,第三个 I 是第 A+I+I 列,以此类推。用相对偏移而非绝对值,能进一步压缩 Sourcemap 的体积。

真正复杂的是第三层——片段位置的具体数值,它用到了一种高效的数值编码算法 VLQ(Variable-length Quantity)。

VLQ 编码规则:VLQ 本质上是把整数转换为 Base64 的算法,它先将整数转换为一系列六位分组,再按 Base64 规则映射为可见字符。每个六位分组的结构是:第一位为连续标志位,标识后续分组是否属于同一数字;第六位为符号位,0 表示正数、1 表示负数;中间的第 2-5 位才是实际数值。

以数字 7 为例,编码结果为 001110,等于十进制的 14,按 Base64 字码表映射为字母 O

由于单个分组只有中间 4 位表示数值,它只能表达 -15~15 之间的范围。超过这个范围的整数需要用多个分组组合表达,规则是:只有第一个分组的最后一位是符号位,其余分组的第 2-6 位都是数值位;取二进制的最后四位作为第一个分组,之后从后往前每 5 位划一个分组;除最后一个分组外,其余分组的连续标志位都置为 1。

例如十进制 -17,二进制为 10001,从后往前拆成两组:后四位 0001 为第一组(连续位 1、符号位 1,结果 1,0001,1),剩下的 1 为最后一组(连续位 0,结果 0,00001),按 Base64 映射为 jA

十进制     二进制               VLQ    Base64
  -17 => 1,0001 => 100011, 000001 =>     jA

再如更大的 1200,二进制为 10010110000,拆成三组 [10, 01011, 0000],从后往前编码后按 Base64 映射为 grC

十进制            二进制                     VLQ    Base64
 1200 => 10;01011;0000 => 100000,101011,000010 =>    grC

回过头解码开头的例子。结合 VLQ 规则,重新解读第 6 行片段 ['AAAA', 'IAAMA', 'IAAI', 'GAAG', 'QAAb']

  • AAAA 解码为 [0, 0, 0, 0],即产物第 6 行第 0 列映射到 sources[0] 文件的第 0 行第 0 列,对应产物 var 到源码 const 的位置映射
  • IAAMA 解码为 [4, 0, 0, 6, 0],即产物第 6 行第 4 列映射到 sources[0] 文件的第 0 行第 6 列,对应产物 name 到源码 name 的位置映射

其它片段以此类推。理解了这套分层 + 相对偏移 + VLQ 的组合,就理解了 Sourcemap 高效压缩的全部秘密。

2. 使用 Sourcemap

Webpack 提供了两种设置 Sourcemap 的方式:一是通过 devtool 配置项设置规则短语;二是直接使用 SourceMapDevToolPluginEvalSourceMapDevToolPlugin 插件深度定制生成逻辑。下面先展开介绍比较晦涩的 devtool 配置项。

2.1 devtool 的七个关键字

devtool 支持 25 种字符串枚举值,包括 evalsource-mapeval-source-map 等,单独看都特别晦涩。但仔细观察就会发现,这些值都是由 inlineevalsource-mapnosourceshiddencheapmodule 七个关键字组合而成,每个关键字各自代表一项独立的 Sourcemap 规则。逐个拆开就不难理解了。

eval:当 devtool 包含 eval 时,生成的模块代码会被包裹进一段 eval 函数中,且模块的 Sourcemap 信息通过 //# sourceURL 直接挂载在模块代码内,例如 eval("var foo = 'bar'\n\n\n//# sourceURL=webpack:///./src/index.ts?")eval 模式编译速度通常很快,但产物中直接包含了 Sourcemap 信息,因此只推荐在开发环境使用。

source-map:当 devtool 包含 source-map 时,Webpack 才会真正生成独立的 .map 文件。实际上,除 eval 之外的其它枚举值都隐含该字段。

cheap:当 devtool 包含 cheap 时,生成的 Sourcemap 会抛弃维度的信息,浏览器只能映射到代码行。对比来看,cheap-source-mapmappings 会退化为 AAAA 这样只含行信息的短串,浏览器点击报错只能跳到对应的行;而完整的 source-map 则能精确定位到行和列。虽然精度下降,但很多时候定位到已经足够调试,此时用 cheap 可以显著减小 .map 文件体积。

modulemodule 只在 cheap 场景下生效,例如 cheap-module-source-map。它决定 source 取的是 loader 处理之前的原始代码,还是 loader 处理之后的结果。带 module 时,sourcesContent 里映射的是包含 class Person 之类语法的最原始代码;不带 module 时,映射的则是经过 babel-loader 编译降级后的内容。调试时通常希望看到自己写的原始代码,所以生产排查更倾向带上 module

nosources:当 devtool 包含 nosources 时,生成的 Sourcemap 不包含 sourcesContent 字段,也就是不带源码内容。但 .map 中仍保留文件名、mappings、变量名等信息,依然能帮开发者定位到原始位置。配合 Sentry 等工具的源码映射功能,可以在异地还原错误堆栈,同时避免把源码直接暴露出去。

inline:当 devtool 包含 inline 时,Webpack 会把 Sourcemap 编码为 Base64 DataURL,直接追加到产物文件末尾(//# sourceMappingURL=data:application/json;base64,...)。inline 模式编译较慢、产物体积很大,只适合开发环境。

hidden:通常产物中必须携带 //# sourceMappingURL= 指令,浏览器才能找到 Sourcemap 文件。当 devtool 包含 hidden 时,编译产物中不写入这条指令——.map 文件照常生成,只是浏览器 Devtool 不会自动加载。当你需要 Sourcemap 功能,又不希望它被自动加载时,可以用这个选项,并在需要时手动打开:

2.2 常见取值的组合与选择

理解了七个关键字后,任何 devtool 取值都可以拆开来读,例如 cheap-source-map 就是"不带列映射的 Sourcemap",eval-nosources-cheap-source-map 则是"以 eval 包裹模块代码、.map 中不带源码、且不带列映射的 Sourcemap"。其它取值以此类推。

按环境归纳一下常用的组合。开发环境追求编译速度和调试体验:

  • eval:速度极快,但只能看到原始文件结构,看不到打包前的代码内容
  • cheap-eval-source-map:速度较快,能看到打包前的代码内容,但看不到 loader 处理之前的源码
  • cheap-module-eval-source-map:速度较快,能看到 loader 处理之前的源码,但定位不到列级别
  • eval-source-map:初次编译较慢,但定位精度最高

生产环境则要在信息完整度和源码安全性之间权衡:

  • source-map:信息最完整,但安全性最低,外部用户可轻易拿到压缩混淆前的源码,需慎用
  • hidden-source-map:信息较完整,安全性也偏低,外部一旦拿到 .map 地址仍能还原源码
  • nosources-source-map:不含源码内容,安全性较高,需配合 Sentry 等工具才能实现完整映射

可以看出,devtool 的选择本质上是在编译速度、定位精度、源码安全这三者之间做取舍,没有一个万能选项,只有匹配当前环境诉求的组合。

2.3 用插件做深度定制

devtool 配置项本质上只是一组方便记忆的规则缩写,底层处理逻辑实际由 SourceMapDevToolPluginEvalSourceMapDevToolPlugin 两个插件实现。在 devtool 之上,插件提供了更细粒度的配置项以满足复杂场景:用 testincludeexclude 指定对哪些 bundle 生成 Sourcemap,用 appendfilenamemoduleFilenameTemplatepublicPath 定制 .map 文件的文件名与 URL。

const webpack = require('webpack');
module.exports = {
  // ...
  devtool: false,
  plugins: [new webpack.SourceMapDevToolPlugin({
    exclude: ['vendor.js']
  })],
};

插件完整配置项参考官方文档:https://webpack.js.org/plugins/source-map-dev-tool-plugin/

3. 总结

Sourcemap 是一套高效的位置映射方案:它把产物到源码的位置关系用 mappings 的三层分割结构表达,再用相对偏移和 VLQ 编码把体积压到极致,最后由 Chrome、Safari、VS Code、Sentry 等工具在异地还原回接近开发状态的源码。理解了 mappings 与 VLQ,就理解了 Sourcemap 为什么又小又准。

回到工程实践,Webpack 把这套能力封装成了 devtool 的一组关键字组合。选择哪一种,取决于你当前更在意编译速度、定位精度还是源码安全——开发环境优先前两者,生产环境则要把安全性放进权衡。绝大多数场景下选对 devtool 短语就够了,只有更复杂的定制需求,才需要落到 SourceMapDevToolPlugin 这一层。

On this page