SourceMap 原理
前端发展至今已不再是刀耕火种的年代了,出现了typescript、babel、uglify.js等功能强大的工具。我们手动撰写的代码一般具有可读性,并且可以享受高级语法、类型检查带来的便利,但经过工具链处理并上线的代码一般不具有可读性,且为了兼容低版本浏览器往往降级到低级语法,这些代码在转换过程中发生了变化,使我们并不能马上识别原始代码的组合方式,这提供了一定的源码安全性。
虽然带来了这些好处,但最终代码的排错是一个难点,SourceMap作为一种代码索引的工具,已经被广泛应用于这类场景了,它通过保存转换前和转换后代码在行、列上的对应关系,形成类似“映射”的结构,一旦转换的代码出了问题,可以查找到对应原始代码的位置。
本文针对webpack SourceMap的生成方法进行了探讨,涉及Base64 VLQ编码的基本知识,配合案例进行讨论,希望能对想了解它的开发者有所帮助。
生成SourceMap
我们先创建一个文件index.js,书写一些ES6的语法,然后配置webpack利用babel转换到低级语法。
// index.js
const foo = 'hello';
const bar = (a, b) => a+b;
然后配置webpack生成SourceMap文件
const path = require('path')
{ =
mode: 'development',
entry: './index.js',
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist')
},
module: {
rules: [
{
test: /\.js$/,
use: [
{
loader: 'babel-loader',
options: {
exclude: /node_modules/
}
}
]
}
]
},
devtool: 'source-map',
optimization: {
runtimeChunk: {
name: 'manifest'
}
}
}
devtool指定了生成的sourceMap类型,这里我们选择最原始的source-map即可。注意使用optimization.runtimeChunk选项抽离webpack注入的骨架代码,这些代码会干扰我们分析。
运行打包后,在dist目录得到四个文件,分别是main.js, main.js.map, manifest.js, manifest.js.map, 其中main.js是输出的代码文件,而main.js.map是SourceMap文件。
先看main.js, 文件的10-14行就是转化后的代码,可见原始代码中的const和箭头函数语法均被低级语法代替。
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["main"],{
/***/ "./index.js":
/*!******************!*\
!*** ./index.js ***!
\******************/
/*! no static exports found */
/***/ (function(module, exports) {
var foo = 'hello';
var bar = function bar(a, b) {
return a + b;
};
/***/ })
},[["./index.js","manifest"]]]);
//# sourceMappingURL=main.js.map
再看main.js.map文件, 这是一个JSON格式的文件,其中names字段包含了所有原始代码里的形参和实参,sourcesContent字段是原始代码,mappings字段则是生成的sourceMap。
{
"version": 3,
"sources": ["webpack:///./index.js"],
"names": ["foo", "bar", "a", "b"],
"mappings": ";;;;;;;;;AAAA,IAAMA,GAAG,GAAG,OAAZ;;AACA,IAAMC,GAAG,GAAG,SAANA,GAAM,CAACC,CAAD,EAAIC,CAAJ;AAAA,SAAUD,CAAC,GAACC,CAAZ;AAAA,CAAZ,C",
"file": "main.js",
"sourcesContent": ["const foo = 'hello';\r\nconst bar = (a, b) => a+b;\r\n"],
"sourceRoot": ""
}
SourceMap的格式
SourceMap的格式以;作为行分隔符,以,作为行中条目的分隔符,每个条目包含4-5个编码字符,这5个字符分别表示:
1、该位置在转化后的代码中的列数(相对于上一个条目)
2、文件序号
3、该位置在原始代码中的行数(相对于上一个条目)
4、该位置在原始代码中的列数(相对于上一个条目)
5、可能没有,该位置包含names属性中的哪个变量的声明,对应该属性的index (相对于上一个变量出现的index)
编码分析
这些信息都是数字格式,使用Base64 VLQ进行编码,在二进制位运算基础上操作,具体步骤为:
1、如果数字大于或等于0,左移一位;如果数字小于0,先取绝对值,然后左移一位,接着将末位置为1;
2、取数字最低的5位,并将数字右移5位;
3、如果此时数字为0,使用Base64编码字符序列输出第2步中取到的5位;如果数字不为0,则将第2步中取到的5位前面补1,使用Base64编码字符序列输出字符并循环第2步;
Base64编码序列表:
下面举两个例子具体来看下。
首先看16这个数:
1、16(10000)大于0,按照第1步,左移一位,变成100000;
2、按照第2步,取最低的5位,得到00000,数字剩余1,按照第3步,在00000前方补1得到100000,转化为十进制是32,对应的字符是g,此时有数字剩余,继续第2步;
3、按照第2步,取最低的5位,得到1,数字剩余0,按照第3步,直接输出1对应的字符'B'; 经过转化,16对应的Base64 VLQ编码是 gB
。
再看-2333这个数:
1、-2333小于0,按照第1步,先取绝对值得到2333(100100011101),左移一位,然后末位置为1,变成1001000111011;
2、按照第2步,取最低的5位,得到11011,数字剩余10010001,按照第3步,在11011前方补1得到111011,转化为十进制是59,对应的字符是7,此时有数字剩余,继续第2步;
3、按照第2步,取最低的5位,得到10001,数字剩余100,按照第3步,在10001前方补1得到110001,转化为十进制是49,对应的字符是x,此时有数字剩余,继续第2步;
4、按照第2步,取最低的5位,得到100,数字剩余0,按照第3步,100转化为十进制是4,直接输出对应的字符E;
经过转化,-2333对应的Base64 VLQ编码是 7xE
。
解码分析
经过上面的格式分析,mappings开头的每个分号都对应着转换后代码中的一行,通过观察转换后的文件我们发现mappings开头有9个分号,代表这9行内容都是webpack自己加进去的,跟我们的源代码没有关系,所以这里就直接忽略他们。
了解了编码方式,其实解码就是编码的反操作,就不赘述具体步骤了。为了帮助解析mappings这堆字符的含义,我们直接引入vlq这个库。
先分析第10行,利用下面的代码解析vlq字符串:
const vlq = require('vlq')
const source = 'AAAA,IAAMA,GAAG,GAAG,OAAZ'
function extract (sourceString) {
const lines = sourceString.split(';')
return lines.map(line => line.split(',').map(vlq.decode))
}
console.log(extract(source))
得到一个数组:
[
[
[ 0, 0, 0, 0 ], // 第10行第0列对应原始代码第1行第0列
[ 4, 0, 0, 6, 0 ], // 第0-3列对应原始代码第0-5列 (var -> const),同时包含names[0], 即foo变量的声明
[ 3, 0, 0, 3 ], // 第4-6列对应原始代码第6-8列 (foo -> foo)
[ 3, 0, 0, 3 ], // 第7-9列对应原始代码第9-11列 ( = -> = )
[ 7, 0, 0, -12 ] // 第10-16列对应源代码从第12列直到下一行开头('hello' -> 'hello';)
]
]
接下来的第11行是一个空行,直接用一个;结束。
再接下去是箭头函数的转换,我们接着看第12行,把 AACA,IAAMC,GAAG,GAAG,SAANA,GAAM,CAACC,CAAD,EAAIC,CAAJ
进行转化,得到:
[
[
[ 0, 0, 1, 0 ], // 第12行第0列对应原始代码第2行第0列
[ 4, 0, 0, 6, 1 ], // 第0-3列对应原始代码0-5列 (var -> const ),同时包含names[0+1], 即bar变量的声明
[ 3, 0, 0, 3 ], // 第4-6列对应原始代码第6-8列 (bar -> bar)
[ 3, 0, 0, 3 ], // 第7-9列对应原始代码第9-11列 ( = -> = )
[ 9, 0, 0, -6, 0 ], // 第10-18列没对应到内容,原始代码回到第5列 (function -> )
[ 3, 0, 0, 6 ], // 第19-21列对应原始代码第6-11列 (bar -> bar = )
[ 1, 0, 0, 1, 1 ], // 第22列对应原始代码第12列 ( ( -> ( ),同时包含names[1+1], 即形参a的声明
[ 1, 0, 0, -1 ], // 第23列没对应到内容,原始代码回到第11列
[ 2, 0, 0, 4, 1 ], // 第24-25列对应原始代码第1行第12-15列(, -> (a, ),同时包含names[2+1], 即形参b的声明
[ 1, 0, 0, -4 ] // 第26列没对应到内容,原始代码回到第11列
]
]
后面的都是以此类推,就不一一分析了。
以上就是SourceMap编解码的大体流程,github地址在这里,感兴趣的可以自己尝试一下。
cheap-source-map 和 eval-source-map 最后来看看在开发中用得较多的这两种SourceMap,分别以cheap和eval作为前缀。我们先分析cheap,顾名思义,这种SourceMap比较“便宜”一些,由于大多数情况下我们只需要映射源码的行号,而列号和变量信息其实不是必需的,因为一行代码也就那么些字符,出错后找到对应的行进行检查即可。这种方式节省了大量的存储和计算开销,我们把上面的devtool设置成cheap-source-map再编译,看下main.js.map文件:
{
"version": 3,
"file": "main.js",
"sources": ["webpack:///./index.js"],
"sourcesContent": ["var foo = 'hello';\n\nvar bar = function bar(a, b) {\n return a + b;\n};"],
"mappings": ";;;;;;;;;AAAA;AACA;AACA;AACA;AACA;;;;A",
"sourceRoot": ""
}
与source-map不同,这里的sourcesContent字段保存的是经过babel转换后的代码,这意味着它是webpack生成的代码与经babel转化后的代码的映射,而非与原始代码的映射。再看mappings信息,前9行仍然是没法对应,都以一个分号表示,第10行是AAAA,解码后是[0, 0, 0, 0]代表sourcesContent的第1行; 第11-14行都是AACA,解码后是[0, 0, 1, 0]分别代表sourcesContent的第2-5行,这几行都是由原始代码中的箭头函数解析得到的。
再来看看eval-source-map,使用它SourceMap信息始终内联在代码文件中,比如这样:
eval("var foo = 'hello';\n\nvar bar = function bar(a, b) {\n return a + b;\n};//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9pbmRleC5qcz80MWY1Il0sIm5hbWVzIjpbImZvbyIsImJhciIsImEiLCJiIl0sIm1hcHBpbmdzIjoiQUFBQSxJQUFNQSxHQUFHLEdBQUcsT0FBWjs7QUFDQSxJQUFNQyxHQUFHLEdBQUcsU0FBTkEsR0FBTSxDQUFDQyxDQUFELEVBQUlDLENBQUo7QUFBQSxTQUFVRCxDQUFDLEdBQUNDLENBQVo7QUFBQSxDQUFaIiwiZmlsZSI6Ii4vaW5kZXguanMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJjb25zdCBmb28gPSAnaGVsbG8nO1xyXG5jb25zdCBiYXIgPSAoYSwgYikgPT4gYStiO1xyXG4iXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///./index.js\n");
看来看去,我们的代码就是前面一小段,后面带着一个很长的尾巴sourceMappingURL,这个是什么东西呢?不妨用base64解码一下:
JSON.parse(atob('eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9pbmRleC5qcz80MWY1Il0sIm5hbWVzIjpbImZvbyIsImJhciIsImEiLCJiIl0sIm1hcHBpbmdzIjoiQUFBQSxJQUFNQSxHQUFHLEdBQUcsT0FBWjs7QUFDQSxJQUFNQyxHQUFHLEdBQUcsU0FBTkEsR0FBTSxDQUFDQyxDQUFELEVBQUlDLENBQUo7QUFBQSxTQUFVRCxDQUFDLEdBQUNDLENBQVo7QUFBQSxDQUFaIiwiZmlsZSI6Ii4vaW5kZXguanMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJjb25zdCBmb28gPSAnaGVsbG8nO1xyXG5jb25zdCBiYXIgPSAoYSwgYikgPT4gYStiO1xyXG4iXSwic291cmNlUm9vdCI6IiJ9'))
{
"version": 3,
"sources": ["webpack:///./index.js?41f5"],
"names": ["foo", "bar", "a", "b"],
"mappings": "AAAA,IAAMA,GAAG,GAAG,OAAZ;;AACA,IAAMC,GAAG,GAAG,SAANA,GAAM,CAACC,CAAD,EAAIC,CAAJ;AAAA,SAAUD,CAAC,GAACC,CAAZ;AAAA,CAAZ",
"file": "./index.js.js",
"sourcesContent": ["const foo = 'hello';\r\nconst bar = (a, b) => a+b;\r\n"],
"sourceRoot": ""
}
可见其只不过是把map信息以base64格式存储在代码中,换汤不换药,其实还是那些东西,穿了个马甲而已。
总结
本文探索了SourceMap的编解码原理,这种常用的源码映射工具使用了Base64 VLQ编码,引入vlq库可以轻松地进行编解码;
对webpack中常用的cheap-source-map和eval-source-map进行了分析,其实跟上者大同小异;