Source map

源映射指的是将转换之后的代码映射回原来的代码,比如在我们进行项目的生产构建时,常常会将代码进行压缩以提升运行效率。

源码转换通常存在于以下几种情况:

  • 压缩代码从而减小体积。
  • 合并多个文件以减少 http 请求数。
  • 编译其他预处理语言。

但打包之后的代码常常晦涩难懂,并且开发代码不用于运行代码,不好调试。

当我们直接运行没经过处理的代码,出现了问题我们可以第一时间定位到错误代码的位置。但对于处理过的,比如压缩后的代码,你根本看不出它所对应的源码位置在哪里。

这就是压缩过后的代码,讲真,🐶 都不看。

在我们使用打包工具如 Webpack,Rollup 等,配置了 SourceMap 后(开发环境下会自动开启该功能)。

Webpack 的 Source map

在 Webpack 中(这里特指 Webpack5),配置 devtool 属性可以开启源码映射功能,Webpack 有几十种 Source map 配置,因此这里不多讲,详情请看:Devtoolopen in new window

对于开发环境,官方认为最佳的配置是 eval-source-map,这表示每个模块使用 eval() 执行,并且 Source map 转换为 DataUrl 后添加到 eval() 中。初始化 Source map 时比较慢,但是会在重新构建时提供比较快的速度,并且生成实际的文件。行数能够正确映射,因为会映射到原始代码中。它会生成用于开发环境的最佳品质的 Source map。

许多项目使用 eval 配置 devtool,这样会更快速地构建项目,由于会映射到转换后的代码,而不是映射到原始代码,所以不能正确的显示行数。每个被处理的文件最后一行都会有类似的注释:

//@ sourceURL=...

表示该文件对应的源映射文件的位置(在老版本的 Webpack 中是使用 base64 格式的路径)。

Notice

并不是所有浏览器都支持 Source map,一些浏览器只会将文件最下面的特殊注释看成普通注释。

如何实现映射

说了这么多,只知道 Source map 是什么,那么它是如何实现的呢?

下面内容大多数参考:JavaScript Source Map 详解open in new window

一个 .map 文件通常包含以下内容:

{
  version : 3,
  file: "out.js",
  sourceRoot : "",
  sources: ["foo.js", "bar.js"],
  names: ["src", "maps", "are", "fun"],
  mappings: "AACvB,gBAAgB,EAAE;AAClB;"
}
  • version:源映射版本。
  • file:转换后文件名。
  • sourceRoot:转换前文件所在目录。如果与转换前的文件在同一目录,该项为空。
  • sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并。
  • names:转换前的所有变量名和属性名。
  • mappings:记录位置信息的字符串。

mappings 是整个文件的关键,该属性值是一个很长的字符串,分为三层:

  • 行对应:每一行之间用分号 ; 相隔开,第一个分号前的内容就是第一行,以此类推。
  • 位置对应:每个逗号 , 对应源码中的一个位置,第一个分号前的内容就是该行源码的第一个位置,以此类推。
  • 位置转换:以 VLQ 编码表示,代表该位置对应的转换前的源码位置。

我们可以发现,每一个位置都对应着多个 VLQ 编码,每个编码作用不同:

  • 第一位,表示这个位置在转换后代码的第几列。
  • 第二位,表示这个位置属于sources 属性中的哪一个文件。
  • 第三位,表示这个位置属于转换前代码的第几行。
  • 第四位,表示这个位置属于转换前代码的第几列。
  • 第五位和第六位,表示这个位置属于names 属性的哪一个变量。

所有的值都是以 0 作为基数的。其次,第五位和第六位不是必需的,如果该位置没有对应 names 属性中的变量,可以省略第五位和第六位。再次,每一位都采用 VLQ 编码表示;由于 VLQ 编码是变长的,所以每一位可以由多个字符构成。

VLQ

VLQ(Variable-length quantity) 是一种通用的、使用任意位数的二进制来表示一个任意大的数字的一种编码方式。这种编码最早用于 MIDI 文件,后来被多种格式采用,它的特点就是可以非常精简地表示很大的数值,用来节省空间。

上图就是 base64 位字符对应的编码表。

举个例子:MACdC,以十进制数字可表示为 12 0 2 29 2。我们知道数字在计算机中是以二进制存储和计算的,64 进制数可以表示为 6 位 2 进制数,所以又可以转换为:

12 --> 001100
0  --> 000000
2  --> 000010
29 --> 011101
2  --> 000010

在这六位二进制数中:

  • 第一位:是否连续,代表这6个位后面的 6 个位也属于同一个数。如果是 0,表示该数值到这 6 个位结束。
  • 中间四位:数值位
  • 末位:符号位,如果是 0,代表该数值是一个整数。如果是 1 代表该数值是一个负数。

中间 4 位的二进制数,算上符号位,可以表示 [-15, 15] 区间内的 31 位数。

因此,算数值位和符号位,MACdC 真正代表的其实是:6 0 1 -14 1。相对应的是:

  • 转换后代码的第 7 列。
  • 代码属于 sources 中的第一个文件。
  • 属于转换前代码的第 2 行。
  • 属于转换前代码的相对位置的 -13 列。
  • 属于 names 中的第 2 个变量。

我们来看下这个例子(来自于source map 原理分析&vlqopen in new window):

{
  "sources":["test.js"],
  "names":["sayHello","name","console","log"],
  "mappings":"AAAA,SAASA,SAASC,MACdC,QAAQC,IAAI,SAAUF"
}

我们来分析一下 mappings 里面的 VLQ 编码,按照编码表可以转换成:

base64编码      十进制        转成二进制取出数值和符号位
  AAAA   --> 0 0 0 0     -->   0 0 0 0
  SAASA  --> 18 0 0 18 0 -->   9 0 0 9 0
  SAASC  --> 18 0 0 18 2 -->   9 0 0 9 1
  MACdC  --> 12 0 2 29 2 -->   6 0 1 -14 1
  QAAQC  --> 16 0 0 16 2 -->   8 0 0 8 1
  IAAI   --> 8 0 0 8     -->   4 0 0 4
  SAAUF  --> 18 0 0 20 5 -->   9 0 0 10 -2

得出了最终的数值,在知道每一位所代表的意义之后,我们就能理解 VLQ 编码了。

总结

对于前端开发来说,Source map 是非常必要且好用的 debug 工具,它通过 VLQ 编码来实现源代码到打包后代码的映射。

但请注意:在生产环境下开启 Source map 让我们即使运行打包后的代码也能找出源码中的错误。但这样做会显著的增加项目包的大小,因为项目中会包含许多源映射 .map 文件。

参考文章