Babel 插件原理的理解與深刻

如今談到 babel 確定你們都不會感受到陌生,雖然平常開發中不多會直接接觸到它,但它已然成爲了前端開發中不可或缺的工具,不只可讓開發者能夠當即使用 ES 規範中的最新特性,也大大的提升了前端新技術的普及(學不動了...)。可是對於其轉換代碼的內部原理咱們大多數人卻知之甚少,因此帶着好奇與疑問,筆者嘗試對其原理進行探索。

Babel 是一個通用的多功能 JavaScript 編譯器,但與通常編譯器不一樣的是它只是把同種語言的高版本規則轉換爲低版本規則,而不是輸出另外一種低級機器可識別的代碼,而且在依賴不一樣的拓展插件下可用於不一樣形式的靜態分析。(靜態分析:指在不須要執行代碼的前提下對代碼進行分析以及相應處理的一個過程,主要應用於語法檢查、編譯、代碼高亮、代碼轉換、優化、壓縮等等)html

babel 作了什麼

和編譯器相似,babel 的轉譯過程也分爲三個階段,這三步具體是:前端

  • 解析 Parse

將代碼解析生成抽象語法樹( 即AST ),也就是計算機理解咱們代碼的方式(擴展:通常來講每一個 js 引擎都有本身的 AST,好比熟知的 v8,chrome 瀏覽器會把 js 源碼轉換爲抽象語法樹,再進一步轉換爲字節碼或機器代碼),而 babel 則是經過 babylon 實現的 。簡單來講就是一個對於 JS 代碼的一個編譯過程,進行了詞法分析與語法分析的過程。node

  • 轉換 Transform

對於 AST 進行變換一系列的操做,babel 接受獲得 AST 並經過 babel-traverse 對其進行遍歷,在此過程當中進行添加、更新及移除等操做。git

  • 生成 Generate

將變換後的 AST 再轉換爲 JS 代碼, 使用到的模塊是 babel-generatorgithub

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

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

而咱們編寫的 babel 插件則主要專一於第二步轉換過程的工做,專一於對於代碼的轉化規則的拓展,解析與生成的偏底層相關操做則有對應的模塊支持,在此咱們理解它主要作了什麼便可。express

好比這樣一段代碼:編程

console.log("hello")

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

{
    "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 規範

抽象語法樹是怎麼生成的

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

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屬性有幾個重要的組成,主要以下:

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

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

參考

推薦工具

相關文章
相關標籤/搜索