source-map的作用在于关联编译后的代码和源码, 可以让我们实现打断点调试和线上错误代码定位, 基本原理就是记录源码和编译后的目标代码的位置, 来实现定位.
source-map的基本格式
- 所在位置
当我们执行webpack打包的时候, 会生成很多.map结尾的文件, 这些就是source-map文件
而每个source-map文件都有对应的js文件, 在这些生成js文件的底部, 又有//# sourceMappingURL=xxxxx.map这种注释, 浏览器会根据这个信息, 找到对应的source-map文件
- 基本格式
我们点开一个source-map文件, 格式化后, 可以看到其基本格式类似于
arduino
{
version : 3,
file: "target-code.js", // 目标代码的文件名, 也就是编译后的文件名
sourceRoot : "", // 源码根目录
sources: ["foo.js", "bar.js"], // 源代码文件
names: ["a", "b"], // 源码中的变量名
mappings: "AAgBC,SAAQ,CAAEA;AAAEA", // 位置映射(重要)
// 源代码文件的内容, 和sources属性相对应
sourcesContent: ['const a = 1; console.log(a)', 'const b = 2; console.log(b)']
}
注意, 这里的mappings是核心, 它记录了位置映射, 先来了解下其值的含义:
- 分号代表行分隔, 例如, 上面代码中的mappings值包含一个分号, 说明这里映射的源码有2行
- 逗号代表映射的位置, 例如, 示例中映射了源码第1行的3个位置和第2行的1个位置
- 每个位置有五位, 分别代表
-
- 源码列数(行数已经通过分号确定了)
- 源文件名, 这个本质上一个索引, 通过这个索引可以找到sources中对应的元素,从而确定源文件名
- 对应源文件行数
- 对应源文件列数
- 源码中的变量名, 这里本质上也是一个索引, 通过这个索引可以找到names中对应的属性, 从而确定这个位置对应的源码中的变量
看到这里, 其他的都好理解, 关键是mappings中的值到底是个什么鬼? 其实这里用了VLQ编码, 好了, 再说这个之前, 我们先来思考下,如果要我们自己实现source-map, 该怎么做?
实现source-map思路
初步构思
其实source-map本质就是将源代码和目标代码关联起来, 假设我们有这么一段代码转换
arduino
// 这里其实就是简单的位置转换
"feel the force" ⇒ "the force feel"
那如果要设计一个source-map, 我们该如何做, 首先能想到的就是, 位置映射,我把你俩位置全部一一关联起来!
目标代码中的位置 | 源文件名 | 源码中的位置 | 对应字符 |
---|---|---|---|
行 1, 列 0 | source.txt | 行 1, 列 5 | t |
行 1, 列 1 | source.txt | 行 1, 列 6 | h |
行 1, 列 2 | source.txt | 行 1, 列 7 | e |
行 1, 列 4 | source.txt | 行 1, 列 9 | f |
行 1, 列 5 | source.txt | 行 1, 列 10 | o |
行 1, 列 6 | source.txt | 行 1, 列 11 | r |
行 1, 列 7 | source.txt | 行 1, 列 12 | c |
行 1, 列 8 | source.txt | 行 1, 列 13 | e |
行 1, 列 10 | source.txt | 行 1, 列 0 | f |
行 1, 列 11 | source.txt | 行 1, 列 1 | e |
行 1, 列 12 | source.txt | 行 1, 列 2 | e |
行 1, 列 13 | source.txt | 行 1, 列 3 | l |
打个比方, t这个字符, 在源码中的第1行第5列, 在目标代码的第1行第0列, 所以为了表示t, 我们可以写出如下代码
arduino
// 目标行数|目标列数|源文件名|源代码行数|源代码列数
1|0|source.txt|1|5
所以最终, 我们的mappings应该是
makefile
mappings:1|0|source.txt|1|5, 1|1|source.txt|1|6, 1|2|source.txt|1|7, 1|4|source.txt|1|9, 1|5|source.txt|1|10, 1|6|source.txt|1|11, 1|7|source.txt|1|12, 1|8|source.txt|1|13, 1|10|source.txt|1|0, 1|11|source.txt|1|1, 1|12|source.txt|1|2, 1|13|source.txt|1|3
如果代码多了, 那source-map体积将增大很多
去掉行号
根据前面的介绍, 我们知道, 行号可以通过分号来表示, 所以我们的第一位, 可以去掉了
makefile
mappings:0|source.txt|1|5, 1|source.txt|1|6, 2|source.txt|1|7, 4|source.txt|1|9, 5|source.txt|1|10, 6|source.txt|1|11, 7|source.txt|1|12, 8|source.txt|1|13, 10|source.txt|1|0, 11|source.txt|1|1, 12|source.txt|1|2, 13|source.txt|1|3
可视化字符提取
我们可以发现, 为了表达'the'这个单词, 我门用了三组数据来表达
0|source.txt|1|5, 1|source.txt|1|6, 2|source.txt|1|7
但是现实中, 一个标识符(包括变量名/函数名等等), 其实里面的字符都是连续的, 所以我们完全可以只记录这个'the'开始的位置, 然后, 将这个单词完整地记录在另一个数组中, 而这个数组, 就是我们之前介绍的names属性中的内容
arduino
names: ['the', 'force', 'feel']
而此时的'the'就可以表示为
0|source.txt|1|5|0
所以此时完整的表示就是
0|source.txt|1|5|0, 4|source.txt|1|9|1, 10|source.txt|1|0|2
整合源文件名
我们把变量名统一另外存在了一个属性中, 同样的道理, 一直重复的文件名, 也应如此, 而原本写文件名的位置只留下一个索引即可, 现在我们的映射有
vbnet
file: ['source.txt']
names: ['the', 'force', 'feel']
mappings: 0|0|1|5|0, 4|0|1|9|1, 10|0|1|0|2
相对位置转换
我们的源码列数, 都是使用绝对位数, 也就是第一个单词定位到源码第5列之后, 后一个单词还是从头开始, 算到第9列, 如果代码量少还好, 如果很多的话, 就会导致列数非常之大, 所以, 我们可以采用相对位置来对源码列数进行定位:
例如,
- 源码列中:
第一个单词列数为5, 那就是5
第二个为9 ,那么可以理解为第二个相对于第一个+4, 所以可以记为4
第三个为0, 可以定义为第二个(绝对位置为9)的位置-9, 所以可以记为-9
- 目标代码列中:
第一个单词列数为0, 那就是0, 保持不变
第二个为4 ,那么可以理解为第二个相对于第一个+4, 所以可以记为4, 保持不变
第三个为10, 可以定义为第二个(绝对位置为4)的位置+6, 所以可以记为6
- 变量数组索引
第一个单词变量索引为0, 保持不变
第二个单词变量索引为1, 相对第一个来说+1, 所以仍记为1
第三个单词变量索引为2, 相对第二个来说+1, 所以记为1
现在我们的映射有
vbnet
file: ['source.txt']
names: ['the', 'force', 'feel']
mappings: 0|0|1|5|0, 4|0|1|4|1, 6|0|1|-9|1
VLQ编码
我们的source-map看上去已经很简洁了, 但是, 如果代码多了, 其中的数字仍有可能很大, 一旦数字大了, 就有可能依然占用很大的空间
在介绍VLQ之前, 我们来看下一个简单的例子
- 如果我们要记录连续几个数字
arduino
// 我们可以用|来作为数字之间的分隔
1|2|3|4
// 如果说这些数字都是个位数, 则可以去掉|, 这样可以节省空间
1234
- 但是我们要记录的不可能永远都是个位数, 例如:
arduino
// 如果中间有超过2位数的数字, 我们还是要|来分隔,否则完全分不清到底有几组数字
1|23|456|7
而使用|, 毕竟是数字符号之外的符号, 不利于体积的减小, 我们要有一种新的记录 方式来记录这段数字.
VLQ以二进制方式呈现
先看这段数字, 和上面是同一组数据, 都是分了4组
1234567
我们来捋一捋规则
- 1底下没有横线, 说明1不连续, 到这里, 第一组就终止了.
- 2底下有横线, 说明这是个连续的数字, 继续往后看, 3底下没有了, 那么23就是一组
- 45底下都有横线, 说明还在连续, 6底下没有了, 说名连续中止, 456就是一组
- 7底下没有横线, 说明最后一组只有7.
在此, 以6bit为一组, 我们姑且称之为: '比特组', 表示一组数字, 如果一个比特组不够, 则再加一个比特组(也是6bit)
B5 | B4 | B3 | B2 | B1 | B0 |
---|---|---|---|---|---|
是否连续 | 数字的值 | 正负 |
可以看出, 这6位中:
- 第一位的含义是本数字是否连续, 也就是前面说的, 底下是否有横线(1-连续, 0-不连续)
- 中间四位表示数字的值的二进制表达形式
- 最后一位表示这个数字的正负(1-负数, 0-正数), 注意, 只有第一个比特组需要记录正负
Binary group | Meaning |
---|---|
000000 | 0 |
000001 | -0 |
000010 | 1 |
000011 | -1 |
000100 | 2 |
000101 | -2 |
... | ... |
011110 | 15 |
011111 | -15 |
100000 | 未结束的0 |
100001 | 未结束的-0 |
100010 | 未结束的1 |
100011 | 未结束的-1 |
... | ... |
111110 | 未结束的15 |
111111 | 未结束的-15 |
只有第一个比特组才需要记录正负, 后续的数字, 最后一位参与数值表达:
B5 | B4 | B3 | B2 | B1 | B0 |
---|---|---|---|---|---|
是否连续 | 数值(二进制形式, 包括最后一位B0) |
用VLQ二进制方式编码
我们现在可以按照这套规则, 对上面案例中的1234567, 进行二进制编码
- 首先是1, 它是正数, 二进制形式用4位足以表达(0001), 所以一个比特组就够了, 这个比特组肯定就是不连续的了(后面没有新的比特组了):
B5(C) | B4 | B3 | B2 | B1 | B0(S) |
---|---|---|---|---|---|
0(不连续) | 0 | 0 | 0 | 1 | 0(正数) |
- 再是23, 它是正数, 二进制形式10111, 第一个比特组只有4位来表示数值, 不够, 所以这里需要2个比特组:
B5(C) | B4 | B3 | B2 | B1 | B0(S) | B5(C) | B4 | B3 | B2 | B1 | B0 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
第一个比特组中, 首位和末位分别表示'连续'和'正数'的含义, 中间四位是23的二进制形式 (10111)的后四位, 即0111;
第二个比特组中, 首位表示'不连续', 后续五位全部都可用来表示数值: 而我们只剩下一个1需要表示, 所以就是00001;
- 然后是456, 正数, 二进制形式111001000, 高达9位, 一个比特组也放不下:
B5(C) | B4 | B3 | B2 | B1 | B0(S) | B5(C) | B4 | B3 | B2 | B1 | B0 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 0 | 0 |
- 最后是7, 正数, 二进制形式111, 一个bit组就够了
B5(C) | B4 | B3 | B2 | B1 | B0(S) |
---|---|---|---|---|---|
0 | 0 | 1 | 1 | 1 | 0 |
所以, 综合以上所有的二进制数据, 我们可以得到:
000010 101110 000001 110000 011100 001110
这样, 就是按照我们所述的规则, 表示出了: 1234567. 然后我们再根据__base64 编码表__的出最终的结果
- 先将我们得到的二进制数据转为十进制
arduino
000010 101110 000001 110000 011100 001110
// 转换
2 46 1 48 28 14
// 对照base64表得到
CuBwco
现在回到我们之前的source-map中的mappings
makefile
mappings: 0|0|1|5|0, 4|0|1|4|1, 6|0|1|-9|1
按照之前介绍的方法, 我们可以计算得出
arduino
// 第一组
0|0|1|5|0
// 转为VLQ二进制形式
000000 000000 000010 001010 000000
// 转十进制
0 0 2 10 0
// 对照base64表
AACKA
// 第二组
4|0|1|4|1
// 转为VLQ二进制形式
001000 000000 000010 001000 000010
// 转十进制
8 0 2 8 2
// 对照base64表
IACIC
// 第二组
6|0|1|-9|1
// 转为VLQ二进制形式
001100 000000 000010 010011 000010
// 转十进制
12 0 2 19 2
// 对照base64表
MACTC
综合:
我们的source-map的最终代码就是
arduino
{
"version": 3,
"file": ['source.txt'],
"sources": ["source-code.txt"],
"names": ['the', 'force', 'feel'],
"mappings": "AACKA, IACIC, MACTC"
}