正确使用 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 版本,目前最新版本为3names:字符串数组,记录原始代码中出现的变量名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 配置项设置规则短语;二是直接使用 SourceMapDevToolPlugin 或 EvalSourceMapDevToolPlugin 插件深度定制生成逻辑。下面先展开介绍比较晦涩的 devtool 配置项。
2.1 devtool 的七个关键字
devtool 支持 25 种字符串枚举值,包括 eval、source-map、eval-source-map 等,单独看都特别晦涩。但仔细观察就会发现,这些值都是由 inline、eval、source-map、nosources、hidden、cheap、module 七个关键字组合而成,每个关键字各自代表一项独立的 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-map 的 mappings 会退化为 AAAA 这样只含行信息的短串,浏览器点击报错只能跳到对应的行;而完整的 source-map 则能精确定位到行和列。虽然精度下降,但很多时候定位到行已经足够调试,此时用 cheap 可以显著减小 .map 文件体积。
module:module 只在 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 配置项本质上只是一组方便记忆的规则缩写,底层处理逻辑实际由 SourceMapDevToolPlugin 与 EvalSourceMapDevToolPlugin 两个插件实现。在 devtool 之上,插件提供了更细粒度的配置项以满足复杂场景:用 test、include、exclude 指定对哪些 bundle 生成 Sourcemap,用 append、filename、moduleFilenameTemplate、publicPath 定制 .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 这一层。
