前端发展至今已不再是刀耕火种的年代了,出现了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

SourceMap解析的更多相关文章

  1. Webfunny知识分享:webpack sourceMap解析源码

    前端的业务越来越庞大,导致我们需要引入的js等静态资源文件的体积也越来越大,不得不使用压缩js文件的方式来提高加载的效率. 编译工具的诞生,极大地方便了我们处理js文件的这一过程,但压缩后的js文件极 ...

  2. 从无到有<前端异常监控系统>落地

    导火索 有一天一个测试同事的一个移动端页面白屏了,看样子是页面哪里报错了.  我自己打开页面并没有报错,最后发现报错只存在于他的手机,移动端项目又是在微信环境下,调试起来会比较麻烦,最后用他手机调试才 ...

  3. jQuery2.x源码解析(构建篇)

    jQuery2.x源码解析(构建篇) jQuery2.x源码解析(设计篇) jQuery2.x源码解析(回调篇) jQuery2.x源码解析(缓存篇) 笔者阅读了园友艾伦 Aaron的系列博客< ...

  4. happypack 原理解析

    说起 happypack 可能很多同学还比较陌生,其实 happypack 是 webpack 的一个插件,目的是通过多进程模型,来加速代码构建,目前我们的线上服务器已经上线这个插件功能,并做了一定适 ...

  5. 利用sourcemap来调试sass

    最近项目用上了sass,作为css的预处理器,它可以让我们用程序化的思维书写样式,极大的简化了css的开发,实在是前端居家旅行必备的利器. 我们都知道,在项目中,样式的频繁调试是不可避免的,用上sas ...

  6. Spring初始化 Map 和 解析Json value

    单独定义Map数据结构的bean: <bean id= "expToLevelMap" class="org.springframework.beans.facto ...

  7. gulp源码解析(二)—— vinyl-fs

    在上一篇文章我们对 Stream 的特性及其接口进行了介绍,gulp 之所以在性能上好于 grunt,主要是因为有了 Stream 助力来做数据的传输和处理. 那么我们不难猜想出,在 gulp 的任务 ...

  8. vue-cli的webpack模版项目配置解析

    上一篇文章已经分析了build/dev-server.js,里面使用到了其他config文件. 那么我们这篇文章,按着dev-server.js的使用顺序,来分析下其他文件. 首选,调用check-v ...

  9. vue-cli的webpack模版,相关配置文件dev-server.js与webpack.config.js配置解析

    1.下载vue-cli npm install vue-cli -g vue-cli的使用与详细介绍,可以到github上获取https://github.com/vuejs/vue-cli 2.安装 ...

  10. 简单解析nestJS目录

    使用Nest CLI设置新项目非常简单 .只需确保 安装了npm,然后在OS终端中使用以下命令: $ npm i -g @nestjs/cli $ nest new project-name $ cd ...

随机推荐

  1. Error occurred while proxying request localhost:端口 报错500的解决方法

    '/AuthServer/api/': { target: 'https://localhost:44319', secure:false,// 这是签名认证,http和https区分的参数设置 ch ...

  2. Git客户端部署使用-生成ssh密钥2

    1. 设置用户名 其中双引号中的 XXX 是用户名,记得替换成自己的用户名,需要注意的是这里的用户名是git服务器所使用的名称,一般公司用的都是员工名称的全拼. # 在git窗口中输入命令行: git ...

  3. php不缓存直接输出

    ini_set('max_execution_time', 600); header('X-Accel-Buffering:no'); ob_end_flush(); $l_zhen = \M('zh ...

  4. TCP通信实现两个主机之间的信息交互

    TCP通信概述TCP协议用来控制两个网络设备之间的点对点通信,两端设备按作用分为客户端和服务端.服务端为客户端提供服务,通常等待客户端的请求信息,有客户端请求到达之后,及时提供服务和返回响应消息:客户 ...

  5. django限制表单上传图片的大小

    django的ImageField没有提供控制上传图片的内置方法,我们可以在表单验证的过程中用clean函数来控制,搬运博客园 python小童鞋 ,可以通过重写ImageField的方法来控制上传图 ...

  6. 【git】3.5 git分支-远程分支

    资料来源 (1) https://git-scm.com/book/zh/v2/Git-%E5%88%86%E6%94%AF-%E8%BF%9C%E7%A8%8B%E5%88%86%E6%94%AF ...

  7. NSQ(8)-有赞相关改进

    如何保证消息队列的高可用(HA) NSQ 本身就是一个分布式消息队列,且支持水平扩展,无单点故障,能在无中断的情况下无缝添加集群结点. nsq用到了集群去保证整个服务的高可用,但并不能保证单个topi ...

  8. 安装fearch

    sudo add-apt-repository ppa:christian-boxdoerfer/fsearch-daily sudo apt-get update sudo apt-get inst ...

  9. mysql添加到环境变量

    今天换新系统,以前的一些常用软件重新安装了一下,安装到mysql我还是按照以前的习惯选择了低版本的5.7系列,突然想要装一把,像python一样可以直接访问解释器,能不能直接在cmd中输入mysql就 ...

  10. Docker的常见使用

    一.Docker的常见使用 1.docker的使用 1.1 查看docker版本号信息 docker version docker info 1.2 启动docker systemctl start ...