天天看點

深入了解SourceMap一 Source Map是什麼?二 為什麼使用Source Map?三 Source Map是怎麼實作映射的?

一 Source Map是什麼?

Source Map,顧名思義,是儲存源代碼映射關系的檔案,相信用過webpack的開發者對它應該不會陌生。在項目開發完進行打包後,在打包的檔案夾裡通常除了js,css,圖檔字型等資源檔案外,大家一定還見過xxx.js.map的檔案。這種帶map字尾的檔案就是Source Map檔案——它儲存了源代碼和轉換之後代碼(通常經過壓縮混淆和其他轉換)的關系。 下圖展示了部分打包之後生成的Source Map檔案:

下面是一個典型的Source Map檔案的格式:

{
  "version": ,
  "sources": [
    "log.js",
    "main.js"
  ],
  "names": [
    "sayHello",
    "name",
    "length",
    "substr",
    "console",
    "log"
  ],
  "mappings": "AAAA,SAASA,SAAUC,MACjB,GAAIA,KAAKC,OAAS,EAAG,CACnBD,KAAOA,KAAKE,OAAO,EAAG,GAAK,MAE7BC,QAAQC,IAAI,QAASJ,MCJvBD,SAAS"
}
複制代碼
           

二 為什麼使用Source Map?

明白了什麼是Source Map之後,大家肯定有個疑問,我們為什麼需要Source Map? 由于現代前端項目的發展,前端代碼變得越來越龐大和複雜。大部分源碼都需要經過轉換,才能投入到生産環境中使用。 常見的轉換過程包括但不限于:

  • 壓縮混淆(UglifyJS)
  • 編譯(TypeScript, CoffeeScript)
  • 轉譯(Babel)
  • 合并多個檔案,減少帶寬請求。

經過上述轉換過程的代碼,往往都會變得面目全非,就像下面這樣:

這樣雖然對帶寬很友好,但是調試起來就不是那麼輕松了。我們在代碼出錯的時候,肯定最希望能定位其在源碼中的位置。 比如下面這兩個錯誤提示: 對于大多數開發者來說,希望看到的應該是第二種提示方式,而這就是Source Map能夠輸出的能力。

三 Source Map是怎麼實作映射的?

在探索這個問題之前,可以先想想真實世界裡對這種語言轉換是怎麼做的?

I AM CHRIS ——> Map ——> CHRIS I AM
複制代碼
           

現在我們要從CHRIS I AM還原到I AM CHRIS,Map裡應該存儲哪些資訊呢?

3.1 最簡單粗暴的方法

将輸出檔案中每個字元位置對應在輸入檔案名中的原位置儲存起來,并一一進行映射。上面的這個映射關系應該得到下面的表格:

字元 輸出位置 在輸入中的位置 輸入的檔案名
c 行1,列1 行1,列6 輸入檔案1.txt
h 行1,列2 行1,列7 輸入檔案1.txt
r 行1,列3 行1,列8 輸入檔案1.txt
i 行1,列4 行1,列9 輸入檔案1.txt
s 行1,列5 行1,列10 輸入檔案1.txt
i 行1,列7 行1,列1 輸入檔案1.txt
a 行1,列9 行1,列3 輸入檔案1.txt
m 行1,列10 行1,列4 輸入檔案1.txt

備注: 由于輸入資訊可能來自多個檔案,是以這裡也同時記錄輸入檔案的資訊。

将上面表格整理成映射表的話,看起來就像這樣(使用"|"符号分割字元)

mappings: "1|1|輸入檔案1.txt|1|6,1|2輸入檔案1.txt|1|7,1|3|輸入檔案1.txt|1|8,1|4|輸入檔案1.txt|1|9,1|5|輸入檔案1.txt|1|10,1|7|輸入檔案1.txt|1|1,1|9|輸入檔案1.txt|1|3,1|10|輸入檔案1.txt|1|4"(長度:)
複制代碼
           

這種方法确實能将處理後的内容還原成處理前的内容,但是随着内容的增加,轉換規則的複雜,這個編碼表的記錄将飛速增長。目前僅僅10個字元,映射表的長度已經達到了144個字元。如何進一步優化這個映射表呢?

備注:

mappings: "輸出檔案行位置|輸出檔案列位置|輸入檔案名|輸入檔案行号|輸入檔案列号,....."

3.2 優化手段1:不要輸出檔案中的行号

在經曆過壓縮和混淆之後,代碼基本上不會有多少行(特别是JS,通常隻有1到2行)。這樣的話,就可以在上節的基礎上移除輸出位置的行數,使用";"号來辨別新行。 那麼映射資訊就變成了下面這樣

mappings: "1|輸入檔案1.txt|1|6,2|輸入檔案1.txt|1|7,3|輸入檔案1.txt|1|8,4|輸入檔案1.txt|1|9,5|輸入檔案1.txt|1|10,7|輸入檔案1.txt|1|1,9|輸入檔案1.txt|1|3,10|輸入檔案1.txt|1|4; 如果有第二行的話"(長度:)
複制代碼
           

備注:

mappings: "輸出檔案列位置|輸入檔案名|輸入檔案行号|輸入檔案列号,....."

3.3 優化手段2:提取輸入檔案名

由于可能存在多個輸入檔案,且描述輸入檔案的資訊比較長,是以可以将輸入檔案的資訊存儲到一個數組裡,記錄檔案資訊時,隻記錄它在數組裡的索引值就好了。 經過這步操作後,映射資訊如下所示:

sources: ['輸入檔案1.txt'],
mappings: "1|0|1|6,2|0|1|7,3|0|1|8,4|0|1|9,5|0|1|10,7|0|1|1,9|0|1|3,10|0|1|4;" (長度:)
複制代碼
           

經過轉換後mappings字元數從129下降到了65。

備注:

mappings: "輸出檔案列位置|輸入檔案名索引|輸入檔案行号|輸入檔案列号,....."

3.4 優化手段3: 可符号化字元的提取

經過上一步的優化,mappings字元數有了很大的下降,可見提取資訊是一個很有用的簡化手段,那麼還有什麼資訊是能夠提取的麼? 當然。已輸出檔案中的

Chris

字元為例,當我們找到了它的首字元

C

在源檔案中的位置(行1,列6)時,就不需要再去找剩下的

hris

的位置了,因為

Chris

可以作為一個整體來看待。想想源碼裡的變量名,函數名,都是作為一個整體存在的。 現在可以把作為整體的字元提取并存儲到一個數組裡,然後和檔案名一樣,在mapping裡隻記錄它們的索引值。這樣就避免了每一個字元都要記的窘境,大大縮減mappings的長度。

添加一個包含所有可符号化字元的數組:

names: ['I', 'am', 'Chris']
複制代碼
           

那麼之前

Chris

的映射就從

|||,|||,|||,|||,|||
複制代碼
           

變成了

||||
複制代碼
           

最終的映射資訊變成了:

sources: ['輸入檔案1.txt'],
names: ['I', 'am', 'Chris'],
mappings: "1|0|1|6|2,7|0|1|1|0,9|0|1|3|1" (長度: )
複制代碼
           

備注: 1. “I am Chris"中的"I"抽出來放在數組裡,其實意義不大,因為它本身也就隻有一個字元。但是為了示範友善,是以拆出來放在數組裡。 2. mappings:

"輸出檔案列位置|輸入檔案名索引|輸入檔案行号|輸入檔案列号|字元索引,....."

3.5 優化手段4: 記錄相對位置

前面記錄位置資訊(主要是列)時,記錄的都是絕對位置資訊,設想一下,當檔案内容比較多時,這些數字可能會變的很大,這個問題怎麼解決呢? 可以通過隻記錄相對位置來解決這個問題(除了第一個字元)。 來看一下具體怎麼實作的,以之前的mappings編碼為例:

mappings: "1(輸出列的絕對位置)|0|1|6(輸入列的絕對位置)|2,7(輸出列的絕對位置)|0|1|1(輸入列的絕對位置)|0,9(輸出列的絕對位置)|0|1|3(輸入列的絕對位置)|1"
複制代碼
           

轉換成隻記錄相對位置

mappings: "1(輸出列的絕對位置)|0|1|6(輸入列的絕對位置)|2,6(輸出列的相對位置)|0|1|-3(輸入列的相對位置)|0,2(輸出列的相對位置)|0|1|-2(輸入列的絕對位置)|1"
複制代碼
           

從上面的例子可能看不太出這個方法的好處,但是當檔案慢慢大起來,使用相對位置可以節省很多字元長度,特别是對于記錄輸出檔案列資訊的字元來說。

3.6 優化手段5: VLQ編碼

經過上面幾步操作之後,現在最應該優化的地方應該就是用來分割數字的"|"号了。 這個優化應該怎麼實作呢? 在回答之前,先來看這樣一個問題——如果你想順序的記錄4組數字,最簡單的就是用"|"号進行分割。

|||
複制代碼
           

如果每個數字隻有1位的話,可以直接表示成

複制代碼
           

但是很多時候每個數字不止有1位,比如

|||
複制代碼
           

這個時候,就一定得用符号把各個數字分割開,像我們上面例子中一樣。還有好的方法嘛? 通過VLQ編碼的方式,你可以很好的處理這種情況,先來看看VLQ的定義:

3.6.1 VLQ定義

A variable-length quantity (VLQ) is a universal code that uses an arbitrary number of binary octets (eight-bit bytes) to represent an arbitrarily large integer. 翻譯一下:VLQ是用任意個2進制位元組組去表示一個任意數字的編碼形式。

VLQ的編碼形式很多,這篇文章中要說明的是下面這種:

  • 一個組包含6個二進制位。
  • 在每組中的第一位C用來辨別其後面是否會跟着另一個VLQ位元組組,值為0表示其是最後一個VLQ位元組組,值為1表示後面還跟着另一個VLQ位元組組。
  • 在第一組中,最後1位用來表示符号,值為0則表示正數,為1表示負數。其他組的最後一位都是表示數字。
  • 其他組都是表示數字。

這種編碼方式也稱為Base64 VLQ編碼,因為每一個組對應一個Base64編碼。

3.6.2 小例子說明VLQ

現在我們用這套VLQ編碼規則對

12|3|456|7

進行編碼,先将這些數字轉換為二進制數。

——> 
   ——> 
 ——> 
   ——> 
複制代碼
           
  • 對12進行編碼

12需要1位表示符号,1位表示是否延續,剩下的4位表示數字

B5(C) B4 B3 B2 B1 B0
1 1
  • 對3進行編碼
B5(C) B4 B3 B2 B1 B0
1 1
  • 對456進行編碼

從轉換關系中能夠看到,456對應的二進制已經超過了6位,用1組來表示肯定是不行的,這裡需要用兩組位元組組來表示。先拆除最後4個數(1000)放入第一個位元組組,剩下的放在跟随位元組組中。

B5(C) B4 B3 B2 B1 B0 B5(c) B4 B3 B2 B1 B0
1 1 1 1 1
  • 對7進行編碼
B5(C) B4 B3 B2 B1 B0
1 1 1

最後得到下列VLQ編碼:

011000 000110 110000 011100 001110
複制代碼
           

通過Base64進行轉換之後:

最終得到下列結果:

YGwcO
複制代碼
           

3.6.3 轉換之前的例子

通過上面這套VLQ的轉換流程轉換之前的例子,先來編碼

1|0|1|6|2

. 轉換成VLQ為:

1 ——> 1(二進制) ——> 000010(VLQ)
0 ——> 0(二進制) ——> 000000(VLQ)
1 ——> 1(二進制) ——> 000010(VLQ)
6 ——> 110(二進制) ——> 001100(VLQ)
2 ——> 10(二進制) ——> 000100(VLQ)
複制代碼
           

合并後編碼為

000010 000000 000010 001100 000100
複制代碼
           

轉換成Base64

BABME
複制代碼
           

其他也是按這種方式編碼,最後得到的mapping檔案如下:

sources: ['輸入檔案1.txt'],
names: ['I', 'am', 'Chris'],
mappings: "BABME,OABBA,SABGB" (長度: )
複制代碼
           

和第一節的mappings檔案對比一樣,是不是一樣呢?

3.6.4 one more thing

在真實場景中,我們在mappings中經常可以看到不是5位的字元。大于5位好了解,可能表示的數字太大。比如123456789轉換成Base64 VLQ編碼就是qxmvrH。而少于5位的情況在于mappings的編碼片段中可能不需要那麼多資訊就能進行映射,比如不需要Names屬性,這樣隻通過4位資訊也就能擷取到映射關系了。一個編碼片段可能會有以下幾種長度:

  • 5 - 包含全部五個部分:輸出檔案中的列号,輸入檔案索引,輸入檔案中的行号,輸入檔案中的列号,符号索引
  • 4 - 輸出檔案中的列号,輸入檔案索引,輸入檔案中的行号,輸入檔案中的列号
  • 1 - 輸出檔案中的列号

通過上面的講解,相信大家一定對Source Map是如何映射源碼轉換後代碼之間的位置關系有所了解。在了解了Source Map原理之後,日後再去使用它肯定能夠得心應手。

轉載于:https://juejin.im/post/5d0790996fb9a07f0052dbbb

繼續閱讀