前言
说起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配置了devtool为false, 并且mode为prodction, 也就是说代码会被压缩,最后生成了文件bundle.js。
然后我们打开页面,不出意外代码报错了:
我们直接点报错的位置~
什么玩意儿~ 好吧,接下来配置上sourcemap
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
filename: 'bundle.js',
},
devtool: 'source-map', // 配置sourcemap
// ...
}
在配置好sourcemap后会多生成一个.map文件,那么它有什么用呢
我们来看看打包后的文件
可以看到js代码通过 sourceMappingURL 指定了 sourcemap文件的路径
OK,我们再来打开页面看看
非常完美,不仅展示了原始的代码,而且精确到了对应的列。
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":""
}
解释一下:
| 字段 | 含义 |
|---|---|
| version | sourcemap的版本,大家不用关心 |
| sources | 转换前的文件,该项是一个数组,表示可能存在多个文件合并, 这里可以看到有两个文件路径,第一个是webpack注入的运行时代码,也就是__webpack_require__这些用于执行的代码, 第二个是我们自己的入口代码路径 |
| names | 代码里面所有的标识符(token) |
| mappings | 代码转换前后的位置信息,后面会讲 |
| file | 转换后的文件名 |
| sourcesContent | 原始代码内容 |
| sourceRoot | 转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空 |
devtool配置
在webpack中source是通过devtool来配置sourcemap的,但是由于生成sourcemap也需要耗费一定的时间,所以,对于devtool我们在不同环境需要做一些特定的配置。那么在webpack中的配置其实有很多种:
| devtool | performance | production | quality | comment |
|---|---|---|---|---|
| (none) | build: 最快 rebuild: 最快 | yes | bundle | Recommended choice for production builds with maximum performance. |
eval | build: 快 rebuild: 最快 | no | generated | Recommended choice for development builds with maximum performance. |
eval-cheap-source-map | build: ok rebuild: 快 | no | transformed | Tradeoff choice for development builds. |
eval-cheap-module-source-map | build: 慢 rebuild: 快 | no | original lines | Tradeoff choice for development builds. |
eval-source-map | build: 最慢 rebuild: ok | no | original | Recommended choice for development builds with high quality SourceMaps. |
cheap-source-map | build: ok rebuild: 慢 | no | transformed | |
cheap-module-source-map | build: 慢 rebuild: 慢 | no | original lines | |
source-map | build: 最慢 rebuild: 最慢 | yes | original | Recommended choice for production builds with high quality SourceMaps. |
inline-cheap-source-map | build: ok rebuild: 慢 | no | transformed | |
inline-cheap-module-source-map | build: 慢 rebuild: 慢 | no | original lines | |
inline-source-map | build: 最慢 rebuild: 最慢 | no | original | Possible choice when publishing a single file |
eval-nosources-cheap-source-map | build: ok rebuild: fast | no | transformed | source code not included |
eval-nosources-cheap-module-source-map | build: 慢 rebuild: fast | no | original lines | source code not included |
eval-nosources-source-map | build: 最慢 rebuild: ok | no | original | source code not included |
inline-nosources-cheap-source-map | build: ok rebuild: 慢 | no | transformed | source code not included |
inline-nosources-cheap-module-source-map | build: 慢 rebuild: 慢 | no | original lines | source code not included |
inline-nosources-source-map | build: 最慢 rebuild: 最慢 | no | original | source code not included |
nosources-cheap-source-map | build: ok rebuild: 慢 | no | transformed | source code not included |
nosources-cheap-module-source-map | build: 慢 rebuild: 慢 | no | original lines | source code not included |
nosources-source-map | build: 最慢 rebuild: 最慢 | yes | original | source code not included |
hidden-nosources-cheap-source-map | build: ok rebuild: 慢 | no | transformed | no reference, source code not included |
hidden-nosources-cheap-module-source-map | build: 慢 rebuild: 慢 | no | original lines | no reference, source code not included |
hidden-nosources-source-map | build: 最慢 rebuild: 最慢 | yes | original | no reference, source code not included |
hidden-cheap-source-map | build: ok rebuild: 慢 | no | transformed | no reference |
hidden-cheap-module-source-map | build: 慢 rebuild: 慢 | no | original lines | no reference |
hidden-source-map | build: 最慢 rebuild: 最慢 | yes | original | no 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
// ...
}
总结
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?");
/***/ })
/******/ });
生成的代码将使用eval包裹, 并且没有生成map文件,注意这里是sourceURL, 指向了源代码路径。
下面我们打开文件
可以看到,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");
/***/ })
/******/ });
可以看到,最后打包文件包含了sourceURL和sourceMappingURL,只不过此时的map文件变成了base64
同样的,我们打开浏览器,跳转代码
发现sourcemap能正常帮助我们定位到原始代码的位置了~
那么eval相比普通的source-map到底有什么区别呢,其实webpack文档中,也有相关说明
sourcemap
意思就是说:eval-source-map和source-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放到代码里
总结
- 生成内联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); // 这里必定会报错 没有这个变量!
首先来看下打包后的文件
然后依旧打开文件
我们可以注意这两个点:
- let 变成了 var, 代表sourcemap并没有帮我们还原到最原始的代码
- 报错的位置,无法定位到具体的列。
总结
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
// ...
}
打开对应代码位置:
可以发现,上面的代码是没有经过loader处理的。
总结
module关键字,可以处理loader间的sourcemap, 帮助还原到最初始的代码
最后hidden和nosources 想必不用多说了吧~~
OK,通过上面的示例,相信大家对webpack的devtool配置已经非常清楚了,至于其他配置都是通过这些关键词扩展而来。那么我们在生产环境和开发环境改使用什么样的配置呢。
最佳实践
- 开发环境: 在开发环境,我们想要代码编译的足够快,并且能看到最原始的代码,所以推荐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中,编码顺序是从高位到低位
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);
参考
- 珠峰 sourcemap
- Babel 插件通关秘籍