SourceMap 原理

共 7300字,需浏览 15分钟

 ·

2021-12-16 05:26


前端发展至今已不再是刀耕火种的年代了,出现了typescript、babel、uglify.js等功能强大的工具。我们手动撰写的代码一般具有可读性,并且可以享受高级语法、类型检查带来的便利,但经过工具链处理并上线的代码一般不具有可读性,且为了兼容低版本浏览器往往降级到低级语法,这些代码在转换过程中发生了变化,使我们并不能马上识别原始代码的组合方式,这提供了一定的源码安全性。


虽然带来了这些好处,但最终代码的排错是一个难点,SourceMap作为一种代码索引的工具,已经被广泛应用于这类场景了,它通过保存转换前和转换后代码在行、列上的对应关系,形成类似“映射”的结构,一旦转换的代码出了问题,可以查找到对应原始代码的位置。


本文针对webpack SourceMap的生成方法进行了探讨,涉及Base64 VLQ编码的基本知识,配合案例进行讨论,希望能对想了解它的开发者有所帮助。


生成SourceMap

我们先创建一个文件index.js,书写一些ES6的语法,然后配置webpack利用babel转换到低级语法。

// index.jsconst foo = 'hello';const bar = (a, b) => a+b;


然后配置webpack生成SourceMap文件

const path = require('path')
module.exports = { 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进行了分析,其实跟上者大同小异;


浏览 90
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报