【转】深入SourceMap原理

原文链接:深入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|0

最终的映射信息变成了:

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© B4 B3 B2 B1 B0
0 1 1 0 0 0
  • 对3进行编码
B5© B4 B3 B2 B1 B0
0 0 0 1 1 0
  • 对456进行编码

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

B5© B4 B3 B2 B1 B0 B5© B4 B3 B2 B1 B0
1 1 0 0 0 0 0 1 1 1 0 0
  • 对7进行编码
B5© 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原理之后,日后再去使用它肯定能够得心应手。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容