前端发展至今已不再是刀耕火种的年代了,出现了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. https://www.cnblogs.com/DKSoft/category/608549.html

    https://www.cnblogs.com/DKSoft/category/608549.html

  2. IDEA/webstorm 安装插件重启/迁移后插件丢失

    使用toolbox调整了IDE的安装位置, 重启后发现插件全部丢失, 之前确实破解过, 但是现在是正版的 直接说一下我的解决方案吧: help -> Edit Custom VM Options ...

  3. 云计算——实验3:AWS实验-EC2操作

    本次实验属于验证型实验,通过本次实验学生将掌握以下内容: 1.EC2免费实例创建方法: 2.EC2实例SSH连接以及命令使用. 我使用阿里云进行注册和远程连接,aws需要使用信用卡,国内的比较省事. ...

  4. C# 中 SetTimeout 方案

    近期项目中需在用户点击按钮后,延时执行代码逻辑,避免频繁操作.网上没找到有关 C# SetTimeout 官方API , 于是通过异步线程,动手实现一个.方案如下,如果同一个DelayedProces ...

  5. UVM reg model 常见问题记录

    1.仿真log中报出大量的"include_coverage not located, did you mean ***"? (1) user在构建register model或者 ...

  6. 深入理解 epoll 原理

    从网卡如何接收数据说起 CPU 如何知道接受了数据? 进程阻塞为什么不占用 CPU 资源? 工作队列 等待队列 唤醒进程 内核接收网络数据全过程 同时监视多个 socket 的方法 select 的监 ...

  7. SED fitting

    Using the Robitaille (2017) YSO SED models https://notebook.community/hyperion-rt/paper-2017-sed-mod ...

  8. 《Vue.js 3.x高效前端开发(视频教学版)》简介

    #好书推荐##好书奇遇季#<Vue.js 3.x高效前端开发(视频教学版)>,京东当当天猫都有发售.本书配套示例源码.PPT课件.思维导图.数据集.开发环境与答疑服务. 本书通过对Vue. ...

  9. Beginning IOS 7 Development Exploring the IOS SDK - Handling Basic Interface Fun

    Beginning IOS 7 Development Exploring the IOS SDK 目前使用的是Objective-C,用这本书,简单记录一下 第一章,图书简介 第二章,简要介绍使用x ...

  10. SICP 笔记:环境配置

    SICP 笔记:环境配置 记录学习<算机的程序的构造和解释>的笔记. 环境配置 SICP 里面使用的语言是一种 Lisp 的变体 Scheme. 使用 DrRacket 作为 IDE 来进 ...