SourceMap解析

时间:2020-05-18
本文章向大家介绍SourceMap解析,主要包括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')

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格式存储在代码中,换汤不换药,其实还是那些东西,穿了个马甲而已。

总结

  1. 本文探索了SourceMap的编解码原理,这种常用的源码映射工具使用了Base64 VLQ编码,引入vlq库可以轻松地进行编解码;
  2. 对webpack中常用的cheap-source-map和eval-source-map进行了分析,其实跟上者大同小异;

References

[1]. JavaScript Source Map 详解
[2]. Decoding and Encoding Base64 Vlqs in Source Maps
[3]. wiki

原文地址:https://www.cnblogs.com/SteelArm/p/12865725.html