探究 source map 在編譯過程中的生成原理

本文首發於我的博客(點此查看),歡迎關注。

source map 是開發時調試代碼的利器之一。現代的構建工具如 webpack 早已對 source map 有了完備的支持,對照文檔就能很容易在打包時順手生成然後在現代瀏覽器如 Chrome/Firefox 中使用。關於相關配置的介紹使用已經有很多文章,這裏就不再贅述。本文想探究的是 source map 在編譯器中的實現原理。

source map 介紹

首先對於 source map 還不是特別清楚其原理及使用方式的同學可以先看一下阮一峯老師對其的介紹。一句話總結就是 source map 是一種存儲了源代碼和編譯後代碼映射關係的信息文件。當你的編譯後代碼出現問題時,根據 source map 就能精準定位到源代碼對應的位置。否則,直接在天書一般的編譯後(加上可能壓縮後)代碼中進行調試,難度不小。

AST 中的位置信息

source map 揭示了源代碼和處理後代碼之間的映射關係,而從源碼到處理後代碼的過程自然離不開編譯。一個典型的編譯過程如下:

AST,即抽象語法樹,是源代碼語法結構的一種抽象表示。其以樹狀的形式表現編程語言的語法結構,樹上的每個節點都表示源代碼中的一種結構(來自維基百科解釋)。感興趣的同學可訪問 https://astexplorer.net/ 查看代碼經 parser 處理爲 AST 的過程。在 AST 中,每個節點都會保存自己的來源信息,如下所示:

interface Node {
    type: string;
    loc: SourceLocation | null;
}

interface SourceLocation {
    source: string | null;
    start: Position;
    end: Position;
}

interface Position {
    line: uint32 >= 1;
    column: uint32 >= 0;
}

每個 Node 的 loc 屬性(視 parser 不同可能解析爲其他名稱,如 traceur 將其解析爲 location)包含其 start 和 end 的行列位置信息。在 generate 環節,start 位置信息就是生成 source map 的關鍵。而通常 generator 不會自己去做映射關係的 VLQ 編碼(source map 的位置信息存儲方式),而是交由專業的庫來處理,比如 Mozilla 出品的 source-map

source-map

source-map 庫封裝了底層的映射關係計算的邏輯,在生成 source map 時向開發者提供了兩種類型的 API,一種是低級 API,其單純地通過向結果中插入源代碼和編譯後代碼的行列對應關係來生成 source map,官方示例如下:

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"}'

另一種高級 API 則直接侵入了編譯過程。在 generate 步驟,source-map 提供了 SourceNode 用於在保留原有節點信息的同時添加該節點對應源代碼的行列信息。最後再借助 SourceNode 提供的 toStringWithSourceMap 方法同時輸出代碼和 source map。官方示例如下:

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] }

顯然,高級 API 對於 source-map 的依賴和耦合性比較高。不過筆者在探究各個 generator 對於 source map 的支持時發現兩種 API 均有使用。比如 @babel/generator 使用了低級 API,而 escodegen 則使用了高級 API。

生成原理

生成 source map 的原理並不複雜,使用 source-map 的低級 API 時, generator 的生成代碼是一個遍歷 AST node 然後根據其類型將對應的語句逐個拼裝的過程,這其中會維護生成代碼的行列信息,而在 node 中則保存有源代碼的位置信息,如此便可調用 source-map 的低級 API 去生成 source-map。而使用高級 API 的原理則更簡單,generator 處理好各個 node 對應生成的代碼語句,拿到 node 中的源位置信息,然後調用 new SourceNode()toStringWithSourceMap 交給 source-map 去處理和生成代碼和 srouce map 即可。

最後,回到 source-map 庫的實現上來。在其代碼庫的 lib/source-node.js 中我們可以看到,SourceNode 實例的 toStringWithSourceMap 方法本質上做的工作也無非就是將生成好的代碼片段拼接起來並同時調用低級 API 來生成 source map。至於 VLQ 編碼的方式,源碼裏也有,讀者有興趣可結合原理自行查看。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章