Webpack原理系列(三)sourcemap原理

1,935 阅读13分钟

前言

说起sourcemap,大家应该都不陌生,由于前端代码一般都需要编译和打包,出来的代码是非常难以阅读的,有了它能方便我们调试,快速定位到问题。

什么是sourcemap?

sourcemap本质是个信息文件,记录了代码转换前后的位置信息。另外sourcemap并不是webpack特有的,其他打包工具一般都支持。

使用sourcemap

我们先对比一下使用sourcemap前后的样子

// index.js
function func() {
  return 1
}
console.log(func());
console.log(a); // 报错

上面的代码中,故意写了一行错误代码。

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: false
  // ...
}

webpack.config.js配置了devtoolfalse, 并且mode为prodction, 也就是说代码会被压缩,最后生成了文件bundle.js

然后我们打开页面,不出意外代码报错了:

image.png

我们直接点报错的位置~

image.png

什么玩意儿~ 好吧,接下来配置上sourcemap

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'source-map', // 配置sourcemap
  // ...
}

在配置好sourcemap后会多生成一个.map文件,那么它有什么用呢

image.png

我们来看看打包后的文件

image.png

可以看到js代码通过 sourceMappingURL 指定了 sourcemap文件的路径

OK,我们再来打开页面看看

image.png

image.png

非常完美,不仅展示了原始的代码,而且精确到了对应的

sourcemap文件内容

了解了sourcemap的使用方法后,我们再来看看sourcemap文件的内容到底是什么意思?

{
  "version":3,
  "sources":["webpack:///webpack/bootstrap","webpack:///./src/index.js"],
  "names":[
    "installedModules","__webpack_require__","moduleId","exports","module","i","l","modules","call","m","c","d","name","getter","o","Object","defineProperty","enumerable","get","r","Symbol","toStringTag","value","t","mode","__esModule","ns","create","key","bind","n","object","property","prototype","hasOwnProperty","p","s","console","log","a"
  ],
  "mappings":"aACE,IAAIA,EAAmB,GAGvB,SAASC,EAAoBC,GAG5B,GAAGF,EAAiBE,GACnB,OAAOF,EAAiBE,GAAUC,QAGnC,IAAIC,EAASJ,EAAiBE,GAAY,CACzCG,EAAGH,EACHI,GAAG,EACHH,QAAS,IAUV,OANAI,EAAQL,GAAUM,KAAKJ,EAAOD,QAASC,EAAQA,EAAOD,QAASF,GAG/DG,EAAOE,GAAI,EAGJF,EAAOD,QAKfF,EAAoBQ,EAAIF,EAGxBN,EAAoBS,EAAIV,EAGxBC,EAAoBU,EAAI,SAASR,EAASS,EAAMC,GAC3CZ,EAAoBa,EAAEX,EAASS,IAClCG,OAAOC,eAAeb,EAASS,EAAM,CAAEK,YAAY,EAAMC,IAAKL,KAKhEZ,EAAoBkB,EAAI,SAAShB,GACX,oBAAXiB,QAA0BA,OAAOC,aAC1CN,OAAOC,eAAeb,EAASiB,OAAOC,YAAa,CAAEC,MAAO,WAE7DP,OAAOC,eAAeb,EAAS,aAAc,CAAEmB,OAAO,KAQvDrB,EAAoBsB,EAAI,SAASD,EAAOE,GAEvC,GADU,EAAPA,IAAUF,EAAQrB,EAAoBqB,IAC/B,EAAPE,EAAU,OAAOF,EACpB,GAAW,EAAPE,GAA8B,iBAAVF,GAAsBA,GAASA,EAAMG,WAAY,OAAOH,EAChF,IAAII,EAAKX,OAAOY,OAAO,MAGvB,GAFA1B,EAAoBkB,EAAEO,GACtBX,OAAOC,eAAeU,EAAI,UAAW,CAAET,YAAY,EAAMK,MAAOA,IACtD,EAAPE,GAA4B,iBAATF,EAAmB,IAAI,IAAIM,KAAON,EAAOrB,EAAoBU,EAAEe,EAAIE,EAAK,SAASA,GAAO,OAAON,EAAMM,IAAQC,KAAK,KAAMD,IAC9I,OAAOF,GAIRzB,EAAoB6B,EAAI,SAAS1B,GAChC,IAAIS,EAAST,GAAUA,EAAOqB,WAC7B,WAAwB,OAAOrB,EAAgB,SAC/C,WAA8B,OAAOA,GAEtC,OADAH,EAAoBU,EAAEE,EAAQ,IAAKA,GAC5BA,GAIRZ,EAAoBa,EAAI,SAASiB,EAAQC,GAAY,OAAOjB,OAAOkB,UAAUC,eAAe1B,KAAKuB,EAAQC,IAGzG/B,EAAoBkC,EAAI,GAIjBlC,EAAoBA,EAAoBmC,EAAI,G,gBC/ErDC,QAAQC,IAFC,GAGTD,QAAQC,IAAIC",
  "file":"bundle.js",
  "sourcesContent":[
    " \t// The module cache\n \tvar installedModules = {};\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {...",
    "function func() {\r\n  return 1\r\n}\r\nconsole.log(func());\r\nconsole.log(a);\r\n"
  ],
  "sourceRoot":""
}

解释一下:

字段含义
versionsourcemap的版本,大家不用关心
sources转换前的文件,该项是一个数组,表示可能存在多个文件合并, 这里可以看到有两个文件路径,第一个是webpack注入的运行时代码,也就是__webpack_require__这些用于执行的代码, 第二个是我们自己的入口代码路径
names代码里面所有的标识符(token)
mappings代码转换前后的位置信息,后面会讲
file转换后的文件名
sourcesContent原始代码内容
sourceRoot转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空

devtool配置

在webpack中source是通过devtool来配置sourcemap的,但是由于生成sourcemap也需要耗费一定的时间,所以,对于devtool我们在不同环境需要做一些特定的配置。那么在webpack中的配置其实有很多种:

中文文档

devtoolperformanceproductionqualitycomment
(none)build: 最快
rebuild: 最快
yesbundleRecommended choice for production builds with maximum performance.
evalbuild: 快
rebuild: 最快
nogeneratedRecommended choice for development builds with maximum performance.
eval-cheap-source-mapbuild: ok
rebuild: 快
notransformedTradeoff choice for development builds.
eval-cheap-module-source-mapbuild: 慢
rebuild: 快
nooriginal linesTradeoff choice for development builds.
eval-source-mapbuild: 最慢
rebuild: ok
nooriginalRecommended choice for development builds with high quality SourceMaps.
cheap-source-mapbuild: ok
rebuild: 慢
notransformed
cheap-module-source-mapbuild: 慢
rebuild: 慢
nooriginal lines
source-mapbuild: 最慢
rebuild: 最慢
yesoriginalRecommended choice for production builds with high quality SourceMaps.
inline-cheap-source-mapbuild: ok
rebuild: 慢
notransformed
inline-cheap-module-source-mapbuild: 慢
rebuild: 慢
nooriginal lines
inline-source-mapbuild: 最慢
rebuild: 最慢
nooriginalPossible choice when publishing a single file
eval-nosources-cheap-source-mapbuild: ok
rebuild: fast
notransformedsource code not included
eval-nosources-cheap-module-source-mapbuild: 慢
rebuild: fast
nooriginal linessource code not included
eval-nosources-source-mapbuild: 最慢
rebuild: ok
nooriginalsource code not included
inline-nosources-cheap-source-mapbuild: ok
rebuild: 慢
notransformedsource code not included
inline-nosources-cheap-module-source-mapbuild: 慢
rebuild: 慢
nooriginal linessource code not included
inline-nosources-source-mapbuild: 最慢
rebuild: 最慢
nooriginalsource code not included
nosources-cheap-source-mapbuild: ok
rebuild: 慢
notransformedsource code not included
nosources-cheap-module-source-mapbuild: 慢
rebuild: 慢
nooriginal linessource code not included
nosources-source-mapbuild: 最慢
rebuild: 最慢
yesoriginalsource code not included
hidden-nosources-cheap-source-mapbuild: ok
rebuild: 慢
notransformedno reference, source code not included
hidden-nosources-cheap-module-source-mapbuild: 慢
rebuild: 慢
nooriginal linesno reference, source code not included
hidden-nosources-source-mapbuild: 最慢
rebuild: 最慢
yesoriginalno reference, source code not included
hidden-cheap-source-mapbuild: ok
rebuild: 慢
notransformedno reference
hidden-cheap-module-source-mapbuild: 慢
rebuild: 慢
nooriginal linesno reference
hidden-source-mapbuild: 最慢
rebuild: 最慢
yesoriginalno reference. Possible choice when using SourceMap only for error reporting purposes.

可以看到上面的devtool的配置有很多种,但是怎么能挑选出我们需要的最优配置呢?

其实我们可以把这些配置拆分成多个关键字,并且有各自的含义

  • source-map:产生.map文件
  • eval:使用eval包裹模块代码,并且不会生成sourcemap
  • cheap:不包含列信息也不包含loader的sourcemap
  • module: 包含loader的sourcemap(比如jsx to js ,babel的sourcemap),否则无法定义源文件
  • inline: 将.map作为DataURI(base64)嵌入,不单独生成.map文件,
  • hidden: 隐藏sourcemap, 能正常生成map文件,但不会引入,推荐线上使用
  • nosources:隐藏sourcesContent内容,防止看到源码

下面我会挑几个重要的关键字,并通过一些配置来了解用途:

配置一:source-map

module.exports = {
  mode: 'development', // 开发环境
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'source-map', // 配置sourcemap
  // ...
}

image.png

总结

  • source-map关键字,会生成sourcemap,生成的sourcemap最完整
  • 初次构建和重新构建,都会重新生成,性能最差

配置二:eval

module.exports = {
  mode: 'development', // 开发环境
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'eval', // 配置sourcemap
  // ...
}
// 需要编译的代码
let a = 1
console.log([1].includes('2'));

注意以上代码经过了babel-loader编译!

/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime-corejs3/core-js-stable/instance/includes */ \"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js\");\n/* harmony import */ var _babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0__);\nvar _context;\n\n\nvar a = 1;\nconsole.log(_babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0___default()(_context = [1]).call(_context, '2'));\n\n//# sourceURL=webpack:///./src/index.js?");

/***/ })

/******/ });

image.png

生成的代码将使用eval包裹, 并且没有生成map文件,注意这里是sourceURL, 指向了源代码路径。

下面我们打开文件

image.png

image.png

可以看到,eval只能看到经过loader处理后的代码,并不能看到最原始的代码。另外它只是帮我们定位了eval包裹的代码中的位置

总结

  • eval关键字会使用eval函数包裹,没有生成sourcemap
  • 使用sourceURL指向源码路径
  • 只能看到经过loader处理后的代码,不能看到原始代码

配置三:eval-source-map

相比eval增加了source-map

module.exports = {
  mode: 'development', // 开发环境
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'eval-source-map', // 配置sourcemap
  // ...
}
// 需要编译的代码
let a = 1
console.log([1].includes('2'));
/***/ "./src/index.js":
/*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
/*! no exports provided */
/***/ (function(module, __webpack_exports__, __webpack_require__) {

"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime-corejs3/core-js-stable/instance/includes */ \"./node_modules/@babel/runtime-corejs3/core-js-stable/instance/includes.js\");\n/* harmony import */ var _babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0__);\nvar _context;\n\n\nvar a = 1;\nconsole.log(_babel_runtime_corejs3_core_js_stable_instance_includes__WEBPACK_IMPORTED_MODULE_0___default()(_context = [1]).call(_context, '2'));//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly8vLi9zcmMvaW5kZXguanM/YjYzNSJdLCJuYW1lcyI6WyJhIiwiY29uc29sZSIsImxvZyJdLCJtYXBwaW5ncyI6Ijs7Ozs7O0FBQUEsSUFBSUEsQ0FBQyxHQUFHLENBQVI7QUFDQUMsT0FBTyxDQUFDQyxHQUFSLENBQVksMkdBQUMsQ0FBRCxrQkFBYSxHQUFiLENBQVoiLCJmaWxlIjoiLi9zcmMvaW5kZXguanMuanMiLCJzb3VyY2VzQ29udGVudCI6WyJsZXQgYSA9IDFcclxuY29uc29sZS5sb2coWzFdLmluY2x1ZGVzKCcyJykpOyJdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///./src/index.js\n");

/***/ })

/******/ });

可以看到,最后打包文件包含了sourceURLsourceMappingURL,只不过此时的map文件变成了base64

image.png

同样的,我们打开浏览器,跳转代码

image.png

发现sourcemap能正常帮助我们定位到原始代码的位置了~

那么eval相比普通的source-map到底有什么区别呢,其实webpack文档中,也有相关说明 sourcemap

image.png

意思就是说:eval-source-mapsource-map其实是差不多的,但是eval是字符串,可以对模块进行缓存,重复构建,性能更好。

总结

  • 不会生成map文件,但会生成内联sourcemap
  • 性能更好,可以缓存模块sourcemap

配置四:inline-source-map

module.exports = {
  mode: 'development', // 开发环境
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'inline-source-map', // 配置sourcemap
  // ...
}

这个配置就好理解了,不会生成map文件,但会把sourcemap放到代码里

image.png

总结

  • 生成内联sourcemap,嵌入到代码里

配置五:cheap-source-map

module.exports = {
  mode: 'development', // 开发环境
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'cheap-source-map', // 配置sourcemap
  // ...
}

改动下之前的代码

let a = 1
console.log(b); // 这里必定会报错 没有这个变量!

首先来看下打包后的文件

image.png

然后依旧打开文件

image.png

image.png

我们可以注意这两个点:

  1. let 变成了 var, 代表sourcemap并没有帮我们还原到最原始的代码
  2. 报错的位置,无法定位到具体的列。

总结

  • cheap这个关键字,可以去除列信息,减小map文件的体积,提高编译性能
  • 只能还原到loader处理后的模块代码,无法还原最初始的代码

那么怎么能还原到最初的代码呢?下面我们来看下module关键字

配置六:cheap-module-source-map

什么是模块之间的sourcemap呢,例如js的高版本语法会经历babel-loader处理成低版本的js,如果没有loader之间的sourceMap,那么在定位问题的时候,就只能定位到最后经过loader处理的代码。所以module的作用显而易见了。

module.exports = {
  mode: 'development', // 开发环境
  entry: './src/index.js',
  output: {
    filename: 'bundle.js',
  },
  devtool: 'cheap-module-source-map', // 配置sourcemap
  // ...
}

image.png

打开对应代码位置:

image.png

可以发现,上面的代码是没有经过loader处理的。

总结

  • module关键字,可以处理loader间的sourcemap, 帮助还原到最初始的代码

最后hiddennosources 想必不用多说了吧~~

OK,通过上面的示例,相信大家对webpackdevtool配置已经非常清楚了,至于其他配置都是通过这些关键词扩展而来。那么我们在生产环境和开发环境改使用什么样的配置呢。

最佳实践

  • 开发环境: 在开发环境,我们想要代码编译的足够快,并且能看到最原始的代码,所以推荐cheap-module-eval-source-map
  • 生产环境:在生产环境,我们当然不希望别人能定位到原始代码,但是又希望产生map文件,用于监控平台sentry等,所以推荐hidden-source-map

sourcemap的原理

在sourcemap映射到源码的过程中,主要是依靠了mappings字段

例如我们有如下的sourceMap:

{
  version : 3,
   file: "out.js",
   sourceRoot : "",
   sources: ["foo.js", "bar.js"],
   names: ["src", "maps", "are", "fun"],
   mappings: "AAgBC,SAAQ,CAAEA"
}

重点看 mappping 部分

mappings:"AAAAA,BBBBB;;;;CCCCC,DDDDD"
  • 分号代表换行
  • 第一位是目标代码中的列数
  • 第二位是源码所在的文件名
  • 第三位是源码对应的行数
  • 第四位是源码对应的列数
  • 第五位是源码对应的 names,不一定有

每一位是通过 VLQ 编码的,一个字符就能表示行列数

VLQ编码

  • VLQ是Variable-length quantity 的缩写,是一种通用的、使用任意位数的二进制来表示一个任意大的数字的一种编码方式

  • 这种编码需要用最高位表示连续性,如果是1,代表这组字节后面的一组字节也属于同一个数;如果是0,表示该数值到这就结束了

  • 比如我们对数值137进行VLQ编码

    • 将137改写成二进制形式 10001001
    • 七位一组做分组,不足的补0 0000001 0001001
    • 最后一组开头补0,其余补1 10000001 00001001
    • 137的VLQ编码形式为10000001 00001001

代码实现:

let binary = 137..toString(2);
console.log(binary); //10001001
let padded = binary.padStart(Math.ceil(binary.length / 7) * 7, '0'); console.log(padded);//00000010001001
let groups = padded.match(/\d{7}/g);
groups = groups.map((group,index)=>(index==0?'1':'0')+group); 

console.log(groups);// ['10000001','00001001']

虽然实现了简单的VLQ编码,但是仍然还不够,还需要Base64对VLQ做进一步转换

Base64 VLQ

  • 一个Base64字符只能表示6bit(2^6)的数据

  • Base64 VLQ需要能够表示负数,于是用最后一位来作为符号标志位

  • 由于只能用6位进行存储,而第一位表示是否连续的标志,最后一位表示正数/负数。中间只有4位,因此一个单元表示的范围为[-15,15],如果超过了就要用连续标识位了

  • 表示正负的方式

    • 如果这组数是某个数值的VLQ编码的第一组字节,那它的最后一位代表"符号",0为正,1为负;
    • 如果不是,这个位没有特殊含义,被算作数值的一部分
  • 在Base64 VLQ中,编码顺序是从低位到高位,而在VLQ中,编码顺序是从高位到低位

vlq

let base64 = [
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
    'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
    'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
    'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'
];
/**
 * 1. 将137改写成二进制形式  10001001
 * 2. 127是正数,末位补0 100010010
 * 3. 五位一组做分组,不足的补0 01000 10010
 * 4. 将组倒序排序 10010 01000
 * 5. 最后一组开头补0,其余补1 110010 001000
 * 6. 转64进制 y和I
 */
function encode(num) {
    //1. 将137改写成二进制形式,如果是负数的话是绝对值转二进制
    let binary = (Math.abs(num)).toString(2);
    //2.正数最后边补0,负数最右边补1,127是正数,末位补0 100010010
    binary = num >= 0 ? binary + '0' : binary + '1';
    //3.五位一组做分组,不足的补0   01000 10010 
    let zero = 5 - (binary.length % 5);
    if (zero > 0) {
        binary = binary.padStart(Math.ceil(binary.length / 5) * 5, '0');
    }
    let parts = [];
    for (let i = 0; i < binary.length; i += 5) {
        parts.push(binary.slice(i, i + 5));
    }// 01000 10010
    //4. 将组倒序排序 10010 01000
    parts.reverse();// ['00000','00001']
    //5. 最后一组开头补0,其余补1 110010 001000
    for (let i = 0; i < parts.length; i++) {
        if (i === parts.length - 1) {
            parts[i] = '0' + parts[i];
        } else {
            parts[i] = '1' + parts[i];
        }
    }
    //6.转64进制 y和I
    let chars = [];
    for (let i = 0; i < parts.length; i++) {
        chars.push(base64[parseInt(parts[i], 2)]);
    }
    return chars.join('')
}
let result = encode(137);
console.log(result);

计算位移

let base64 = [
    'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
    'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f',
    'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v',
    'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/'
];
function getValue(char) {
    let index = base64.findIndex(item => item == char);//先找这个字符的索引
    let str = (index).toString(2);//索引转成2进制
    str = str.padStart(6, '0');//在前面补0补到6位
    //最后一位是符号位,正数最后一位是0,负数最后一位为1
    let sign = str.slice(-1)=='0'?1:-1;
    //最后一组第一位为0,其它的第一位为1
    str = str.slice(1, -1);
    return parseInt(str, 2)*sign;
}
function decode(values) {
    let parts = values.split(',');//分开每一个位置
    let positions = [];
    for(let i=0;i<parts.length;i++){
        let part = parts[i];
        let chars = part.split('');//得到每一个字符
        let position = [];
        for (let i = 0; i < chars.length; i++) {
            position.push(getValue(chars[i]));//获取此编写对应的值
        }
        positions.push(position);
    }
    return positions;
}
let positions = decode('AAAA,IAAIA,EAAE,CAAN,CACIC,EAAE,CADN,CAEIC,EAAE');
//后列,哪个源文件,前行,前列,变量
console.log('positions',positions);
let offsets = positions.map(item=>[item[2],item[3],0,item[0],]);
console.log('offsets',offsets);
let origin = {x:0,y:0};
let target = {x:0,y:0};
let mapping=[];
for(let i=0;i<offsets.length;i++){
    let [originX,originY,targetX,targetY] = offsets[i];
    origin.x += originX;
    origin.y += originY;
    target.x += targetX;
    target.y += targetY;
    mapping.push(`[${origin.x},${origin.y}]=>[${target.x},${target.y}]`);
}
console.log('mapping',mapping);

参考