線上產品代碼一般是編譯過的,前端的編譯處理過程包括不限於
- 轉譯器/Transpilers (Babel, Traceur)
- 編譯器/Compilers (Closure Compiler, TypeScript, CoffeeScript, Dart)
- 壓縮/Minifiers (UglifyJS)
這里提及的都是可生成source map 的操作。
經過這一系列騷氣的操作后,發布到線上的代碼已經面目全非,對帶寬友好了,但對開發者調試並不友好。於是就有了 source map。顧名思義,他是源碼的映射,可以將壓縮后的代碼再對應回未壓縮的源碼。使得我們在調試線上產品時,就好像在調試開發環境的代碼。
來看一個工作的示例
准備兩個測試文件,一個 log.js
里包含一個輸出內容到控制台的函數:
log.js
function sayHello(name) {
if (name.length > 2) {
name = name.substr(0, 1) + '...'
}
console.log('hello,', name)
}
一個main.js
文件里面對這個方法進行了調用:
main.js
sayHello('世界')
sayHello('第三世界的人們')
我們使用 uglify-js
將兩者合並打包並且壓縮。
npm install uglify-js -g
uglifyjs log.js main.js -o output.js --source-map "url='/output.js.map'"
安裝並執行后,我們得到了一個輸出文件 output.js
,同時生成了一個 source map 文件 output.js.map
。
output.js
function sayHello(name){if(name.length>2){name=name.substr(0,1)+"..."}console.log("hello,",name)}sayHello("世界");sayHello("第三世界的人們");
//# sourceMappingURL=/output.js.map
output.js.map
{"version":3,"sources":["log.js","main.js"],"names":["sayHello","name","length","substr","console","log"],"mappings":"AAAA,SAASA,SAASC,MACd,GAAIA,KAAKC,OAAS,EAAG,CACjBD,KAAOA,KAAKE,OAAO,EAAG,GAAK,MAE/BC,QAAQC,IAAI,SAAUJ,MCJ1BD,SAAS,MACTA,SAAS"}
為了能夠讓 source map 能夠被瀏覽器加載和解析,
- 再添加一個
index.html
來加載我們生成的這個output.js
文件。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>source map demo</title>
</head>
<body>
source map demo
<script src="output.js"></script>
</body>
</html>
- 然后開啟一個本地服務器,這里我使用 python 自帶的server 工具:
python3 -m http.server
- 在瀏覽器中開啟 source map
source map
在瀏覽器中默認是關閉的,這樣就不會影響正常用戶。當我們開啟后,瀏覽器就根據壓縮代碼中指定的 source map 地址去請求 map 資源。
在瀏覽器中開啟 source map
最后,就可以訪問 http://localhost:8000/
來測試我們的代碼了。
在壓縮過的代碼中打斷點
從截圖中可以看到,開啟 source map 后,除了頁面中引用的 output.js
文件,瀏覽器還加載了生成它的兩個源文件,以方便我們在調試瀏覽器會自動映射回未壓縮合並的源文件。
為了測試,我們將 output.js 在調試工具中進行格式化,然后在 sayHello
函數中打一個斷點,看它是否能將這個斷點的位置還原到這段代碼真實所在的文件及位置。
刷新頁面后,我們發現,斷點正確定位到了 log.js
中正確的位置。
代碼的還原
會否覺得很贊啊!
下面我們來了解它的工作原理。
我們所想象的 source map
將現實中的情況簡化一下無非是以下的場景:
輸入 ⇒ 處理轉換(uglify) ⇒ 輸出(js)
上面,輸出無疑就是需要發布到產品線上的瀏覽器能運行的代碼。這里只討論 js,所以輸出是 js 代碼,當然,其實source map 也可以運用於其他資源比如 LESS/SASS 等編譯到的 CSS。
而 source map 的功能是幫助我們在拿到輸出后還原回輸入。如果我們自己來實現,應該怎么做。
最直觀的想法恐怕是,將生成的文件中每個字符位置對應的原位置保存起來,一一映射。請看來自這篇文章中給出的示例:
“feel the force” ⇒ Yoda ⇒ “the force feel”
一個簡單的文本轉換輸出,其中 Yoda
可以理解為一個轉換器。將上面的的輸入與輸出列成表格可以得出這個轉換后輸入與輸出的對應關系。
輸出位置 | 輸入 | 在輸入中的位置 | 字符 |
---|---|---|---|
行 1, 列 0 | Yoda_input.txt | 行 1, 列 5 | t |
行 1, 列 1 | Yoda_input.txt | 行 1, 列 6 | h |
行 1, 列 2 | Yoda_input.txt | 行 1, 列 7 | e |
行 1, 列 4 | Yoda_input.txt | 行 1, 列 9 | f |
行 1, 列 5 | Yoda_input.txt | 行 1, 列 10 | o |
行 1, 列 6 | Yoda_input.txt | 行 1, 列 11 | r |
行 1, 列 7 | Yoda_input.txt | 行 1, 列 12 | c |
行 1, 列 8 | Yoda_input.txt | 行 1, 列 13 | e |
行 1, 列 10 | Yoda_input.txt | 行 1, 列 0 | f |
行 1, 列 11 | Yoda_input.txt | 行 1, 列 1 | e |
行 1, 列 12 | Yoda_input.txt | 行 1, 列 2 | e |
行 1, 列 13 | Yoda_input.txt | 行 1, 列 3 | l |
這里之所以將輸入文件也作為映射的必需值,它可以告訴我們從哪里去找源文件。並且,在代碼合並時,生成輸出文件的源文件不止一個,記錄下每處代碼來自哪個文件,在還原時也很重要。
上面可以直觀看出,生成文件中 (1,0) 位置的字符對應源文件中 (1,5)位置的字符,...
將上面的表格整理記錄成一個映射編碼看起來會是這樣的:
mappings(283 字符):1|0|Yoda_input.txt|1|5, 1|1|Yoda_input.txt|1|6, 1|2|Yoda_input.txt|1|7, 1|4|Yoda_input.txt|1|9, 1|5|Yoda_input.txt|1|10, 1|6|Yoda_input.txt|1|11, 1|7|Yoda_input.txt|1|12, 1|8|Yoda_input.txt|1|13, 1|10|Yoda_input.txt|1|0, 1|11|Yoda_input.txt|1|1, 1|12|Yoda_input.txt|1|2, 1|13|Yoda_input.txt|1|3
這樣確實能夠將處理后的文件映射回原來的文件,但隨着內容的增多,轉換規則更加地復雜,這個記錄映射的編碼將飛速增長。這里源文件 feel the force
才12個字符,而記錄他轉換的映射就已經達到了283個字符。所以這個編碼的方式還有待改進。
省去輸出文件中的行號
大多數情況下處理后的文件行數都會少於源文件,特別是 js,使用 UglifyJS 壓縮后的文件通常只有一行。基於此,每必要在每條映射中都帶上輸出文件的行號,轉而在這些映射中插入;
來標識換行,可以節省大量空間。
mappings (245 字符): 0|Yoda_input.txt|1|5, 1|Yoda_input.txt|1|6, 2|Yoda_input.txt|1|7, 4|Yoda_input.txt|1|9, 5|Yoda_input.txt|1|10, 6|Yoda_input.txt|1|11, 7|Yoda_input.txt|1|12, 8|Yoda_input.txt|1|13, 10|Yoda_input.txt|1|0, 11|Yoda_input.txt|1|1, 12|Yoda_input.txt|1|2, 13|Yoda_input.txt|1|3;
可符號化字符的提取
這個例子中,一共有三個單詞,拿輸出文件中 the
來說,當我們通過它的第一個字母t
(1,0)確定出對應源文件中的位置(1,5),后面的he
其實不用再記錄映射了,因為the
可以作為一個整體來看,試想 js 源碼中一個變量名,函數名這些都不會被拆開的,所以當我們確定的這個單詞首字母的映射關系,那整個單詞其實就能還原到原來的位置了。
所以,首先我們將文件中可符號化的字符提取出來,將他們作為整體來處理。
序號 | 符號 |
---|---|
0 | the |
1 | force |
2 | feel |
於是得到一個所有包含所有符號的數組:
names: ['the','force','feel']
在記錄時,只需要記錄一個索引,還原時通過索引來這個names
數組中找即可。所以上面映射規則中最后一列本來記錄了每個字符,現在改為記錄一個單詞,而單詞我們只記錄其在抽取出來的符號數組中的索引。
所以 the
的映射由原來的
0|Yoda_input.txt|1|5, 1|Yoda_input.txt|1|6, 2|Yoda_input.txt|1|7
可以簡化為
0|Yoda_input.txt|1|5|0
同時,考慮到代碼經常會有合並打包的情況,即輸入文件不止一個,所以可以將輸入文件抽取一個數組,記錄時,只需要記錄一個索引,還原的時候再到這個數組中通過索引取出文件的位置及文件名即可。
sources: ['Yoda_input.txt']
所以上面the
的映射進一步簡化為:
0|0|1|5|0
於是我們得到了完整的映射為:
sources: ['Yoda_input.txt']
names: ['the','force','feel']
mappings (31 字符): 0|0|1|5|0, 4|0|1|9|1, 10|0|1|0|2;
記錄相對位置
當文件內容巨大時,上面精簡后的編碼也有可能會因為數字位數的增加而變得很長,同時,處理較大數字總是不如處理較小數字容易和方便。於是考慮將上面記錄的這些位置用相對值來記錄。比如(1,1001)第一行第999列的符號,如果用相對值,我們就不用每次記錄都從0開始數,假如前一個符號位置為 (1,999),那后面這個符號可記錄為(0,2),類似這樣的相對值幫我們節省了空間,同時降低了數據的維度。
具體到本例中,看看最初的表格中,記錄的輸出文件中的位置:
輸出位置 | 輸出位置 |
---|---|
行 1, 列 0 | 行 1, 列 0 |
行 1, 列 4 | 行 1, 列 (上一值 + 4 = 4) |
行 1, 列 10 | 行 1, 列 (上一值 + 6 = 10) |
對應到整個表格則是:
輸出位置 | 輸入文件的索引 | 輸入的位置 | 符號索引 |
---|---|---|---|
行 1, 列 0 | 0 | 行 1, 列 5 | 0 |
行 1, 列 +4 | +0 | 行 1, 列 +4 | +1 |
行 1, 列 +6 | +0 | 行 1, 列 -9 | +1 |
然后我們得到的編碼為:
sources: ['Yoda_input.txt']
names: ['the','force','feel']
mappings (31 字符): 0|0|1|5|0, 4|0|1|4|1, 6|0|1|-9|1;
注意
- 上面記錄相對位置后,我們的數字中出現了負值,所以之后解析 source map 文件看到負值就不會感到奇怪了
- 另外一點我的思考,對於輸出位置來說,因為是遞增的,相對位置確實有減小數字的作用,但對於輸入位置,效果倒未必是這樣了。拿上面映射中最后一組來說,原來的值是
10|0|1|0|2
,改成相對值后為6|0|1|-9|1
。第四位的值即使去掉減號,因為它在源文件中的位置其實是不確定的,這個相對值可以變得很大,原來一位數記錄的,完全有可能變成兩位甚至三位。不過這種情況應該比較少,它增加的長度比起對於輸出位置使用相對記法后節約的長度要小得多,所以總體上來說空間是被節約了的。
VLQ (Variable Length Quantities)
進一步的優化則需要引入一個新的概念了,VLQ(Variable-length quantity)。
VLQ 以數字的方式呈現
如果你想順序記錄4個數字,最簡單的辦法就是將每個數字用特殊的符號隔開:
1|2|3|4
如果如果提前告訴你這些被記錄的數字都是一位的,那這個分隔線就沒必要了,只需要簡單記錄成如下樣子也能被正確識別出來:
1234
此時這個記錄值的長度是原來的1/2,省了不少空間。
但實際上我們不可能只記錄個位數的數字,使用 VLQ 方式時,如果一個數字后面還跟有剩余數字,將其標識出來即可。假設我們想記錄如下的四個數字:
1|23|456|7
我們使用下划線來標識一個數字后跟有其他數字:
1234567
所以解讀規則為:
- 1沒有下划線,那解析出來第一個數字便是1
- 2有下划線,則繼續解析,碰到3,3沒有下划線,第二位數的解析到此為止,所以第二位數為23
- 4有下划線,繼續,5也有,繼續,6沒有下划線,所以第三位數字為456
- 7沒有下划線,第四位數字則為7
VLQ 以二進制方式的方式呈現
上面的示例中,引入了數字系統外的符號來標識一個數字還未結束。在二進制系統中,我們使用6個字節來記錄一個數字(可表示至多64個值),用其中一個字節來標識它是否未結束(正文 C 標識),不需要引入額外的符號,再用一位標識正負(下方 S),剩下還有四位用來表示數值。用這樣6個字節組成的一組拼起來就可以表示出我們需要的數字串了。
B5 | B4 | B3 | B2 | B1 | B0 |
C | Value | S |
第一個字節組(四位作為值)
這樣一個字節組可以表示的數字范圍為:
Binary group | Meaning |
---|---|
000000 | 0 |
000001 * | -0 |
000010 | 1 |
000011 | -1 |
000100 | 2 |
000101 | -2 |
… | … |
011110 | 15 |
011111 | -15 |
100000 | 未結束的0 |
100001 | 未結束的-0 |
100010 | 未結束的1 |
100011 | 未結束的-1 |
… | … |
111110 | 未結束的15 |
111111 | 未結束的-15 |
* -0 沒有實際意義,但技術上它是存在的
任意數字中,第一個字節組中已經標明了該數字的正負,所以后續的字節組中無需再標識,於是可以多出一位來作表示值。
B5 | B4 | B3 | B2 | B1 | B0 |
C | Value |
未結束的字節組(五位作為值)
現在我們使用上面的二進制規則來重新編碼之前的這個數字序列 1|23|456|7
。
先看每個數字對應的真實二進制是多少:
數值 | 二進制 |
---|---|
1 | 1 |
23 | 10111 |
456 | 111001000 |
7 | 111 |
- 對1進行編碼
1需要一位來表示,還好對於首個字節組,我們有四位來表示值,所以是夠用的。
B5(C) | B4 | B3 | B2 | B1 | B0(S) |
---|---|---|---|---|---|
0 | 0 | 0 | 0 | 1 | 0 |
- 對23進行編碼
23的二進制為10111一共需要5位,第一組字節組只能提供4位來記錄值,所以用一組字節組不行,需要使用兩組字節組。將 10111拆分為兩組,后四位0111放入第一個字節組中,剩下一位1放入第二個字節組中。
B5(C) | B4 | B3 | B2 | B1 | B0(S) | B5(C) | B4 | B3 | B2 | B1 | B0 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
- 對456進行編碼
456的二進制111001000需要占用9個字節,同樣,一個字節組放不下,先拆出最后四位(1000)放入一個首位字節組中,剩下的5位(11100)放入跟隨的字節組中。
B5(C) | B4 | B3 | B2 | B1 | B0(S) | B5(C) | B4 | B3 | B2 | B1 | B0 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|
1 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 0 | 0 |
- 對7進行編碼
3的二進制為111,首位字節組能夠存放得下,於是編碼為:
B5(C) | B4 | B3 | B2 | B1 | B0(S) |
---|---|---|---|---|---|
0 | 0 | 1 | 1 | 1 | 0 |
將上面的編碼合並得到最終的編碼:
000010 101110 000001 110000 011100 001110
base64 編碼表
結合上面的 Base64 編碼表,上面的結果轉成對應的 base64 字符為:
CuBwcO
利用 Base64 VLQ 編碼生成最終的 srouce map
通過上面討論的方法,回到開始的示例中,前面我們已經得到的編碼為
sources: ['Yoda_input.txt']
names: ['the','force','feel']
mappings (31 字符): 0|0|1|5|0, 4|0|1|4|1, 6|0|1|-9|1;
現在來編碼 0|0|1|5|0
。先用二進制對每個數字進行表示,再轉成 VLQ 表示:
0-> 0 -> 000000 //0
0-> 0 -> 000000 //0
1-> 1 -> 000010 //2
5-> 101 -> 001010 // 10
0-> 0 -> 000000 //0
合並后的編碼為:
000000 000000 000001 000101 000000
再轉 Base64 后得到字符形式的結果:
AACKA
后面兩串數通過類似的做法也能得到對應的 Base64編碼,所以最終我們得到的 source map 看起來是這樣的:
sources: ['Yoda_input.txt']
names: ['the','force','feel']
mappings (18 字符): AACKA, IACIC, MACTC;
而真實的 srouce map 如我們文章開頭那個示例一樣,是一個 json 文件,所以最后我們得到的一分像模像樣的 source map 為:
{
"version": 3,
"file": "Yoda_output.txt",
"sources": ["Yoda_input.txt"],
"names": ["the", "force", "feel"],
"mappings": "AACKA,IACIC,MACTC;"
}
略去不必要的字段
上面的例子中,每一片段的編碼由五位組成。真實場景中,有些情況下某些字段其實不必要,這時就可以將其省略。當然,這里給出的這個例子看不出來。
省略其中某些字段后,一個編碼片段就不一定是5位了,他的長度有可能為1,4或者5。
- 5 - 包含全部五個部分:輸出文件中的列號,輸入文件索引,輸入文件中的行號,輸入文件中的列號,符號索引
- 4 - 輸出文件中的列號,輸入文件索引,輸入文件中的行號,輸入文件中的列號
- 1 - 輸出文件中的列號
以上,便探究完了 srouce map 生成的全過程,了解了其原理。
如果感興趣,這個Source map visualizer tool 工具可以在線將 source map 與對應代碼可見化展現出來,方便理解。
另外需要介紹的是,盡管 source map 對於線上調試非常有用,各主流瀏覽器也實現對其的支持,但關於它的規范沒有見諸各 Web 工作組或團體的官方文檔中,它的規范是寫在一個 Google 文檔中的!這你敢信,不信去看一看嘍~ Source Map Revision 3 Proposal
。
相關資料
- Source Map Revision 3 Proposal
- Variable-length quantity
- Base64
- Map Preprocessed Code to Source Code
- Source map visualizer tool
- Source Maps under the hood – VLQ, Base64 and Yoda
- Introduction to JavaScript Source Maps
- BASE64 VLQ 編碼規則
后續
- source map 的保護