Babel 外掛原理的理解與深入

straydog發表於2018-09-11
現在談到 babel 肯定大家都不會感覺到陌生,雖然日常開發中很少會直接接觸到它,但它已然成為了前端開發中不可或缺的工具,不僅可以讓開發者可以立即使用 ES 規範中的最新特性,也大大的提高了前端新技術的普及(學不動了...)。但是對於其轉換程式碼的內部原理我們大多數人卻知之甚少,所以帶著好奇與疑問,筆者嘗試對其原理進行探索。

Babel 是一個通用的多功能 JavaScript 編譯器,但與一般編譯器不同的是它只是把同種語言的高版本規則轉換為低版本規則,而不是輸出另一種低階機器可識別的程式碼,並且在依賴不同的擴充外掛下可用於不同形式的靜態分析。(靜態分析:指在不需要執行程式碼的前提下對程式碼進行分析以及相應處理的一個過程,主要應用於語法檢查、編譯、程式碼高亮、程式碼轉換、最佳化、壓縮等等)

babel 做了什麼

和編譯器類似,babel 的轉譯過程也分為三個階段,這三步具體是:

  • 解析 Parse
    將程式碼解析生成抽象語法樹( 即AST ),也就是計算機理解我們程式碼的方式(擴充套件:一般來說每個 js 引擎都有自己的 AST,比如熟知的 v8,chrome 瀏覽器會把 js 原始碼轉換為抽象語法樹,再進一步轉換為位元組碼或機器程式碼),而 babel 則是透過 babylon 實現的 。簡單來說就是一個對於 JS 程式碼的一個編譯過程,進行了詞法分析與語法分析的過程。
  • 轉換 Transform
    對於 AST 進行變換一系列的操作,babel 接受得到 AST 並透過 babel-traverse 對其進行遍歷,在此過程中進行新增、更新及移除等操作。
  • 生成 Generate
    將變換後的 AST 再轉換為 JS 程式碼, 使用到的模組是 babel-generator

babel-core 模組則是將三者結合使得對外提供的API做了一個簡化。

此外需要注意的是,babel 只是轉譯新標準引入的語法,比如ES6箭頭函式:而新標準引入的新的原生物件,部分原生物件新增的原型方法,新增的 API 等(Proxy、Set 等), 這些事不會轉譯的,需要引入對應的 polyfill 來解決。

而我們編寫的 babel 外掛則主要專注於第二步轉換過程的工作,專注於對於程式碼的轉化規則的擴充,解析與生成的偏底層相關操作則有對應的模組支援,在此我們理解它主要做了什麼即可。

比如這樣一段程式碼:

console.log("hello")

則會得到這樣一個樹形結構(已簡化):


{
    "type": "Program", // 程式根節點
    "body": [
        {
            "type": "ExpressionStatement", // 一個語句節點
            "expression": {
                "type": "CallExpression", // 一個函式呼叫表示式節點
                "callee": {
                    "type": "MemberExpression", // 表示式
                    "object": {
                        "type": "Identifier",
                        "name": "console"
                    },
                    "property": {
                        "type": "Identifier",
                        "name": "log"
                    },
                    "computed": false
                },
                "arguments": [
                    {
                        "type": "StringLiteral",
                        "extra": {
                            "rawValue": "hello",
                            "raw": "\"hello\""
                        },
                        "value": "hello"
                    }
                ]
            }
        }
    ],
    "directives": []
}

其中的所有節點名詞,均來源於 ECMA 規範

抽象語法樹是怎麼生成的

image.png

談到這點,就要說到計算機是怎麼讀懂我們的程式碼的。解析過程分為兩個步驟:

1.分詞: 將整個程式碼字串分割成語法單元陣列(token)

JS 程式碼中的語法單元主要指如識別符號(if/else、return、function)、運算子、括號、數字、字串、空格等等能被解析的最小單元。比如下面的程式碼生成的語法單元陣列如下:
線上分詞工具

function demo (a) {
    console.log(a || 'a');
}
=> 

[
    { "type": "Keyword","value": "function" },
    { "type": "Identifier","value": "demo" },
    { "type": "Punctuator","value": "(" },
    { "type": "Identifier","value": "a" },
    { "type": "Punctuator","value": ")" },
    { "type": "Punctuator","value": "{ " },
    { "type": "Identifier","value": "console" },
    { "type": "Punctuator","value": "." },
    { "type": "Identifier","value": "log" },
    { "type": "Punctuator","value": "(" },
    { "type": "Identifier","value": "a" },
    { "type": "Punctuator","value": "||" },
    { "type": "String","value": "'a'" },
    { "type": "Punctuator","value": ")" },
    { "type": "Punctuator","value": "}" }
]

2.語義分析: 在分詞結果的基礎上分析語法單元之間的關係。

語義分析則是將得到的詞彙進行一個立體的組合,確定詞語之間的關係。考慮到程式語言的各種從屬關係的複雜性,語義分析的過程又是在遍歷得到的語法單元組,相對而言就會變得更復雜。

先理解兩個重要概念,即語句和表示式。

  • 語句(statement),即指一個具備邊界的程式碼區域,相鄰的兩個語句之間從語法上來講互補影響,即調換順序也不會產生語法錯誤。
  • 表示式(expression),則指最終有個結果的一小段程式碼,他可以嵌入到另一個表示式,且包含在語句中。

簡單來說語義分析既是對語句和表示式識別,這是個遞迴過程,在解析中,babel 會在解析每個語句和表示式的過程中設定一個暫存器,用來暫存當前讀取到的語法單元,如果解析失敗,就會返回之前的暫存點,再按照另一種方式進行解析,如果解析成功,則將暫存點銷燬,不斷重複以上操作,直到最後生成對應的語法樹。

{"type": "Program",
"body": [{
    "type": "FunctionDeclaration",
    "id": { "type": "Identifier", "name": "demo" },
    "params": [{ "type": "Identifier", "name": "a" }],
    "body": {
        "type": "BlockStatement",
        "body": [{
            "type": "ExpressionStatement",
            "expression": {
                "type": "CallExpression",
                "callee": {
                    "type": "MemberExpression",
                    "computed": false,
                    "object": { "type": "Identifier", "name": "console" },
                    "property": { "type": "Identifier", "name": "log" }
                },
                "arguments": [{   
                    "type": "LogicalExpression",
                    "operator": "||",
                    "left": { "type": "Identifier", "name": "a" },
                    "right": { "type": "Literal", "value": "a", "raw": "'a'" }
                }]
            }
        }]
    },
}]}

推薦
the-super-tiny-compiler 這是一個只用了百來行程式碼的簡單編譯器開源專案,裡面的作者也很用心的編寫了詳盡的註釋,透過程式碼可以更好地理解這個過程。

具體過程分析

瞭解原始碼的 AST 結構則是我們轉換過程的關鍵點,可以藉助直觀的樹形結構轉換 AST Explorer,更加直觀的理解 AST 結構。

Visitors
對於這個遍歷過程,babel 透過例項化 visitor 物件完成,既其實我們生成出來的 AST 結構都擁有一個 accept 方法用來接收 visitor 訪問者物件的訪問,而訪問者其中也定義了 visit 方法(即開發者定義的函式方法)使其能夠對樹狀結構不同節點做出不同的處理,藉此做到在物件結構的一次訪問過程中,我們能夠遍歷整個物件結構。(訪問者設計模式:提供一個作用於某物件結構中的各元素的操作表示,它使得可以在不改變各元素的類的前提下定義作用於這些元素的新操作)

遍歷結點讓我們可以定位並找到我們想要操作的結點,在遍歷每一個節點時,存在enter和exit兩個時態週期,一個是進入結點時,這個時候節點的子節點還沒觸達,遍歷子節點完成的後,會離開該節點並觸發exit方法。

Paths
Visitors 在遍歷到每個節點的時候,都會給我們傳入 path 引數,包含了節點的資訊以及節點和所在的位置,供我們對特定節點進行修改,之所以稱之為 path 是其表示的是兩個節點之間連線的物件,而非指當前的節點物件。path屬性有幾個重要的組成,主要如下:

image.png

例如,如果訪問到下面這樣的一個節點

{
    type: "FunctionDeclaration",
    id: {
        type: "Identifier",
        name: "square"
    }
}

而他的 path 關聯路徑得到的物件則是這樣的。

{
    "parent": {
        "type": "FunctionDeclaration",
        "id": {...},...
    }, {
        "node": {
            "type": "Identifier",
            "name": "square"
        }
    }
}

可以看到 path 其實是一個節點在樹中的位置以及關於該節點各種資訊的響應式表示,即我們訪問過程中操作的並不是節點本身而是路徑,且其中包含了新增、更新、移動和刪除節點有關的其他很多方法,當呼叫一個修改樹的方法後,路徑資訊也會被更新。主要目的還是為了簡化操作,儘可能做到無狀態。

實際運用
假如有如下程式碼:

NEJ.define(["./modal"], function(Modal){});

=> transform 為
define(["./modal"], function(Modal){});

我們想要把 NEJ.define轉化為 define,為了將模組依賴系統轉換為標準的 AMD 形式,則可以用編寫 babel 外掛的方式去做。

首先我們先分析需要訪問修改的 AST 結構

{
    ExpressionStatement {
        expression: CallExpression {
            callee: MemberExpression {
                object: Identifier {
                    name: "NEJ"
                }
                property: Identifier {
                    name: "define"
                }
            }
            arguments: [
                ArrayExpression{},
                FunctionExpression{}
            ]
        }
    }
}

=>  轉化為下面這樣

{
    ExpressionStatement {
        expression: CallExpression {
            callee:  Identifier {
                 name: "define"
            }
            arguments: [
                ArrayExpression{},
                FunctionExpression{}
            ]
        }
    }
}

分析結構可以看到,arguments 是程式碼中傳入的引數部分,這部分保持不變直接拿到就可以了,我們需要修改的是 MemberExpression 表示式節點下的name 為 'NEJ' 的 Identifier部分,由於修改後的結構是一個CallExpression函式呼叫形式的表示式,那麼整體思路現在就是建立一個CallExpression替換掉原來的 MemberExpression即可。這裡借用了 babel-type( 為 babel提供多種輔助函式,類似於 loadsh 與 js之間的關係)建立節點。

const babel = require('babel-core');
const t = require('babel-types');
const code = 'NEJ.define(["./modal"], function(Modal){});';
let args = [];
const visitor = {
    ExpressionStatement(path) {
        if (path.node && path.node.arguments) {
            args = path.node.arguments;
        }
    },
    MemberExpression(path) {
        if (path.node && path.node.object && path.node.object.name === 'NEJ') {
            path.replaceWith(t.CallExpression(
                t.identifier('define'), args
            ))
        }
    }
}
const result = babel.transform(code, {
    plugins: [{
        visitor
    }]
})
console.log(result.code)

執行後即可看到結果

define((["./modal"], function (Modal) {});

在程式碼中可以看到,對於每一步訪問到的節點我們都要嚴格的判斷是否與我們預想的型別一致,這樣不僅是為了排除到其他情況,更是為了防止 Visitor 在訪問相同節點時誤入到其中,但是它可能沒有需要的屬性,那麼就非常容易出錯或者誤傷,嚴格的控制節點的獲取流程將會省去不少不必要的麻煩。

需要注意什麼

State 狀態

狀態是抽象語法樹 AST 轉換的敵人,狀態管理會不斷牽扯我們的精力,而且幾乎所有你對狀態的假設,總是會有一些未考慮到的語法最終證明你的假設是錯誤的。

Scope 作用域

在 JavaScript 中,每當你建立了一個引用,不管是透過變數(variable)、函式(function)、型別(class)、引數(params)、模組匯入(import)還是標籤(label)等,它都屬於當前作用域。

當編寫一個轉換時,必須要小心作用域。我們得確保在改變程式碼的各個部分時不會破壞已經存在的程式碼。在新增一個新的引用時需要確保新增加的引用名字和已有的所有引用不衝突,或者僅僅想找出使用一個變數的所有引用, 我們只想在給定的作用域(Scope)中找出這些引用。

作用域可以被表示為如下形式:

{
  path: path,
  block: path.node,
  parentBlock: path.parent,
  parent: parentScope,
  bindings: [...]
}

即在建立一個新的作用域的時候,需要給出它的路徑和父作用域,之後在遍歷的過程中它會在該作用域內收集所有的引用,收集完畢後既可以在作用域上呼叫方法。

例如下面程式碼中,我麼需要將函式中的 n 轉換為 x 。

function square(n) {
  return n * n;
}
var n = 1;

// 定義的 visitor(錯誤版❌)
let paramName;

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    paramName = param.name;
    param.name = "x";
  },

  Identifier(path) {
    if (path.node.name === paramName) {
      path.node.name = "x";
    }
  }
};

如果不考慮作用域的問題,則會導致函式外的 n 也被轉變,所以在轉換的過程中我們可以在 FunctionDeclaration 節點中進行 n 的轉變,把需要遍歷的轉換方法放在其中,防止對外部的程式碼產生作用。

// 改進後
const updateParamNameVisitor = {
  Identifier(path) {
    if (path.node.name === this.paramName) {
      path.node.name = "x";
    }
  }
};

const MyVisitor = {
  FunctionDeclaration(path) {
    const param = path.node.params[0];
    const paramName = param.name;
    param.name = "x";

    path.traverse(updateParamNameVisitor, { paramName });
  }
};

path.traverse(MyVisitor);

Bindings 繫結
所有引用屬於特定的作用域,引用和作用域的這種關係稱作為繫結。

例如需要將 const 轉換為 var,並且對 const 宣告的值給予只讀保護。

const  a = 1;
const  b = 4;
function test (){
    let a = 2;
      a = 3;
}
a = 34;

而對於上面的這種情況,由於 function 有自己的作用域,所以在 function 內 a 可以被修改,而在外面則不能被修改。所以在實際應用中就需要考慮到繫結關係。

使用配置

常見做法是設定一個根目錄下的 .babelrc 檔案,統一將 babel 的設定都放在這裡。

常用 options 欄位說明

  • env:env 的核心目的是透過配置得知目標環境的特點,然後只做必要的轉換。例如目標瀏覽器支援 es2015,那麼 es2015 這個 preset 其實是不需要的,於是程式碼就可以小一點(一般轉化後的程式碼總是更長),構建時間也可以縮短一些。如果不寫任何配置項,env 等價於 latest,也等價於 es2015 + es2016 + es2017 三個相加(不包含 stage-x 中的外掛)。
  • plugins:要載入和使用的外掛,外掛名前的babel-plugin-可省略;plugin列表按從頭到尾的順序執行
  • presets:要載入和使用的preset ,每個 preset 表示一個預設外掛列表,preset名前的babel-preset-可省略;presets列表的preset按從尾到頭的逆序執行(為了相容使用者使用習慣)
  • 同時設定了presets和plugins,那麼plugins的先執行;每個preset和plugin都可以再配置自己的option

常見的配置方法

{
    "plugins": [
        "transform-remove-strict-mode",
        ["transform-nej-module", {"mode": "web"}]
    ],
    "presets": [
        "env"
    ]
}

參考

推薦工具

相關文章