什么是Sourcemap

Sourcemap 本质上是一个信息文件,里面储存着代码转换前后的对应位置信息。它记录了转换压缩后的代码所对应的转换前的源代码位置,是源代码和生产环境代码的映射。 Sourcemap 解决了在打包过程中,代码经过压缩,去空格以及 babel 编译转化后,由于源代码与生产环境代码之间差异性过大,造成无法debug的问题。

在生产环境文件中,会有一行底部注释 其中 sourceMappingURL 指向 sourcemap 文件地址,形如:

1
//# sourceMappingURL=http://example.com/path/to/your/sourcemap.map

Sourcemap作用

Sourcemap 构建了处理前以及处理后的代码之间的一座桥梁,方便定位生产环境中出现 bug 的位置。因为现在的前端开发都是模块化、组件化的方式,在上线前对 js 和 css 文件进行合并压缩容易造成混淆。如果对这样的线上代码进行调试,肯定不切实际,Sourcemap 的作用就是能够让浏览器的调试面版将生成后的代码映射到源码文件当中,开发者可以在源码文件中 debug,这样就会让调试轻松、简单很多。

Sourcemap的用法

为了调试工作能够使用 Sourcemap,你必须:

  1. 生产一个 Sourcemap 文件
  2. 在转换后的文件末尾加入一个注释,以 # 开始,声明参数 sourceMappingURL 指向 Sourcemap 文件所在位置。

这样,浏览器的开发者工具就会在 debug 时解析 sourcemap 文件定位源代码中变量的位置

Sourcemap的原理

为了更清晰的描述 sourcemap 的生成,我们用一个最简单的 case 来编译并生成 sourcemap:

1
2
3
4
5
6
7
8
9
10
11
// input
const example = () => {
console.log('example');
}

// output
"use strict";

var example = function example(){
console.log("example");
};

利用 babel 将上述代码转为 es5 的同时,我们可以得到一份 sourcemap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"version": 3,
"sources": [
"src/example.js"
],
"names": [
"example",
"console",
"log"
],
"mappings": ";;AAAA,IAAMA,OAAO,GAAG,SAAVA,OAAU,GAAM;AACpBC,EAAAA,OAAO,CAACC,GAAR,CAAY,SAAZ;AACD,CAFD",
"sourcesContent": [
"const example = () => {\n console.log(\"example\");\n};\n"
]
}

可以看到其有多个属性,分别代表着:

  • version: source map 的版本号。
  • sources: 转换前的文件。该项是一个数组,可能存在多个文件合并成一个文件。
  • names: 转换前的所有变量名和属性名。
  • mappings: 记录位置信息的字符串。
  • sourceContent: 原始内容。

其中最重要的,便是记录着原始代码和编译后代码映射关系的 mappings 字段。

mappings 是如何记录映射的

可以花两分钟简单思考一下,如果是你来设计 sourcemap,你会如何记录一份原始代码到编译后代码的映射?很简单,我将编译后的每一个单词,对应的原始位置都记录下来就可以了,需要注意的是,由于存在多个文件编译成一个文件的情况,所以我们需要记录下原始文件名:

编译后的位置(行/列) 编译后单词 原始文件名 原始位置(行/列) 原始单词
0, 0 var src/example.js 0, 0 const
0, 4 example src/example.js 0, 6 example
0, 11 = src/example.js 0, 13 =
0, 14 function src/example.js 0, 16 (
0, 23 example src/example.js 0, 6 example
0, 30 ( src/example.js 0, 16 (
0, 33 { src/example.js 0, 22 {

到这里,我们已经将第一行代码的原始信息记录下来了,可以表示为:

1
2
0|0|src/example.js|0|0, 0|4|src/example.js|0|6, 0|11|src/example.js|0|13, 0|14|src/example.js|0|16, 0|23|src/example.js|0|16, 0|30|src/example.js|0|16, 0|33|src/example.js|0|22

同样的,第二行代码与第三行代码的映射关系可以用相同的方式记录下来。当我们完成了映射关系的记录后,便需要考虑一个现实问题:只有 23 个字符的原始信息,我们需要用 150 个字符来记录其映射关系。有没有什么办法,可以用更少的字符记录呢?

现在,我们对照 sourcemap 的做法,将上面的信息进行逐层的优化:

对 mappings 的优化

  1. 省去输出文件中的行号,改用 ; 来标识换行

利用; 来标识换行,我们可以将上述的编码节省为:

1
0|src/example.js|0|0, 4|src/example.js|0|6, 11|src/example.js|0|13, 14|src/example.js|0|16, 23|src/example.js|0|16, 30|src/example.js|0|16, 33|src/example.js|0|22;
  1. 用索引标识变量名

前面我们提到 sourcemap 中的 names 数组,在 sourcemap 中,它会将变量名在 names 数组中的索引也记录下来,所以编码会变成如下:

1
0|src/example.js|0|0, 4|src/example.js|0|6|0, 11|src/example.js|0|13, 14|src/example.js|0|16, 23|src/example.js|0|16|0, 30|src/example.js|0|16, 33|src/example.js|0|22;
  1. 用索引来代替文件名

使用 sources 属性记录下来的原始文件数组,在记录原始信息时用索引代替,如 src/example.js 在 sources 中的索引为 0,所以可以进一步简化为:

1
0|0|0|0, 4|0|0|6|0, 11|0|0|13, 14|0|0|16, 23|0|0|16|0, 30|0|0|16, 33|0|0|22;
  1. 用相对位置来代替绝对位置

当文件内容巨大时,上面精简后的代码也有可能某些数字会随着增加而变得很长,如果一行的位置记录了某个位置,那么根据这一位置进行相对定位是可以到达一行内的任意位置。如:

编译后的位置(列) 编译后单词 原始文件名 原始位置(行/列) 原始单词
0 var src/example.js 0, 0 const
4(上一个位置+4) example src/example.js 0, 6 example
7(上一个位置+7) = src/example.js 0, 7 =
3(上一个位置+10) function src/example.js 0, 3 (
9(上一个位置+9) example src/example.js 0, -10 example
7(上一个位置+7) ( src/example.js 0, 10 (
3(上一个位置+3) { src/example.js 0, 6 {

所以我们的 mappings 继续被简化为:

1
0|0|0|0, 4|0|0|6|0, 7|0|0|7, 3|0|0|3, 10|0|10, 9|0|0|-10, 7|0|0|10, 3|0|0|6;
  1. VLQ 编码

如果我们可以想办法去掉每个单词之间的分隔符(在我们的例子中是 | ),我们可以进一步省下大量的字符。当然,限制我们去掉这个分隔符的问题是,我们无法在没有分隔符的帮助下区分 10010 是 10|0|10 还是 100|1|0,但我们可以设计一套方法,让我们能够在去掉分隔符的情况下依然能够正确的分组。sourcemap 使用了这一套方法:

在二进制中,使用 6 个字节比特来记录一个数字,用其中一个字节来标识它是否结束(下方 C),再用一位标识正负(下方 S),剩下还有四位用来表示数值。用这样 6 个字节来表示我们需要的数字。

B5 B4 B3 B2 B1 B0
C value value value value S
十进制 二进制
4 100
0 0
6 110

任意数字中,第一组的第一个比特就已经明确标明该数字的正负,所以后续比特不需要再标识,也就是说,第一组有 4 个比特来表示数值,后续每一组都有 5 个 比特来表示数值(每组依然有一个比特标识是否结束) 我们用上述的简化过的 mappings 的第二项 4|0|0|6|0 为例:

所以它们应该被编码为:

4 B5 B4 B3 B2 B1 B0
0 0 1 0 0 0
0 B5 B4 B3 B2 B1 B0
0 0 0 0 0 0
6 B5 B4 B3 B2 B1 B0
0 0 1 1 0 0

注:如果是一个分组无法表达的数字,则会用第二个分组来容纳剩余部分,这里举个例子:23 的二进制为 10111,由于一个分组无法容纳,那么将 10111 分为两组,第一组是最后面的四位,既 10111,第二组是剩下的 10111,那么它最终会被编码为:101110 000001。

所以 4|0|0|6|0 最终被转化为 001000 000000 000000 001100 000000,随后再进行 base64 编码得到:

1
2
001000 000000 000000 001100 000000
I A A M A

之所以要用 6 个比特为一组记录一个数字,正是因为每一个 base64 编码最多可以表示二进制 6 位,所以通过这样的编码,我们将 4|0|0|6|0 转化为了 IAAMA。至此,我们便了解了 sourcemap 的原理和生成方式。

在 webpack 中使用 Sourcemap

可以在 webpack 配置文件中添加 devtool 参数用来说明 Sroucemap 的处理方式。常见的有以下几种:

devtool取值 作用
source-map 产生一个单独的source-map文件,功能最完全,但会减慢打包速度
inline (如 inline-source-map) 该模式不会生成一份独立的.map 文件,而是用 base64 编码将 sourcemap 进行编码后附在编译后代码的末处。缺点是这样会使得编译后代码的体积变得庞大,其他方面则和 source-map 模式一样。
eval 源码以字符的形式被 eval(…) 来调用,不会生成 sourceMap 信息,只会通过一个附着在各个模块后的 sourceURL 来存储原始文件的位置,同时,我们只能在控制台中看到经过 webpack 处理的编译后代码,所以它并不能反映真实的行号
eval-source-map 使用eval打包源文件模块,直接在源文件中写入干净完整的source-map,不影响构建速度,但影响执行速度和安全,建议开发环境中使用,生产阶段不要使用
cheap-source-map 生成的 sourcemap 只有行信息,不会记录列信息。cheap-source-map 记录下的是与被 loader 转化后的代码之间的映射
cheap-module-source-map 和 cheap-source-map 类似,但使用 cheap-module-source-map 可以记录下 loader 转译前的信息
cheap-module-eval-source-map 不会产生单独的map文件,(与eval-source-map类似)但开发者工具就只能看到行,无法对应到具体的列(符号),对调试不便
nosources 在这个模式下,会生成不包含 sourcecontent 的 sourcemap,具体表现为有错误堆栈信息但没有具体的内容
hidden 在这个模式下,会生成 sourcemap,但是不会将 sourcemapURL 信息附着在编译后代码中

此外还有一些模式的组合,具体可以是”^(inline-|hidden-|eval-)?(nosources-)?(cheap-(module-)?)?source-map$”