babel外掛開發心得

瀟湘待雨發表於2018-06-05

前言

對於前端開發而言,babel肯定是再熟悉不過了,工作中肯定會用到。除了用作轉換es6和jsx的工具之外,個人感覺babel基於抽象語法樹的外掛機制,給我們提供了更多的可能。關於babel相關概念和外掛文件,網上是有很多的,講的挺不錯的。詳細的解析推薦官方的babel外掛手冊。在開發外掛之前,有些內容還是要了解一下的,已經熟悉的大佬們可以直接跳過。

抽象語法樹(AST)

Babel 使用一個基於 ESTree 並修改過的 AST,它的核心說明文件可以在[這裡](https://github. com/babel/babel/blob/master/doc/ast/spec. md)找到。 直接看例項應該更清晰:

function square(n) {
  return n * n;
}
複製程式碼

對應的AST物件(babel提供的物件格式)

{
  //程式碼塊類別,函式宣告
  type: "FunctionDeclaration",
  //變數標識
  id: {
    type: "Identifier",
    //變數名稱
    name: "square"
  },
  //引數
  params: [{
    type: "Identifier",
    name: "n"
  }],
  //函式體
  body: {
     //塊語句
    type: "BlockStatement",
    body: [{
       //return 語句
      type: "ReturnStatement",
      argument: {
        //二元表示式
        type: "BinaryExpression",
        //操作符
        operator: "*",
        left: {
          type: "Identifier",
          name: "n"
        },
        right: {
          type: "Identifier",
          name: "n"
        }
      }
    }]
  }
}
複製程式碼

大概就是上面這個層級關係,每一層都被稱為節點(Node),一個完整AST對應的js物件可能會有很多節點,視具體情況而定。babel將每個節點都作為一個介面返回。其中包括的屬性就如上面程式碼所示,例如type,start,end,loc等通用屬性和具體type對應的私有屬性。我們後面外掛的處理也是根據不同的type來處理的。

看到這個龐大的js物件,不要感到頭疼,如果說讓我們每次都自己去分析AST和按照babel的定義去記住不同型別,顯然不現實。這種事情應該交給電腦來執行,我們可以利用AST Explorer 來將目的碼轉成語法樹物件,結合 AST node types來檢視具體屬性。

Babel 的處理步驟

Babel 的三個主要處理步驟分別是: 解析(parse),轉換(transform),生成(generate),具體過程就不想詳細描述了,直接看官方手冊就好。
需要注意的是,babel外掛就是在轉換過程中起作用的,即將解析完成的語法樹物件按照自己的目的進行處理,然後再進行程式碼生成步驟。所以要深入瞭解轉換相關的內容。

程式碼生成步驟把最終(經過一系列轉換之後)的 AST 轉換成字串形式的程式碼,同時還會建立原始碼對映(source maps),以便於除錯。

程式碼生成的原理:深度優先遍歷整個 AST,然後構建可以表示轉換後程式碼的字串。轉換的時候是是進行遞迴的樹形遍歷。

轉換

Visitor

轉換的時候,是外掛開始起作用的時候,但是如何進入到這個過程呢,babel給我們提供了一個Visitor的規範。我們可以通過Visitor來定義我們的訪問邏輯。大概就是下面這個樣子

const MyVisitor = {
  //這裡對應上面node的type,所有type為Identifier的節點都會進入該方法中
  Identifier() {
    console.log("Called!");
  }
};
//以該方法為例 
function square(n) {
  return n * n;
} 
//會呼叫四次,因為
//函式名square
//形參 n
//函式體中的兩個n,都是Identifier
path.traverse(MyVisitor); 
//  所以輸出四個
Called!
Called!
Called!
Called!
複製程式碼

因為深度優先的遍歷演算法,到一個葉子節點之後,發現沒有子孫節點,需要向上溯源才能回到上一級繼續遍歷下個子節點,所以每個節點都會被訪問兩次。
如果不指定的話,呼叫都發生在進入節點時,當然也可以在退出時呼叫訪問者方法。

const MyVisitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
}; 
複製程式碼

此外還有一些小技巧:

可以在方法名用|來匹配多種不同的type,使用相同的處理函式。

const MyVisitor = {
  "ExportNamedDeclaration|Flow"(path) {}
};
複製程式碼

此外可以在訪問者中使用別名(如babel-types定義) 例如Function是FunctionDeclaration,FunctionExpression,ArrowFunctionExpression,ObjectMethod和ObjectMethod的別名,可以用它來匹配上述所有型別的type

const MyVisitor = {
  Function(path) {}
};
複製程式碼

Paths

AST 通常會有許多節點,那麼節點直接如何相互關聯呢? 我們可以使用一個可操作和訪問的巨大可變物件表示節點之間的關聯關係,或者也可以用Paths(路徑)來簡化這件事情。Path 是表示兩個節點之間連線的物件。直接看例子比較清晰一點。


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


複製程式碼

將子節點 Identifier 表示為一個路徑(Path)的話,看起來是這樣的:

{
  "parent": {
    "type": "FunctionDeclaration",
    "id": {...},
    ....
  },
  "node": {
    "type": "Identifier",
    "name": "square"
  }
}
複製程式碼

當你通過一個 Identifier() 成員方法的訪問者時,你實際上是在訪問路徑而非節點。 通過這種方式,你操作的就是節點的響應式表示(譯註:即路徑)而非節點本身。

編寫外掛

前面都是些必備知識點,本文只是將一些相對重要一點的知識點提了一下。詳細的還是要去看開發手冊的。 個人而言開發外掛的話應該有下面三個步驟:

  1. 分析原始檔抽象語法樹AST
  2. 分析目標檔案抽象語法樹
  3. 構建Visitor
    3.1 確定訪問條件
    3.2 確定轉換邏輯

外掛主要的就是3步驟,但是前兩步是十分重要的。3.1和3.2分別依賴於1和2的結果。只有清晰瞭解AST結構之後,才能有的放矢,事半功倍。 舉個例子,如下程式碼:

var func = ()=>{
    console.log(this.b)
}; 
複製程式碼

目的是將箭頭函式轉換成普通函式宣告(這裡僅僅是具體這種格式的轉化,其他部分就先不涉及)。如下:

var _this = this;
var func = function () {
    console.log(_this.b);
};
複製程式碼

原始檔語法樹

這裡分析下這個簡單的函式宣告,按照上面定義分析,不過這裡還是推薦AST Explorer 可以清晰的看到我們的語法樹。這裡只擷取有用資訊:

        "init": {
              "type": "ArrowFunctionExpression",
              /*...其他資訊....*/
              "id": null,
              //形參
              "params": [],
              "body": {
                //函式體,this部分
                "arguments": [
                        {
                          "type": "MemberExpression",
                          "object": {
                             //this 表示式
                            "type": "ThisExpression",
                          },
                          "property": {
                             //b屬性
                            "type": "Identifier",
                            "name": "b"
                          }
                        }
                      ]
             }
        }
複製程式碼

我們要轉換的只是ArrowFunctionExpression即箭頭函式和this表示式ThisExpression部分,其他暫時不動。
那麼我們的visitor裡的函式名稱就包括ArrowFunctionExpression和ThisExpression了。

//visitor裡面方法的key就對應我們要處理的node  type
const visitor = {
    //處理this表示式  
    ThisExpression(path){
        //將this轉換成_this的形式
    },
    //處理箭頭函式。
    ArrowFunctionExpression(path){
       //轉換成普通的FunctionExpression
    }
}   
複製程式碼

目標檔案語法樹

同樣的方法,語法樹物件如下:
語法樹太長,我們就看一下變化的地方好了

    //轉換之後的body由兩個元素的陣列,兩個變數宣告是統計關係
    "body": [
      //var _this = this;結構
      {
        "type": "VariableDeclaration",
        "kind": "var",
        "declarations": [
          {
            "type": "VariableDeclarator",
            //left為_this的標識
            "id": {
              "type": "Identifier",
              "name": "_this"
            },
            //right為this表示式
            "init": {
              "type": "ThisExpression"
              /***其他**/
            }
      },   
      // var func = function (b) {
      //      console.log(_this.b);
      //  };結構 只看關鍵的
      {
        "type": "VariableDeclaration",
        "kind": "var",
        "declarations": [
          {
            /*****省略*******/
            "arguments": [
                        {
                          "type": "MemberExpression",
                          //轉換之後的_this.b
                          "object": {
                            "type": "Identifier",
                            "name": "_this"
                          },
                          "property": {
                            "type": "Identifier",
                            "name": "b"
                          }
                          ]
          }
      }
    ]
複製程式碼

經過對比,確定我們的操作應該是將ArrowFunctionExpression替換為FunctionExpression,遇到有this表示式的,繫結一下this,並將其轉換。 進行替換增加等操作時就要用到path提供的api了:

  • replaceWith(targetObj) 替換
  • findParent() 查詢滿足條件的父節點
  • insertBefore 插入兄弟節點
    更多請查詢文件,這裡只列出我們用到的方法。

構造節點

這裡將這個操作單獨拿出來,toFunctionExpression這個api的說明我始終沒找到。。。。可能是我沒找對地方FunctionExpression,沒辦法我去babel原始碼裡找了一遍:

//@src  /babel/packages/babel-types/src/definitions/core.js
defineType("FunctionExpression", {
  inherits: "FunctionDeclaration",
  //....
}
//又找到 FunctionDeclaration
defineType("FunctionDeclaration", {
  //這裡才看到引數: id,params,body..
  builder: ["id", "params", "body", "generator", "async"],
  visitor: ["id", "params", "body", "returnType", "typeParameters"]
  //....  
}

複製程式碼

這樣的話才知道入參,如果有清晰的文件,請大家不吝賜教。下面就簡單了。

後來又專門找了一下,終於找到對應文件了傳送門

完善Visitor

const Visitor = {
    //this表示式
    ThisExpression(path){
        //構建var _this = this
        let node = t.VariableDeclaration(
            'var',
            [
                t.VariableDeclarator(
                    t.Identifier('_this'),
                    t.Identifier('this')
                )
            ]
        ),
        //構建 _this識別符號
        str = t.Identifier('_this'),
        //查詢變數宣告的父節點
        //這裡只是針對例子的,真正轉換需要考慮的情況很多
        parentPath = path.findParent((path) => path.isVariableDeclaration())
        //滿足條件
        if(parentPath){
            //插入
            parentPath.insertBefore(node)
            path.replaceWith(
                str
            )
        }else{
            return
        }
    },
    //處理箭頭函式。
    ArrowFunctionExpression(path){
        var node = path.node
        //構造一個t.FunctionExpression節點,將原有path替換掉即可
        path.replaceWith(t.FunctionExpression(
            node.id,
            node.params,
            node.body
          ))
    }
}     
複製程式碼

主體visitor至此算結束了,當然如果是外掛的話

//babel呼叫外掛時會將babel-types作為引數傳入 
export default function({ types: t }) {
  return {
    visitor:Visitor
  }
複製程式碼

在本地除錯的話,可以分別引入babel-core和babel-types

var babel = require('babel-core');
var t = require('babel-types');
var code = `var func = ()=>{
    console.log(this.b)
  };`
const result = babel.transform(code, {
	plugins: [{
	  //前面的Visitor
		visitor: Visitor
	}]
});  
//輸出轉換之後的code
/**
 * var _this = this;
 * var func = function () {
 * console.log(_this.b);
 * }; 
 */
console.log(result.code);  
複製程式碼

結束語

參考文章

Babel 外掛手冊
Babel for ES6? And Beyond!

紙上得來終覺淺,原本也認為已經理解了babel的原理和外掛機制,沒想到連寫個小demo都這麼費勁。主要還是對相關api不熟悉,不知道如何去構建節點,熟練之後應該會好很多。此文是外掛手冊的一個簡單總結,把自己實現的思路彙總了一下。拋磚引玉,共同進步,另外希望對有需要的同學略有幫助。詳見我的部落格

相關文章