跳到主要内容

原理-SourceMap

简单的来说, Source Map就是一个信息文件, 里面存储了位置信息. 也就是说, 转换后的代码的每一个位置, 所对应的转换前的位置. 这样一来, 在浏览器中进行断点调试的时候, 就能直接显示原始代码, 而不是转换后的代码.

为什么要用 srouce Map就是一个信息文件

一般来说, 我们发布到生产环境的代码都进行了如下的操作:

  • 通过编译和转译, 将其他语言的代码(typescript)编译成javascript
  • 多个文件合并, 减少HTTP请求数量
  • 压缩混淆, 减小代码体积

对于线上代码报错, 直接通过源码来捕获和调试都是非常困难的.

有了Source Map, 出错的时候我们就可以直接定位到原始代码, 而不是转换后的代码.

启用 source map

source map 主要要求浏览器支持, 目前主流PC端浏览器支持SourceMap的情况如下:

image

假设我们有个app.js文件, 则会有一个同名的map文件在同一个路径下面:

- app.js
- app.js.map

app.js的文件末尾, 会有一行指向map文件的引用:

//# sourceMappingURL=app.js.map

其实还可以生成内嵌的sourcemap:

'//# sourceMappingURL=data:application/json;charset=utf8;base64,' + base64Map;

Source Map 文件解析

map文件的格式如下:

{
version : 3, //SourceMap的版本,目前为3
sources: ["foo.js", "bar.js"], //转换前的文件,该项是一个数组,表示可能存在多个文件合并
names: ["src", "maps", "are", "fun"], //转换前的所有变量名和属性名
mappings: "AACvB,gBAAgB,EAAE;AAClB;", //记录位置信息的字符串
file: "out.js", //转换后的文件名
sourcesContent: ["\t// The module cache\n", "xxx"], //转换前的文件内容列表,与sources列表依次对应
sourceRoot : "" //转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空
}

mappings

那么, 压缩过的文件和原始文件是如何一一对应的呢?

关键就在于map文件的mappings属性. 这是一个很长的字符串, 它分成三层.

  • 第一层: 行对应, 以分号;表示, 每个分号对应转换后源码的一行. 所以, 第一个分号前的内容, 就对应源码的第一行, 以此类推.
  • 第二层: 位置对应, 以逗号,表示, 每个逗号对应转换后源码的一个位置. 所以, 第一个逗号前的内容, 就对应该行源码的第一个位置, 以此类推.
  • 第三层: 位置转换, 以VLQ编码表示, 代表该位置对应的转换前的源码位置.

位置关系的对应

每个位置使用5位, 表示5个字段, 从左边开始:

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

这里有些概念需要说明:

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

映射关系实例

假设我们现在有一个a文件, 内容为:

feel the force

处理之后变成了b文件:

the force feel

以字符h为例, 它在输入中的位置为(0, 6), 在输出中的位置为(0, 1), 那么映射关系为:

1 | 0 | 0 | 5 | 1
// 输出列 | 输入文件下标 | 输入行 | 输入列 | 变量下标
  • 输入文件可以抽离出来放在数组中, 减少mapping文件的大小, names同理
  • 很多时候, 我们输出的文件都是同一行的, 这样输出的行号就可以省略

以这里的例子:

  • source: ['a.js']
  • names: ['feel', 'the', 'force']

mapping中的位置一直用的是绝对定位, 如果文件特别大的话, 行列就会很大, 因此我们可以用相对位置进行记录.

其中第一次输入的位置和输出位置是绝对的, 往后的输入位置和输出位置都是相对于上一次的位置移动了多少. 例如: the的输出位置为(0, -10), 因为thefeel的左边数10下才能到the的位置, 这样我们就得到了一个简单的mapping:

sources:['a.js']
names:['feel','the','force']
mappings:[10|0|0|0|0,-10|0|0|5|1,4|0|0|4|2]

然后, 对这些竖线分割的数字进行VLQ编码.

VLQ编码

VLQ是Variable-length quantity的缩写, 是一种通用的, 使用任意位数的二进制来表示一个任意大的数字的一种编码方式

这种编码最早用于MIDI文件, 后来被多种格式采用. 特点是可以非常精确的表示很大的数值.

VLQ编码是变长的. 每7位表示一个数字. 开头的第一位表示是否连续(continuation), 如果是1, 表示下面7位也是同一个数, 如果是0, 表示数据到此结束

VLQ 编码实例

比如将一个137进行VLQ编码:

  1. 将137改写为二进制: 10001001
  2. 7位一组分组, 不足的补0: 0000001 0001001
  3. 最后一组开头补0, 其余补1: 10000001 00001001

Base64 VLQ

如果整数值在-15+15之间(包含端点), 用一个字符表示. 超出这个范围就需要用多个字符表示.

与一般的VLQ的区别:

  • 一个Base64字符只能表示6bit(2^6)的数据
  • Base64 VLQ需要能够表示负数, 因此最后一位作为符号位, 0表示正数, 1表示负数
  • 只能用6位进行存储, 所以一个单元表示的范围为[-15, 15], 如果超过了就要使用连续标志位
Continuation
  |     Sign
  |     |
  V     V
  101011

而6个位中的右边最后一位, 取决于这6个位是否是某个数值的VLQ编码的第一个字符.

  • 如果是: 这一个为表示符号(sign), 0为正, 1位负(Source map的符号固定为0);
  • 如果不是: 这个位没有特殊含义, 算作数值的一部分;

6位的设计主要是为了可以借用base 64编码的字符表.

image

下面看一个例子, 如何对数值16进行VLQ编码:

  1. 将16改写为二进制: 10000
  2. 在最右边补充符号位, 因为16大于0, 所以符号位为0, 整个数字为: 10000 0
  3. 从右边的最低位开始, 将整个树每隔5位, 进行分段, 即变为100000两段. 如果最高位所在的段不足5位, 则前面补0, 也就是变成了0000100000
  4. 将两段的顺序颠倒, 即00000, 00001
  5. 在每一段的最前面添加一个'连续位', 除了最后一段为0, 其他都为1, 即100000000001
  6. 将每一段转换为Base 64编码, 查表可知100000为g, 000001为B

最终, 数值16被编码为gB.

小结

可以看出:

  • Base64 VLQ中, 编码顺序是从低位到高位
  • VLQ中, 编码顺序是从高位到低位

参考