前言
只要你是個前端工程師,或是曾經開發過前端專案,相信對 source map 都不陌生,不管你常用的 bundler/generator 工具是什麼,幾乎都有完整的 source map 支援,甚至有各種選項可以配置,但是你知道 source map 的原理嗎?它是怎麼產生的?它又是怎麼幫助我們從 bundler/generator 產生的程式碼中找出對應的原始碼,讓我們方便除錯呢?
這些問題我也不太清楚,雖然大致上的原理稍微思考一下都能夠猜個八九不離十,但對於實際運作細節從來沒有探討過,因此這週末利用了點時間稍微研究一下,記錄在這篇文章跟大家分享。
Source Map 是什麼
簡單來說,source map 就是儲存了原始碼與編譯後程式碼的對應關係之檔案,讓你在開啟 Devtool 時,能讓瀏覽器透過載入 source map 的方式幫助你定位原始碼位置,方便下中斷點除錯。
以目前的瀏覽器實作來看,都是只有在打開 Devtool 的時候,才會根據它獲取的 source map url 資訊來載入 source map,不會影響網站載入速度與一般使用者的體驗。
提供瀏覽器 source map url 的方式有兩種,一個是將其寫在編譯後程式碼檔案中,也是大多數現在 bundler/generator 的做法:
parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;....
//# sourceMappingURL=file.map.js
另一種則是透過特殊的 http header,讓瀏覽器在 request 你的 javascript 檔案時,能夠從 header 欄位中找到 source map url 資訊:
X-SourceMap: /path/to/file.js.map
順帶一提,Devtool 載入 source map 的 request 並不會出現在 Network panel,所以基本上是看不到的。
這在一般使用情境上是沒什麼影響,但我前陣子有個專案部署到測試環境後,卻發現 source map 載入失敗,這時想要確認原因就麻煩了,翻了翻 chrome devtool 的原始碼,才勉勉強強猜測出是因為 devtool 載入 source map 時,不會因為你在瀏覽器中 simulate mobile mode,而跟著送出 mobile 的 user agent,而該專案的 CDN 有設定會將來自 desktop 的 request 轉到特殊的頁面,因此才導致 dev tool 的 source map 載入失敗。如果能看到載入 source map 的 request,這個問題就能更好的確認與解決了。
Souce Map 的內容
Source Map 是有規格的,主要由 Mozilla 與 Google 工程師撰寫,目前最新版本是 version 3,可以在這裡找到。
一個 source map 檔案大概長這樣(這是經過 beatify 後的樣子,通常會是壓縮成一行而已):
{
"version": 3,
"sources": ["logger.ts"],
"names": [],
"mappings": "gBAAgB,EAAE;AA0BA,aAAA,OAAA,eAAA,QAAA,aAAA,CAAA,OAAA,IAvBA,MAAM,...",
"file": "logger.js",
"sourceRoot": "../src",
"sourcesContent": ["/* eslint-disable no-console */\nimport { test } from '...'"]
}
大多數的 bundler/generator 都是使用 Mozilla 的 source-map 套件,或是利用該套件的 API 自己去做一些客製化,像是 Webpack 就是如此。但也有像是 v2 版本的 Parcel,就使用了 C++ 從頭撰寫,號稱效率更高。
實際檔案內容可能根據你所使用的 bundler/generator 會有些許不同,但都會遵照這個規格。
- version:source map 的版本,目前為 3。
- source:編譯前的文件名稱,是一個 array,因為很多時候你會將多個檔案編譯到一個。
- names:編譯前的變數。可能不是必要欄位,所以大多都是空的。
- mappings:source map 的主要資訊,是一連串編碼,用來表示原始碼與編譯後程式碼的對應訊息。
- file:編譯後的文件名稱。
- sourceRoot:編譯前的檔案之所在位置。
- sourcesContent: 原始碼內容,也是個 array,對應每個檔案的原始碼。
其中最重要的就是 mappings
這個欄位,記錄了編譯前後兩個文件怎麼做對應的資訊。以上面的例子來看:
"mappings": "gBAAgB,EAAE;AA0BA,aAAA,OAAA,eAAA,QAAA,aAAA,CAAA,OAAA,IAvBA,MAAM,...",
mappings 這個字串裡面有三層資訊:
用分號
;
區隔編譯後程式碼的行,所以第一個分號前的編碼,對應編譯後程式碼的第一行。以上面例子來看,gBAAgB,EAAE
就是對應編譯後程式碼第一行的編碼。用逗號
,
隔開的是編譯後程式碼某一行內的某個位置。以上面例子來看,第一行紀錄了兩個位置的對應編碼,gBAAgB
與EAAE
。 ---(感謝網友 davidhcefx 指正!)最後是一個 Base64 VLQ 的編碼,解碼後可以得到編譯前原始碼的位置。
何謂 Base64 VLQ
VLQ (variable-length quantity)
VLQ 是一種壓縮 large integers 的編碼方式,同樣一個整數,用數字表示一定會消耗比 VLQ 更多的空間。用 Base64 來表達則可以將 VLQ 表示限縮在 ASCII 的子集中,解決一些語言問題。
有興趣深入了解的人可以看看 svelte 的作者 Rich-Harris 的實作,下表範例也是取自其 Readme:
Integer | Base64 VLQ |
---|---|
0 | A |
1 | C |
123 | 2H |
123456789 | qxmvrH |
可以看到以 Base64 VLQ 來表示數字能夠縮減需要的儲存空間。
Source Map 如何用 Base64 VLQ 記錄位置資訊
知道了 source map 是利用 mappings 裡面的 Base64 VLQ 編碼來記錄兩邊程式碼的對應位置關係,我們可以來仔細解析一下 VLQ 的內容,以上面範例中的編碼 EAAE
來看,共有四位數,每一個位數都是一個 Base64 VLQ 編碼,各自代表一個資訊:
四個欄位裡面:
- 第一個欄位:標記在編譯後程式碼的第幾列(column)
- 第二個欄位:標記屬於 source 欄位中的哪個檔案
- 第三個欄位:標記在編譯前程式碼的第幾行(line number)
- 第四個欄位:標記在編譯前程式碼的第幾列(column)
其實還有第五個欄位,代表屬於 source map 檔案中 names
屬性所列的變數中的哪一個,如果 names
為空,這邊就不會產生第五個欄位。
瀏覽器就是透過這些資訊來定位編譯前後程式碼的位置,讓你能輕鬆的除錯。至於瀏覽器怎麼解析跟實際顯示在 devtool 中,就不在今天討論範圍,還得去爬他們的程式碼才行,但我估計也是用到 source-map 套件。
原始碼的編譯過程中如何產生 Source Map
知道了 source map 的內容後,下個問題來了,編譯過程中,是怎麼產生這些資訊,並儲存在 source map file 中的呢?
如果有寫過 babel/eslint plugin 或是讀過 透過製作 Babel-plugin 初訪 AST 與 寫一個簡單堪用的 ESLint plugin的讀者應該對於 AST 有些了解,知道程式碼在轉換的過程中,都會經歷如下的歷程:
AST(Abstract Syntax Tree)中每個 Node 其實都會記載其位置(start 與 end):
基本上就提供了我們 source map 所需的資訊,因此 generate 步驟後,除了產生編譯後的程式碼外,也能順帶產生 source map:
而如同文章前半段所提,大多數 bundler/generator 會用到 mozilla 的 source-map 套件來幫忙在 generate 階段產生 source map,使用方法在其官方 readme 中可以找到,大致上分為兩種:
第一種是 low level API(官方範例)
var map = new SourceMapGenerator({
file: "source-mapped.js"
});
map.addMapping({
generated: {
line: 10,
column: 35
},
source: "foo.js",
original: {
line: 33,
column: 2
},
name: "christopher"
});
console.log(map.toString());
// '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'
透過 SourceMapGenerator
告知其編譯後檔案位置,然後手動加入對照的程式碼行與列資訊,source-map 就能幫忙算出 Based64 VLQ 並產生 source map 檔案。這種作法就是要自己額外維護 AST node 中提供的行列資訊,以及原始碼的行列資訊。
第二種是 high level API(官方範例)
function compile(ast) {
switch (ast.type) {
case "BinaryExpression":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, [
compile(ast.left),
" + ",
compile(ast.right)
]);
case "Literal":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, String(ast.value));
// ...
default:
throw new Error("Bad AST");
}
}
var ast = parse("40 + 2", "add.js");
console.log(
compile(ast).toStringWithSourceMap({
file: "add.js"
})
);
// { code: '40 + 2',
// map: [object SourceMapGenerator] }
high level API 則是直接應用在 AST 中,透過 SourceNode
來包裹原有的 AST node,將對應編譯前原始碼的資訊附加上去,最後使用 source-map 提供的 toStringWithSourceMap
來輸出原始碼與 source map 檔。
如果你去看 SourceNode
的原始碼,你會發現 toStringWithSourceMap
底層也是呼叫了 low levle API,將整個樹的資訊 concat 起來:
this.walk(function(chunk, original) {
generated.code += chunk;
if (
original.source !== null &&
original.line !== null &&
original.column !== null
) {
if (
lastOriginalSource !== original.source ||
lastOriginalLine !== original.line ||
lastOriginalColumn !== original.column ||
lastOriginalName !== original.name
) {
map.addMapping({
source: original.source,
original: {
line: original.line,
column: original.column
},
generated: {
line: generated.line,
column: generated.column
},
name: original.name
});
}
// 略...
兩種 API 都有人使用,babel 是使用 low level API,而 webpack 則用到了 high level API。
結論
至此我們大致上解析了 source map 的內容,並初步了解他是怎麼生成的,如果想要再繼續研究的話,可以往 source-map 的原始碼鑽研,包含 VLQ 的實作也有,或是 webpack、bable 或 parcel 的原始碼也值得一看。
雖然理解這些原理與否並不影響你開發網站與產品,也不一定能增加你的效率或薪水,但是純粹的學習知識其實也是很快樂的,希望大家看到這邊都能有所收穫!有任何問題歡迎留言指教。