阅读 2037

深入理解SourceMap

一 Source Map是什么?

Source Map,顾名思义,是保存源代码映射关系的文件,相信用过webpack的开发者对它应该不会陌生。在项目开发完进行打包后,在打包的文件夹里通常除了js,css,图片字体等资源文件外,大家一定还见过xxx.js.map的文件。这种带map后缀的文件就是Source Map文件——它保存了源代码和转换之后代码(通常经过压缩混淆和其他转换)的关系。 下图展示了部分打包之后生成的Source Map文件:

WX20190614-151320.png

下面是一个典型的Source Map文件的格式:

{
  "version": 3,
  "sources": [
    "log.js",
    "main.js"
  ],
  "names": [
    "sayHello",
    "name",
    "length",
    "substr",
    "console",
    "log"
  ],
  "mappings": "AAAA,SAASA,SAAUC,MACjB,GAAIA,KAAKC,OAAS,EAAG,CACnBD,KAAOA,KAAKE,OAAO,EAAG,GAAK,MAE7BC,QAAQC,IAAI,QAASJ,MCJvBD,SAAS"
}
复制代码

二 为什么使用Source Map?

明白了什么是Source Map之后,大家肯定有个疑问,我们为什么需要Source Map? 由于现代前端项目的发展,前端代码变得越来越庞大和复杂。大部分源码都需要经过转换,才能投入到生产环境中使用。 常见的转换过程包括但不限于:

  • 压缩混淆(UglifyJS)
  • 编译(TypeScript, CoffeeScript)
  • 转译(Babel)
  • 合并多个文件,减少带宽请求。

经过上述转换过程的代码,往往都会变得面目全非,就像下面这样:

WX20190614-154341.png
这样虽然对带宽很友好,但是调试起来就不是那么轻松了。我们在代码出错的时候,肯定最希望能定位其在源码中的位置。 比如下面这两个错误提示:
WX20190614-160302.png
WX20190614-160413.png
对于大多数开发者来说,希望看到的应该是第二种提示方式,而这就是Source Map能够输出的能力。

三 Source Map是怎么实现映射的?

在探索这个问题之前,可以先想想真实世界里对这种语言转换是怎么做的?

I AM CHRIS ——> Map ——> CHRIS I AM
复制代码

现在我们要从CHRIS I AM还原到I AM CHRIS,Map里应该存储哪些信息呢?

3.1 最简单粗暴的方法

将输出文件中每个字符位置对应在输入文件名中的原位置保存起来,并一一进行映射。上面的这个映射关系应该得到下面的表格:

字符 输出位置 在输入中的位置 输入的文件名
c 行1,列1 行1,列6 输入文件1.txt
h 行1,列2 行1,列7 输入文件1.txt
r 行1,列3 行1,列8 输入文件1.txt
i 行1,列4 行1,列9 输入文件1.txt
s 行1,列5 行1,列10 输入文件1.txt
i 行1,列7 行1,列1 输入文件1.txt
a 行1,列9 行1,列3 输入文件1.txt
m 行1,列10 行1,列4 输入文件1.txt

备注: 由于输入信息可能来自多个文件,所以这里也同时记录输入文件的信息。

将上面表格整理成映射表的话,看起来就像这样(使用"|"符号分割字符)

mappings: "1|1|输入文件1.txt|1|6,1|2输入文件1.txt|1|7,1|3|输入文件1.txt|1|8,1|4|输入文件1.txt|1|9,1|5|输入文件1.txt|1|10,1|7|输入文件1.txt|1|1,1|9|输入文件1.txt|1|3,1|10|输入文件1.txt|1|4"(长度:144复制代码

这种方法确实能将处理后的内容还原成处理前的内容,但是随着内容的增加,转换规则的复杂,这个编码表的记录将飞速增长。目前仅仅10个字符,映射表的长度已经达到了144个字符。如何进一步优化这个映射表呢?

备注: mappings: "输出文件行位置|输出文件列位置|输入文件名|输入文件行号|输入文件列号,....."

3.2 优化手段1:不要输出文件中的行号

在经历过压缩和混淆之后,代码基本上不会有多少行(特别是JS,通常只有1到2行)。这样的话,就可以在上节的基础上移除输出位置的行数,使用";"号来标识新行。 那么映射信息就变成了下面这样

mappings: "1|输入文件1.txt|1|6,2|输入文件1.txt|1|7,3|输入文件1.txt|1|8,4|输入文件1.txt|1|9,5|输入文件1.txt|1|10,7|输入文件1.txt|1|1,9|输入文件1.txt|1|3,10|输入文件1.txt|1|4; 如果有第二行的话"(长度:129复制代码

备注: mappings: "输出文件列位置|输入文件名|输入文件行号|输入文件列号,....."

3.3 优化手段2:提取输入文件名

由于可能存在多个输入文件,且描述输入文件的信息比较长,所以可以将输入文件的信息存储到一个数组里,记录文件信息时,只记录它在数组里的索引值就好了。 经过这步操作后,映射信息如下所示:

sources: ['输入文件1.txt'],
mappings: "1|0|1|6,2|0|1|7,3|0|1|8,4|0|1|9,5|0|1|10,7|0|1|1,9|0|1|3,10|0|1|4;" (长度:65)
复制代码

经过转换后mappings字符数从129下降到了65。

备注: mappings: "输出文件列位置|输入文件名索引|输入文件行号|输入文件列号,....."

3.4 优化手段3: 可符号化字符的提取

经过上一步的优化,mappings字符数有了很大的下降,可见提取信息是一个很有用的简化手段,那么还有什么信息是能够提取的么? 当然。已输出文件中的Chris字符为例,当我们找到了它的首字符C在源文件中的位置(行1,列6)时,就不需要再去找剩下的hris的位置了,因为Chris可以作为一个整体来看待。想想源码里的变量名,函数名,都是作为一个整体存在的。 现在可以把作为整体的字符提取并存储到一个数组里,然后和文件名一样,在mapping里只记录它们的索引值。这样就避免了每一个字符都要记的窘境,大大缩减mappings的长度。

添加一个包含所有可符号化字符的数组:

names: ['I', 'am', 'Chris']
复制代码

那么之前Chris的映射就从

1|0|1|6,2|0|1|7,3|0|1|8,4|0|1|9,5|0|1|10
复制代码

变成了

1|0|1|6|2
复制代码

最终的映射信息变成了:

sources: ['输入文件1.txt'],
names: ['I', 'am', 'Chris'],
mappings: "1|0|1|6|2,7|0|1|1|0,9|0|1|3|1" (长度: 29)
复制代码

备注: 1. “I am Chris"中的"I"抽出来放在数组里,其实意义不大,因为它本身也就只有一个字符。但是为了演示方便,所以拆出来放在数组里。 2. mappings: "输出文件列位置|输入文件名索引|输入文件行号|输入文件列号|字符索引,....."

3.5 优化手段4: 记录相对位置

前面记录位置信息(主要是列)时,记录的都是绝对位置信息,设想一下,当文件内容比较多时,这些数字可能会变的很大,这个问题怎么解决呢? 可以通过只记录相对位置来解决这个问题(除了第一个字符)。 来看一下具体怎么实现的,以之前的mappings编码为例:

mappings: "1(输出列的绝对位置)|0|1|6(输入列的绝对位置)|2,7(输出列的绝对位置)|0|1|1(输入列的绝对位置)|0,9(输出列的绝对位置)|0|1|3(输入列的绝对位置)|1"
复制代码

转换成只记录相对位置

mappings: "1(输出列的绝对位置)|0|1|6(输入列的绝对位置)|2,6(输出列的相对位置)|0|1|-3(输入列的相对位置)|0,2(输出列的相对位置)|0|1|-2(输入列的绝对位置)|1"
复制代码

从上面的例子可能看不太出这个方法的好处,但是当文件慢慢大起来,使用相对位置可以节省很多字符长度,特别是对于记录输出文件列信息的字符来说。

3.6 优化手段5: VLQ编码

经过上面几步操作之后,现在最应该优化的地方应该就是用来分割数字的"|"号了。 这个优化应该怎么实现呢? 在回答之前,先来看这样一个问题——如果你想顺序的记录4组数字,最简单的就是用"|"号进行分割。

1|2|3|4
复制代码

如果每个数字只有1位的话,可以直接表示成

1234
复制代码

但是很多时候每个数字不止有1位,比如

12|3|456|7
复制代码

这个时候,就一定得用符号把各个数字分割开,像我们上面例子中一样。还有好的方法嘛? 通过VLQ编码的方式,你可以很好的处理这种情况,先来看看VLQ的定义:

3.6.1 VLQ定义

A variable-length quantity (VLQ) is a universal code that uses an arbitrary number of binary octets (eight-bit bytes) to represent an arbitrarily large integer. 翻译一下:VLQ是用任意个2进制字节组去表示一个任意数字的编码形式。

VLQ的编码形式很多,这篇文章中要说明的是下面这种:

WX20190615-165403.png

  • 一个组包含6个二进制位。
  • 在每组中的第一位C用来标识其后面是否会跟着另一个VLQ字节组,值为0表示其是最后一个VLQ字节组,值为1表示后面还跟着另一个VLQ字节组。
  • 在第一组中,最后1位用来表示符号,值为0则表示正数,为1表示负数。其他组的最后一位都是表示数字。
  • 其他组都是表示数字。

这种编码方式也称为Base64 VLQ编码,因为每一个组对应一个Base64编码。

3.6.2 小例子说明VLQ

现在我们用这套VLQ编码规则对12|3|456|7进行编码,先将这些数字转换为二进制数。


12  ——> 1100
3   ——> 11
456 ——> 111001000
7   ——> 111
复制代码
  • 对12进行编码

12需要1位表示符号,1位表示是否延续,剩下的4位表示数字

B5(C) B4 B3 B2 B1 B0
0 1 1 0 0 0
  • 对3进行编码
B5(C) B4 B3 B2 B1 B0
0 0 0 1 1 0
  • 对456进行编码

从转换关系中能够看到,456对应的二进制已经超过了6位,用1组来表示肯定是不行的,这里需要用两组字节组来表示。先拆除最后4个数(1000)放入第一个字节组,剩下的放在跟随字节组中。

B5(C) B4 B3 B2 B1 B0 B5(c) B4 B3 B2 B1 B0
1 1 0 0 0 0 0 1 1 1 0 0
  • 对7进行编码
B5(C) B4 B3 B2 B1 B0
0 0 1 1 1 0

最后得到下列VLQ编码:

011000 000110 110000 011100 001110
复制代码

通过Base64进行转换之后:

bg2013012202 (1).png
最终得到下列结果:

YGwcO
复制代码

3.6.3 转换之前的例子

通过上面这套VLQ的转换流程转换之前的例子,先来编码1|0|1|6|2. 转换成VLQ为:

1 ——> 1(二进制) ——> 000010(VLQ)
0 ——> 0(二进制) ——> 000000(VLQ)
1 ——> 1(二进制) ——> 000010(VLQ)
6 ——> 110(二进制) ——> 001100(VLQ)
2 ——> 10(二进制) ——> 000100(VLQ)
复制代码

合并后编码为

000010 000000 000010 001100 000100
复制代码

转换成Base64

BABME
复制代码

其他也是按这种方式编码,最后得到的mapping文件如下:

sources: ['输入文件1.txt'],
names: ['I', 'am', 'Chris'],
mappings: "BABME,OABBA,SABGB" (长度: 17)
复制代码

和第一节的mappings文件对比一样,是不是一样呢?

3.6.4 one more thing

在真实场景中,我们在mappings中经常可以看到不是5位的字符。大于5位好理解,可能表示的数字太大。比如123456789转换成Base64 VLQ编码就是qxmvrH。而少于5位的情况在于mappings的编码片段中可能不需要那么多信息就能进行映射,比如不需要Names属性,这样只通过4位信息也就能获取到映射关系了。一个编码片段可能会有以下几种长度:

  • 5 - 包含全部五个部分:输出文件中的列号,输入文件索引,输入文件中的行号,输入文件中的列号,符号索引
  • 4 - 输出文件中的列号,输入文件索引,输入文件中的行号,输入文件中的列号
  • 1 - 输出文件中的列号

通过上面的讲解,相信大家一定对Source Map是如何映射源码转换后代码之间的位置关系有所了解。在了解了Source Map原理之后,日后再去使用它肯定能够得心应手。