source map 的原理探究

来源:https://www.cnblogs.com/Wayou/archive/2018/10/08/understanding_frontend_source_map.html
-Advertisement-
Play Games

線上產品代碼一般是編譯過的,前端的編譯處理過程包括不限於 轉譯器/Transpilers (Babel, Traceur) 編譯器/Compilers (Closure Compiler, TypeScript, CoffeeScript, Dart) 壓縮/Minifiers (UglifyJS) ...


線上產品代碼一般是編譯過的,前端的編譯處理過程包括不限於

  • 轉譯器/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

在瀏覽器中開啟 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 編碼表,上面的結果轉成對應的 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 的保護

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 前言 好久沒有寫破解教程了(我不會告訴你我太懶了),找到一款戀愛游戲,像我這樣的宅男只能玩玩戀愛游戲感覺一下戀愛的心動了。。 這款游戲免費試玩,但是後續章節得花6元錢購買,我怎麼會有錢呢,而且身在吾愛的大家庭里,不破解一波怎麼對得起我破解渣渣的身份呢! 喲,還是支付寶購買的,直接9000大法,但是破 ...
  • 編譯報錯 當你開心得升級完新 macOS,以及新 XCode,準備體驗了一把 Dark Mode 編程模式,開心的打開自己的老項目的時候,發現編譯不通過了╮(╯_╰)╭ 如果你的工程中如果依賴 libstdc++,無論是你本身的功能用 C++ 跨平臺編寫,還是你引入了某個 SDK 其內部依賴這個 l ...
  • 解決辦法,改為使用android-gif-drawable.jar來顯示gif圖片(需要配合com.android.support:support-v4:18.0.0使用) ...
  • 如果你是一個有經驗的 Android 程式員,那麼你肯定手寫過許多 以及 方法用來保持 Activity 的狀態,因為 Activity 在變為不可見以後,系統隨時可能把它回收用來釋放記憶體。 重寫 Activity 中的 方法 是 Google 推薦的用來保持 Activity 狀態的做法。 <! ...
  • 本文主要介紹Flutter佈局中的Baseline、FractionallySizedBox、IntrinsicHeight、IntrinsicWidth四種控制項,詳細介紹了其佈局行為以及使用場景,並對源碼進行了分析。 ...
  • 想要實現輪播效果,首先安裝時間定時器 接下來就是在我們的項目中使用定時器 接下來我們將豎著的輪播圖變成橫著的 接下來我們調整間距 我們知道輪播圖下方,還有5個圓點,那我們怎麼做呢? 拿到每一個圓點 看對應的樣式 關於當前樣式和小圓點顏色的改變 見成品圖 我們要達到的效果是,哪一個輪播圖在前面,對應的 ...
  • <!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <title>Title</title></head><body><select id="test" name=""> <option value="1">text1</opti ...
  • 我是菜鳥,老鳥勿看,繼承多態等太多概念難以理解,還是從實踐中慢慢學吧!爭取能大致看懂網上的開源的代碼。 對象的組成:方法和屬性 屬性關鍵詞:靜止的,狀態 方法關鍵詞:動態的,過程,處理,帶括弧 js中的面向對象不是其他語言那樣的面向對象。 結果是數組有個number屬性和test方法,但是數組內容為 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...