欢迎各位兄弟 发布技术文章
这里的技术是共享的
摘要: Source Map很神奇,它的原理挺复杂的…
Fundebug经授权转载,版权归原作者所有。
线上产品代码一般是编译过的,前端的编译处理过程包括不限于
转译器/Transpilers (Babel, Traceur)
编译器/Compilers (Closure Compiler, TypeScript, CoffeeScript, Dart)
压缩/Minifiers (UglifyJS)
这里提及的都是可生成Source Map的操作。
经过这一系列骚气的操作后,发布到线上的代码已经面目全非,对带宽友好了,但对开发者调试并不友好。于是就有了Source Map。顾名思义,他是源码的映射,可以将压缩后的代码再对应回未压缩的源码。使得我们在调试线上产品时,就好像在调试开发环境的代码。
准备两个测试文件,一个 log.js
里包含一个输出内容到控制台的函数:
function sayHello(name) { if (name.length > 2) { name = name.substr(0, 1) + '...' } console.log('hello,', name) }
一个main.js
文件里面对这个方法进行了调用:
sayHello('世界') sayHello('第三世界的人们')
我们使用 uglify-js
将两者合并打包并且压缩。
npm install uglify-js -g uglifyjs log.js main.js -o output.js --source-map "url='/output.js.map'"
安装并执行后,我们得到了一个输出文件 output.js
,同时生成了一个Source Map文件 output.js.map
。
output.js
function sayHello(name){if(name.length>2){name=name.substr(0,1)+"..."}console.log("hello,",name)}sayHello("世界");sayHello("第三世界的人们"); //# sourceMappingURL=/output.js.map
output.js.map
{"version":3,"sources":["log.js","main.js"],"names":["sayHello","name","length","substr","console","log"],"mappings":"AAAA,SAASA,SAASC,MACd,GAAIA,KAAKC,OAAS,EAAG,CACjBD,KAAOA,KAAKE,OAAO,EAAG,GAAK,MAE/BC,QAAQC,IAAI,SAAUJ,MCJ1BD,SAAS,MACTA,SAAS"}
为了能够让Source Map能够被浏览器加载和解析,
再添加一个 index.html
来加载我们生成的这个output.js
文件。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>source map demo</title> </head> <body> Source Mapdemo <script src="output.js"></script> </body> </html>
然后开启一个本地服务器,这里我使用 python 自带的server 工具:
python3 -m http.server
在浏览器中开启Source Map
Source Map在浏览器中默认是关闭的,这样就不会影响正常用户。当我们开启后,浏览器就根据压缩代码中指定的Source Map地址去请求 map 资源。
最后,就可以访问 http://localhost:8000/
来测试我们的代码了。
在压缩过的代码中打断点
从截图中可以看到,开启Source Map后,除了页面中引用的 output.js
文件,浏览器还加载了生成它的两个源文件,以方便我们在调试浏览器会自动映射回未压缩合并的源文件。
为了测试,我们将 output.js 在调试工具中进行格式化,然后在 sayHello
函数中打一个断点,看它是否能将这个断点的位置还原到这段代码真实所在的文件及位置。
刷新页面后,我们发现,断点正确定位到了 log.js
中正确的位置。
会否觉得很赞啊!
下面我们来了解它的工作原理。
将现实中的情况简化一下无非是以下的场景:
输入 ⇒ 处理转换(uglify) ⇒ 输出(js)
上面,输出无疑就是需要发布到产品线上的浏览器能运行的代码。这里只讨论js,所以输出是js代码,当然,其实Source Map也可以运用于其他资源比如LESS/SASS等编译到的CSS。
而Source Map的功能是帮助我们在拿到输出后还原回输入。如果我们自己来实现,应该怎么做。
最直观的想法恐怕是,将生成的文件中每个字符位置对应的原位置保存起来,一一映射。请看来自这篇文章中给出的示例:
“feel the force” ⇒ Yoda ⇒ “the force feel”
一个简单的文本转换输出,其中 Yoda
可以理解为一个转换器。将上面的的输入与输出列成表格可以得出这个转换后输入与输出的对应关系。
输出位置 | 输入 | 在输入中的位置 | 字符 |
---|---|---|---|
行 1, 列 0 | Yoda_input.txt | 行 1, 列 5 | t |
行 1, 列 1 | Yoda_input.txt | 行 1, 列 6 | h |
行 1, 列 2 | Yoda_input.txt | 行 1, 列 7 | e |
行 1, 列 4 | Yoda_input.txt | 行 1, 列 9 | f |
行 1, 列 5 | Yoda_input.txt | 行 1, 列 10 | o |
行 1, 列 6 | Yoda_input.txt | 行 1, 列 11 | r |
行 1, 列 7 | Yoda_input.txt | 行 1, 列 12 | c |
行 1, 列 8 | Yoda_input.txt | 行 1, 列 13 | e |
行 1, 列 10 | Yoda_input.txt | 行 1, 列 0 | f |
行 1, 列 11 | Yoda_input.txt | 行 1, 列 1 | e |
行 1, 列 12 | Yoda_input.txt | 行 1, 列 2 | e |
行 1, 列 13 | Yoda_input.txt | 行 1, 列 3 | l |
这里之所以将输入文件也作为映射的必需值,它可以告诉我们从哪里去找源文件。并且,在代码合并时,生成输出文件的源文件不止一个,记录下每处代码来自哪个文件,在还原时也很重要。
上面可以直观看出,生成文件中 (1,0) 位置的字符对应源文件中 (1,5)位置的字符,… 将上面的表格整理记录成一个映射编码看起来会是这样的:
mappings(283 字符):1|0|Yoda_input.txt|1|5, 1|1|Yoda_input.txt|1|6, 1|2|Yoda_input.txt|1|7, 1|4|Yoda_input.txt|1|9, 1|5|Yoda_input.txt|1|10, 1|6|Yoda_input.txt|1|11, 1|7|Yoda_input.txt|1|12, 1|8|Yoda_input.txt|1|13, 1|10|Yoda_input.txt|1|0, 1|11|Yoda_input.txt|1|1, 1|12|Yoda_input.txt|1|2, 1|13|Yoda_input.txt|1|3
这样确实能够将处理后的文件映射回原来的文件,但随着内容的增多,转换规则更加地复杂,这个记录映射的编码将飞速增长。这里源文件 feel the force
才12个字符,而记录他转换的映射就已经达到了283个字符。所以这个编码的方式还有待改进。
大多数情况下处理后的文件行数都会少于源文件,特别是 js,使用 UglifyJS 压缩后的文件通常只有一行。基于此,每必要在每条映射中都带上输出文件的行号,转而在这些映射中插入;
来标识换行,可以节省大量空间。
mappings (245 字符): 0|Yoda_input.txt|1|5, 1|Yoda_input.txt|1|6, 2|Yoda_input.txt|1|7, 4|Yoda_input.txt|1|9, 5|Yoda_input.txt|1|10, 6|Yoda_input.txt|1|11, 7|Yoda_input.txt|1|12, 8|Yoda_input.txt|1|13, 10|Yoda_input.txt|1|0, 11|Yoda_input.txt|1|1, 12|Yoda_input.txt|1|2, 13|Yoda_input.txt|1|3;
这个例子中,一共有三个单词,拿输出文件中 the
来说,当我们通过它的第一个字母t
(1,0)确定出对应源文件中的位置(1,5),后面的he
其实不用再记录映射了,因为the
可以作为一个整体来看,试想 js 源码中一个变量名,函数名这些都不会被拆开的,所以当我们确定的这个单词首字母的映射关系,那整个单词其实就能还原到原来的位置了。
所以,首先我们将文件中可符号化的字符提取出来,将他们作为整体来处理。
序号 | 符号 |
---|---|
0 | the |
1 | force |
2 | feel |
于是得到一个所有包含所有符号的数组:
names: ['the','force','feel']
在记录时,只需要记录一个索引,还原时通过索引来这个names
数组中找即可。所以上面映射规则中最后一列本来记录了每个字符,现在改为记录一个单词,而单词我们只记录其在抽取出来的符号数组中的索引。
所以 the
的映射由原来的
0|Yoda_input.txt|1|5, 1|Yoda_input.txt|1|6, 2|Yoda_input.txt|1|7
可以简化为
0|Yoda_input.txt|1|5|0
同时,考虑到代码经常会有合并打包的情况,即输入文件不止一个,所以可以将输入文件抽取一个数组,记录时,只需要记录一个索引,还原的时候再到这个数组中通过索引取出文件的位置及文件名即可。
sources: ['Yoda_input.txt']
所以上面the
的映射进一步简化为:
0|0|1|5|0
于是我们得到了完整的映射为:
sources: ['Yoda_input.txt'] names: ['the','force','feel'] mappings (31 字符): 0|0|1|5|0, 4|0|1|9|1, 10|0|1|0|2;
当文件内容巨大时,上面精简后的编码也有可能会因为数字位数的增加而变得很长,同时,处理较大数字总是不如处理较小数字容易和方便。于是考虑将上面记录的这些位置用相对值来记录。比如(1,1001)第一行第999列的符号,如果用相对值,我们就不用每次记录都从0开始数,假如前一个符号位置为 (1,999),那后面这个符号可记录为(0,2),类似这样的相对值帮我们节省了空间,同时降低了数据的维度。
具体到本例中,看看最初的表格中,记录的输出文件中的位置:
输出位置 | 输出位置 |
---|---|
行 1, 列 0 | 行 1, 列 0 |
行 1, 列 4 | 行 1, 列 (上一值 + 4 = 4) |
行 1, 列 10 | 行 1, 列 (上一值 + 6 = 10) |
对应到整个表格则是:
输出位置 | 输入文件的索引 | 输入的位置 | 符号索引 |
---|---|---|---|
行 1, 列 0 | 0 | 行 1, 列 5 | 0 |
行 1, 列 +4 | +0 | 行 1, 列 +4 | +1 |
行 1, 列 +6 | +0 | 行 1, 列 -9 | +1 |
然后我们得到的编码为:
sources: ['Yoda_input.txt'] names: ['the','force','feel'] mappings (31 字符): 0|0|1|5|0, 4|0|1|4|1, 6|0|1|-9|1;
注意
上面记录相对位置后,我们的数字中出现了负值,所以之后解析Source Map文件看到负值就不会感到奇怪了
另外一点我的思考,对于输出位置来说,因为是递增的,相对位置确实有减小数字的作用,但对于输入位置,效果倒未必是这样了。拿上面映射中最后一组来说,原来的值是 10|0|1|0|2
,改成相对值后为 6|0|1|-9|1
。第四位的值即使去掉减号,因为它在源文件中的位置其实是不确定的,这个相对值可以变得很大,原来一位数记录的,完全有可能变成两位甚至三位。不过这种情况应该比较少,它增加的长度比起对于输出位置使用相对记法后节约的长度要小得多,所以总体上来说空间是被节约了的。
进一步的优化则需要引入一个新的概念了,VLQ(Variable-length quantity)。
如果你想顺序记录4个数字,最简单的办法就是将每个数字用特殊的符号隔开:
1|2|3|4
如果如果提前告诉你这些被记录的数字都是一位的,那这个分隔线就没必要了,只需要简单记录成如下样子也能被正确识别出来:
1234
此时这个记录值的长度是原来的1/2,省了不少空间。
但实际上我们不可能只记录个位数的数字,使用 VLQ 方式时,如果一个数字后面还跟有剩余数字,将其标识出来即可。假设我们想记录如下的四个数字:
1|23|456|7
我们使用下划线来标识一个数字后跟有其他数字:
1234567
所以解读规则为:
1没有下划线,那解析出来第一个数字便是1
2有下划线,则继续解析,碰到3,3没有下划线,第二位数的解析到此为止,所以第二位数为23
4有下划线,继续,5也有,继续,6没有下划线,所以第三位数字为456
7没有下划线,第四位数字则为7
上面的示例中,引入了数字系统外的符号来标识一个数字还未结束。在二进制系统中,我们使用6个字节来记录一个数字(可表示至多64个值),用其中一个字节来标识它是否未结束(正文 C 标识),不需要引入额外的符号,再用一位标识正负(下方 S),剩下还有四位用来表示数值。用这样6个字节组成的一组拼起来就可以表示出我们需要的数字串了。
B5 | B4 | B3 | B2 | B1 | B0 |
---|---|---|---|---|---|
C | Value | S |
第一个字节组(四位作为值)
这样一个字节组可以表示的数字范围为:
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 |
* -0 没有实际意义,但技术上它是存在的
任意数字中,第一个字节组中已经标明了该数字的正负,所以后续的字节组中无需再标识,于是可以多出一位来作表示值。
B5 | B4 | B3 | B2 | B1 | B0 |
---|---|---|---|---|---|
C | Value |
未结束的字节组(五位作为值)
现在我们使用上面的二进制规则来重新编码之前的这个数字序列 1|23|456|7
。
先看每个数字对应的真实二进制是多少:
数值 | 二进制 |
---|---|
1 | 1 |
23 | 10111 |
456 | 111001000 |
7 | 111 |
对1进行编码
1需要一位来表示,还好对于首个字节组,我们有四位来表示值,所以是够用的。
B5(C) | B4 | B3 | B2 | B1 | B0(S) |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 1 | 0 |
对23进行编码
23的二进制为10111一共需要5位,第一组字节组只能提供4位来记录值,所以用一组字节组不行,需要使用两组字节组。将 10111拆分为两组,后四位0111放入第一个字节组中,剩下一位1放入第二个字节组中。
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 |
对456进行编码
456的二进制111001000需要占用9个字节,同样,一个字节组放不下,先拆出最后四位(1000)放入一个首位字节组中,剩下的5位(11100)放入跟随的字节组中。
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进行编码
3的二进制为111,首位字节组能够存放得下,于是编码为:
B5(C) | B4 | B3 | B2 | B1 | B0(S) |
---|---|---|---|---|---|
0 | 0 | 1 | 1 | 1 | 0 |
将上面的编码合并得到最终的编码:
000010 101110 000001 110000 011100 001110
结合上面的 Base64 编码表,上面的结果转成对应的 base64 字符为:
CuBwcO
通过上面讨论的方法,回到开始的示例中,前面我们已经得到的编码为
sources: ['Yoda_input.txt'] names: ['the','force','feel'] mappings (31 字符): 0|0|1|5|0, 4|0|1|4|1, 6|0|1|-9|1;
现在来编码 0|0|1|5|0
。先用二进制对每个数字进行表示,再转成 VLQ 表示:
0-> 0 -> 000000 //0 0-> 0 -> 000000 //0 1-> 1 -> 000010 //2 5-> 101 -> 001010 // 10 0-> 0 -> 000000 //0
合并后的编码为:
000000 000000 000001 000101 000000
再转 Base64 后得到字符形式的结果:
AACKA
后面两串数通过类似的做法也能得到对应的 Base64编码,所以最终我们得到的Source Map看起来是这样的:
sources: ['Yoda_input.txt'] names: ['the','force','feel'] mappings (18 字符): AACKA, IACIC, MACTC;
而真实的 srouce map 如我们文章开头那个示例一样,是一个 json 文件,所以最后我们得到的一分像模像样的Source Map为:
{ "version": 3, "file": "Yoda_output.txt", "sources": ["Yoda_input.txt"], "names": ["the", "force", "feel"], "mappings": "AACKA,IACIC,MACTC;" }
上面的例子中,每一片段的编码由五位组成。真实场景中,有些情况下某些字段其实不必要,这时就可以将其省略。当然,这里给出的这个例子看不出来。
省略其中某些字段后,一个编码片段就不一定是5位了,他的长度有可能为1,4或者5。
5 - 包含全部五个部分:输出文件中的列号,输入文件索引,输入文件中的行号,输入文件中的列号,符号索引
4 - 输出文件中的列号,输入文件索引,输入文件中的行号,输入文件中的列号
1 - 输出文件中的列号
以上,便探究完了 srouce map 生成的全过程,了解了其原理。
如果感兴趣,这个Source map visualizer tool 工具可以在线将Source Map与对应代码可见化展现出来,方便理解。
另外需要介绍的是,尽管Source Map对于线上调试非常有用,各主流浏览器也实现对其的支持,但关于它的规范没有见诸各 Web 工作组或团体的官方文档中,它的规范是写在一个 Google 文档中的!这你敢信,不信去看一看喽~ Source Map Revision 3 Proposal。
本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。
来自 https://cloud.tencent.com/developer/article/1354581
介绍 webpack的source-map原理
为什么要有source-map?
答:因为目前我们开发时候的源码跟通过webpack构建混淆压缩后的生产环境部署的代码不一样,sourceMap就是一个文件,里面储存着位置信息。
source-map,怎么来的 ?它在哪儿?
答:通过webpack等工具,我们可以使用 sourceMap,它跟构建后的文件同在一个目录下~
source-map,它怎么用?
答:在webpack的development模式下,会自动开启source-map
例如: 下面的代码没有声明过log函数,结果调用了
componentDidMount() { log(this.props); }
最终精准报错,代码所处的文件,还有行数,这是source-map的最重要作用
可是我们的代码经过webpack打包后,即便是development模式,也会被混淆。真正的内存中的代码,已经是下面这种形式了
那么是它是怎么给我们精准报错定位到源文件地址和行数的呢?
我们通过source面板打开了文件,全局搜索了source
此时发现原来开启source-map后的路径映射,是直接保存在文件中的,但是我看到在老版本的webpack中,是以base64的形式保存在文件中的
⚠️:在webpack配置中设置devtool: 'none',那么就会关闭source-map
development模式会自动开启source-map,是为了让我们方便调试,定位错误,但是production模式,会自动关闭
众所周知,代码加密,也是有代价的,会降低代码运行效率,所以前端安全就很难做。特别是Electron、安卓这种客户端,都是可以反编译的,只要有编译,就基本上都有反编译。所以即便是whatsApp这种,也是大部分代码裸奔。当然,还是有很多手段可以提高反编译成本的
下面是source-map各种不同配置的速度
当我在production模式下,加入 devtool:"source-map"选项,打包构建后,代码里多了很多个.map文件,这些个map文件里,就包含了源码路径
这里可以看到,每个文件模块,都有一个对应的.map文件,里面存放着他们的源码路径
sourcemap文件的格式:
{ version : 3, //SourceMap的版本,目前为3 sources: ["***.js,***.js"], //转换前的文件,该项是一个数组,表示可能存在多个文件合并 names: ["***", "***", "**", "***"], //转换前的所有变量名和属性名 mappings: "AACvB,gBAAgB,EAAE;AAClB;", //记录位置信息的字符串 file: "out.js", //转换后的文件名 sourcesContent: " \t// The module cache\n", //转换后的代码 sourceRoot : "" //转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空}
其他的应该都很好解释,重点是mappings
以"AACvB,gBAAgB,EAAE;AAClB;"
为例:
每个分号对应转换后源码的一行;
每个逗号对应转换后源码的一个位置;
AACvB
代表该位置转换前的源码位置,以VLQ
编码表示;
位置关系的保存经历了诸多步骤和优化,这个不详细说了,想看的可以看这里,我们只说最后的结果。
在每个位置中:
第一位,表示这个位置在【转换后代码】的第几列。
第二位,表示这个位置属于【sources属性】中的哪一个文件。
第三位,表示这个位置属于【转换前代码】的第几行。
第四位,表示这个位置属于【转换前代码】的第几列。
第五位,表示这个位置属于【names属性】的哪一个变量。
假设现在有a.js
,内容为feel the force
,处理后为b.js
,内容为the force feel
以the
为例,它在输出中的位置是(0,0),a.js
是sources
的第1个(这里只是举例),输入中的位置是(0,5),the
是names
的第2个(这里只是举例)。
那么映射关系为: 0 1 0 5 2
最后将 01052 表示为 Base64 VLQ 即可。
说明:
所有的值都是以0作为基数
第五位不是必需的,如果该位置没有对应names
属性中的变量,可以省略第五位
每一位都采用VLQ
编码表示,由于VLQ
编码是可变长的,所以每一位可以由多个字符构成
为什么不保存转换后代码的行号,因为我们输出的文件总是一行,这样输出的行号就可以省略,因为都是0,没必要写出来
对于输出后的位置来说,到后边会发现它的列号特别大,为了避免这个问题,采用相对位置进行描述
相对位置是啥呢,看示意图:
第一次记录的输入位置和输出位置是绝对的,往后的输入位置和输出位置都是相对上一次的位置移动了多少,例如the
的输出位置为(0,-10),因为the
在feel
的左边数10下才能到这个位置。
VLQ
是Variable-length quantity
的缩写,是一种通用的、使用任意位数的二进制来表示一个任意大的数字的一种编码方式。这种编码最早用于MIDI文件,后来被多种格式采用,它的特点就是可以非常精简地表示很大的数值,用来节省空间。
这种编码需要用最高位表示连续性,如果是1,代表这组字节后面的一组字节也属于同一个数;如果是0,表示该数值到这就结束了。
这样干巴巴说不太容易懂,还是举个栗子说明一下吧。
如何对数值137进行VLQ编码:
步骤 | 结果 |
---|---|
将137改写成二进制形式 | 10001001 |
七位一组做分组,不足的补0 | 0000001 0001001 |
最后一组开头补0,其余补1 | 10000001 00001001 |
所以,137的VLQ编码形式为10000001 00001001
与一般的VLQ
的区别:
一个Base64
字符只能表示 6bit(2^6)
的数据
Base64 VLQ
需要能够表示负数,于是用最后一位来作为符号标志位。
由于只能用6位进行存储,而第一位表示是否连续的标志,最后一位表示正数/负数。中间只有4位,因此一个单元表示的范围为[-15,15]
,如果超过了就要用连续标识位了。
表示正负的方式:
如果这组数是某个数值的VLQ
编码的第一组字节,那它的最后一位代表"符号",0为正,1为负;
如果不是,这个位没有特殊含义,被算作数值的一部分。
我们再来举个栗子说明下使用方法。
如何对数值137进行Base64 VLQ
编码:
步骤 | 结果 |
---|---|
将137改写成二进制形式 | 10001001 |
127是正数,末位补0 | 100010010 |
五位一组做分组,不足的补0 | 01000 10010 |
将组倒序排序 | 10010 01000 |
最后一组开头补0,其余补1 | 110010 001000 |
转64进制 | y和I |
所以 137 通过Base64 VLQ
表示为yl
可以看出:
在Base64 VLQ
中,编码顺序是从低位到高位
而在VLQ
中,编码顺序是从高位到低位
对于做前端异常监控来说,source-map是很有必要的,但是对于性能要求极高的项目,那么可能还是要自己去实现一套独特的监控方式。
部分内容来自参考文章:
https://segmentfault.com/a/1190000020213957
本文分享自微信公众号 - 前端皮小蛋(gh_e69260c16440)
原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。
原始发表时间:2020-03-05
本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。
来自 https://cloud.tencent.com/developer/article/1598223
简介
今天这篇文章我们一起聊一聊sourceMap的原理,我们的研究对象是一个常用于各种构建工具的npm包——source-map。
sourceMap的主要作用是为了方便调试,因为现在的前端代码都是模块化、组件化的,在上线前会对js和css文件进行合并、压缩混淆,如果对这样的线上代码进行调试,肯定痛苦万分,sourceMap的作用就是能让浏览器的调试面板将生成后的代码映射到源码文件中,开发者可以在源码文件中debug,这样就会让程序员开心很多!
到目前为止,支持sourceMap的浏览器还是蛮少的,反正chrome是支持的,火狐貌似也是支持的。
使用
看一个例子,如下,我们现在写两个简单的js文件:
现在我们把这两个js文件合并起来并且产出一个sourceMap以供我们在chrome上断点调试!
我们可以结合前面文章提到的gulp写一个简单的构建流,如下:
图1
用gulp运行一下,会在dist目录下产出两个文件:all.js——代码合并后的文件,all.js.map——sourceMap的json串所在的文件。
当你打开all.js后,你会发现all.js用独特的方式引入了all.js.map文件,如下图红色区域,
图2
我们现在写一个html的空白页,仅仅引入all.js,
图3
打开这个页面,并查看chrome devtools的source面板,你会发现这两个源码文件也被映射了进来,并且可以使用这两个源码文件来debug,即使你把本地目录下的两个源码文件删除也不影响映射,非常神奇!
图4
下面我们就来看看生成后的代码是如何映射到源码中的?
sourceMap原理
打开图2中.map文件,如下:
图5
version 代表sourceMap的版本,写死3就可以了;
sources和file 前者是源文件,后者是生成后的代码文件,这个一眼就能看出来;
names 数组,存放转换前的所有变量名和属性名,不是必须的,所以不用太关注;
sourcesContent 数组,存放每个源代码的内容;
mappings 一堆乱七八糟的东西,看不出来是啥,这个就是今天主要的分析对象。
目前大多数的sourceMap都是用source-map这个包实现的,那么我们使用一下它!
图6
如图6,使用source-map先生成一个SourceMapGenerator实例对象,这个实例对象主要是用于存储生成后代码的每一行与源码中每一行的对应关系以及存储每个源码文件的内容(最后一行),此处调用了addMapping方法,它的内部就是一个数组。
首先读取每个源码文件的内容,然后通过split就可以得到每个源码文件有多少行;
addMapping方法参数是个对象,这个对象包含源码文件名,源码内容行和每行起始的列、生成后代码的内容行和起始列;
这里比较难处理的是生成后代码的内容行和起始列,如果仅仅是把源码文件内容简单的从上到下合并在一起,lineOffset从0依次递增就可以了,columnOffset统一是0;如果上一个文件的最后一行和下一个文件的第一行合并在了一行,那么此时的columnOffset就是上一个文件的最后一行的字符长度加1;如果文件内容之间有分隔符,那就把分割符所占的行与列也计算在内;
如果生成后的文件是压缩混淆的,那么就需要AST去计算对应关系了,好在这种情况uglify-js帮助我们做了sourceMap。
现在已经收集了源码内容以及行列生成前后的对应关系,再回头看看图5中的json是如何生成的?
此时生成sourcesContent的对应的源码内容就很简单了,此处不在嗷述,主要来看看mappings是如何生成的,如下图所示,算法还是有点绕的!
图7
首先是for循环遍历图6中存储的对应关系的数据;
区域1:生成后的代码如果本次数据的行和上一次数据的行不是同一行,以分号开始,如果本次数据的行和上一次数据的行是同一行,以逗号开始;
区域2、3、4、5、6都用到了一种编码方式base64-vql,它可以用base64表示任意数值,在上一篇文章中我已经阐述了它的原理,不了解的可以去看一下;
区域2、3、4、5、6分别代表生成后代码的行、源码文件在source中的索引、源码所在的行、源码所在的列、变量名和属性名在name中的索引,它们所有本次的值和上一次的值之差会进行base64-vql的编码;
每次循环会最多生成5个值加一个标点符号,然后拼接起来,如图5中mappings所示。
最终这样的json文件放在chrome等浏览器中就会被自动读取,生成对应的map。
sourceMap内嵌式
除了生成一个外链式的json文件,其实还可以生成内嵌式的data Url,如下图所示
图8
生成方式:
var base64Map = new Buffer(JSON.stringify(sourceMap)).toString('base64');
var data = '//# sourceMappingURL=data:application/json;charset=utf8;base64,' + base64Map;
将上面生成的data插入到文件底部即可!
总结
source-map除了能生成map还能解析map的json串,有兴趣的同学可以继续研究!另外虽然source-map帮我们生成了map,但是浏览器到底是怎么用的,其实对我们来说还是黑盒子,要想知道其中的奥秘可能就需要扒浏览器的代码了!
喜欢我的文章就关注我吧,有问题可以发表评论,我们一起学习,共同成长! 作者:做前端的蜗牛 https://www.bilibili.com/read/cv5010987/ 出处:bilibili
来自 https://www.bilibili.com/read/cv5010987/