Well in Time
Source map 運作原理
03-27-202110 Min Read

source map 就是那種你每天都會看到、用到,但可能從來不曉得他怎麼運作的東西,這篇文章帶你一探究竟。

前言

只要你是個前端工程師,或是曾經開發過前端專案,相信對 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 這個字串裡面有三層資訊:

  1. 用分號 ; 區隔編譯後程式碼,所以第一個分號前的編碼,對應編譯後程式碼的第一行。以上面例子來看,gBAAgB,EAAE 就是對應編譯後程式碼第一行的編碼。
  2. 用逗號 , 隔開的是編譯後程式碼某一行內的某個位置。以上面例子來看,第一行紀錄了兩個位置的對應編碼,gBAAgBEAAE。 ---(感謝網友 davidhcefx 指正!)
  3. 最後是一個 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 編碼,各自代表一個資訊:

source-map-base64vlq

四個欄位裡面:

  • 第一個欄位:標記在編譯後程式碼的第幾列(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 有些了解,知道程式碼在轉換的過程中,都會經歷如下的歷程:

what-ast-play-in-babel

AST(Abstract Syntax Tree)中每個 Node 其實都會記載其位置(startend):

ast-location-sample

基本上就提供了我們 source map 所需的資訊,因此 generate 步驟後,除了產生編譯後的程式碼外,也能順帶產生 source map:

source-map-ast-process

而如同文章前半段所提,大多數 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 的原始碼也值得一看。

雖然理解這些原理與否並不影響你開發網站與產品,也不一定能增加你的效率或薪水,但是純粹的學習知識其實也是很快樂的,希望大家看到這邊都能有所收穫!有任何問題歡迎留言指教。

資料來源

  1. Source Maps from top to bottom
  2. 詳解前端代碼的sourceMap原理——讓你不再為調試代碼發愁
  3. JavaScript Source Map 詳解
  4. 探究 source map 在編譯過程中的生成原理
  5. 前端面试官: 你知道source-map的原理是什么吗?
  6. Compiling to JavaScript, and Debugging with Source Maps
© by Arvin Huang. All rights reserved.
theme inspired by @mhadaily
Last build: 09-08-2024