前端發展至今已不再是刀耕火種的年代了,出現了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個字符分別表示:
- 該位置在轉化后的代碼中的列數(相對於上一個條目)
- 文件序號
- 該位置在原始代碼中的行數(相對於上一個條目)
- 該位置在原始代碼中的列數(相對於上一個條目)
- 可能沒有,該位置包含names屬性中的哪個變量的聲明,對應該屬性的index (相對於上一個變量出現的index)
編碼分析
這些信息都是數字格式,使用Base64 VLQ進行編碼,在二進制位運算基礎上操作,具體步驟為:
- 如果數字大於或等於0,左移一位;如果數字小於0,先取絕對值,然后左移一位,接着將末位置為1;
- 取數字最低的5位,並將數字右移5位;
- 如果此時數字為0,使用Base64編碼字符序列輸出第2步中取到的5位;如果數字不為0,則將第2步中取到的5位前面補1,使用Base64編碼字符序列輸出字符並循環第2步;
Base64編碼序列表:

下面舉兩個例子具體來看下。
首先看16這個數:
- 16(10000)大於0,按照第1步,左移一位,變成100000;
- 按照第2步,取最低的5位,得到00000,數字剩余1,按照第3步,在00000前方補1得到100000,轉化為十進制是32,對應的字符是
g
,此時有數字剩余,繼續第2步; - 按照第2步,取最低的5位,得到1,數字剩余0,按照第3步,直接輸出1對應的字符'B';
經過轉化,16對應的Base64 VLQ編碼是gB
。
再看-2333這個數:
- -2333小於0,按照第1步,先取絕對值得到2333(100100011101),左移一位,然后末位置為1,變成1001000111011;
- 按照第2步,取最低的5位,得到11011,數字剩余10010001,按照第3步,在11011前方補1得到111011,轉化為十進制是59,對應的字符是
7
,此時有數字剩余,繼續第2步; - 按照第2步,取最低的5位,得到10001,數字剩余100,按照第3步,在10001前方補1得到110001,轉化為十進制是49,對應的字符是
x
,此時有數字剩余,繼續第2步; - 按照第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格式存儲在代碼中,換湯不換葯,其實還是那些東西,穿了個馬甲而已。
總結
- 本文探索了SourceMap的編解碼原理,這種常用的源碼映射工具使用了Base64 VLQ編碼,引入vlq庫可以輕松地進行編解碼;
- 對webpack中常用的cheap-source-map和eval-source-map進行了分析,其實跟上者大同小異;
References
[1]. JavaScript Source Map 詳解
[2]. Decoding and Encoding Base64 Vlqs in Source Maps
[3]. wiki