通過開發 Babel 外掛來理解什麼是抽象語法樹(AST)

Vince_發表於2019-06-24

前言

說到 babel 你肯定會先想到 babel 可以將還未被瀏覽器實現的 ES6 規範轉換成能夠執行 ES5 規範,或者可以將 JSX 轉換為瀏覽器能識別的 HTML 結構,那麼 babel 是如何進行這個轉換的步驟呢,下面我將通過開發一個簡單的 babel 外掛來解釋這整個過程,希望你對 Babel 外掛原理與 AST 有新的認知。

Babel 執行階段

從上面的分析,我們大概能猜出 Babel 的執行過程是:原始程式碼 -> 修改程式碼,那麼在這個轉換的過程中,我們需要知道以下三個重要的步驟。

解析

首先需要將 JavaScript 字串經過詞法分析、語法分析後,轉換為計算機更易處理的表現形式,稱之為“抽象語法樹(AST)”,這個步驟我們使用了 Babylon 解析器。

轉換

當 JavaScript 從字串轉換為 AST 後,我們就能更方便地對其進行瀏覽、分析和有規律的修改,根據我們的需求,將其轉換為新的 AST,babel-traverse 是一個很好的轉換工具,使得我們能夠很便利的操作 AST 。

生成

最後,我們將修改完的 AST 進行反向處理,生成 JavaScript 字串,整個轉換過程也就完成了,這一步當中,我們使用到了 babel-generator 模組。

什麼是 AST

之前聽過一句話:“如果你能熟練地操作 AST ,那麼你真的可以為所欲為。”,當時並不理解其含義,直到真正瞭解 AST 後,才發現 AST 對程式語言的重要性是不可估量的。

在電腦科學中,抽象語法樹(abstract syntax tree 或者縮寫為 AST),或者語法樹(syntax tree),是原始碼的抽象語法結構的樹狀表現形式,這裡特指程式語言的原始碼。樹上的每個節點都表示原始碼中的一種結構。

之所以說語法是「抽象」的,是因為這裡的語法並不會表示出真實語法中出現的每個細節。

JavaScript 程式一般是由一系列字元組成的,我們可以使用匹配的字元([], {}, ()),成對的字元('', "")和縮排讓程式解析起來更加簡單,但是對計算機來說,這些字元在記憶體中僅僅是個數值,並不能處理這些高階問題,所以我們需要找到一種方式,將其轉換成計算機能理解的結構。

我們簡單看下面的程式碼:

let a = 2;
a * 8
複製程式碼

將其轉換為 AST 會是怎樣的呢,我們使用 astexplorer 線上 AST 轉換工具,可以得到以下樹結構:

image

為了更形象表述,我們將其轉換為更直觀的結構圖形:

image

AST 的根節點都是 Program ,這個例子中包含了兩部分:

  1. 一個變數申明(VariableDeclarator),將識別符號(Identifier) a 賦值為數值(NumericLiteral) 3。

  2. 一個二元表示式語句(BinaryExpression),描述為標誌符(Identifier)為 a,操作符(operator) + 和數值(NumericLiteral) 5。

這只是一個簡單的例子,在實際開發中,AST 將會是一個巨型節點樹,將字串形式的原始碼轉換成樹狀的結構,計算機便能更方便地處理,我們使用的 Babel 外掛,也就是對 AST 進行插入/移動/替換/刪除節點,建立成新的 AST ,再將 AST 轉換為字串原始碼,這便是 Babel 外掛的原理,之所以能夠“為所欲為”,其原因就是可以將原始程式碼按照指定邏輯轉換為你想要的程式碼。

開發 Babel 外掛 Demo

基礎概念

一個典型的 Babel 外掛結構,如下程式碼所示:

export default function(babel) {
  var t = babel.types;
  return {
    visitor: {
      ArrayExpression(path, state) {
          path.replaceWith(
            t.callExpression(
              t.memberExpression(t.identifier('mori'), t.identifier('vector')),
              path.node.elements
            )
          );
      },
      ASTNodeTypeHere(path, state) {}
    }
  };
};
複製程式碼

我們要關注的幾個點為:

  • babel.types: 用來操作 AST 節點,如建立、轉換、校驗等。
  • vistor: Babel 採用遞迴的方式訪問 AST 的每個節點,之所以叫做visitor,只是因為有個類似的設計模式叫做訪問者模式,如上述程式碼中的 ArrayExpression ,當遍歷到 ArrayExpression 節點時,即觸發對應函式。
  • path: path 是指 AST 節點的物件,可以用來獲取節點的屬性、節點之間的關聯。
  • state: 指外掛的狀態,可以用過 state 來獲取外掛中的配置項。
  • ArrayExpression、ASTNodeTypeHere: 指 AST 中的節點型別。

需求分析

因為是 Demo ,我們需求很簡單,我們開發的 Bable 外掛名稱叫 vincePlugin,在使用的時候,能配置外掛的引數,使得外掛能按照我們配置的引數進行轉換。

// babel 引數配置

plugins: [
    [vincePlugin, {
        name: 'vince'
    }]
]
複製程式碼

轉換效果:

var fool = [1,2,3];
// translate to =>
var fool = vince.init(1,2,3)
複製程式碼

初始化專案

為了大家更方便的閱讀程式碼,原始碼已經上傳到GitHub: babel-plugin-demo

瞭解了以上概念與需求後,我們就可以開始進行 Babel 外掛開發,開始之前先建立一個專案目錄,初始化 npm ,並安裝 babel-core :

mkdir babel-plugin-demo && cd babel-plugin-demo
npm init -y
npm install --save-dev babel-core
複製程式碼

建立 plugin.js babel 外掛檔案,我們將會在這裡寫轉換的邏輯程式碼:

// plugin.js
module.exports = function(babel) {
    var t = babel.types;
    return {
      visitor: {
        // ...
      }
    };
};
複製程式碼

建立原始程式碼 index.js

var fool = [1,2,3];
複製程式碼

建立 test.js 測試函式,這裡我們進行對外掛的測試:

// test.js
var fs = require('fs');
var babel = require('babel-core');
var vincePlugin = require('./plugin');

// read the code from this file
fs.readFile('index.js', function(err, data) {
  if(err) throw err;

  // convert from a buffer to a string
  var src = data.toString();

  // use our plugin to transform the source
  var out = babel.transform(src, {
    plugins: [
        [vincePlugin, {
            name: 'vince'
        }]
    ]
  });

  // print the generated code to screen
  console.log(out.code);
});
複製程式碼

我們通過 node test.js,來測試 babel 外掛的轉換輸出。

節點對比

  • 原始程式碼 var fool = [1,2,3]; 通過 AST 分析出來的節點如圖:

image

  • 轉換後程式碼 var bar = vince.init(1, 2, 3);,通過 AST 分析出來的節點如圖:

image

我們通過用紅色標註來區分原始與轉換後的 AST 結構圖,現在我們可以很清晰的看到我們需要替換的節點,將 ArrayExpression 替換為 CallExpression ,在 CallExpression 節點中中增加一個 MemberExpression,並且保留原始的三個 NumericLiteral。

plugin 編寫

首先,我們需要替換的是 ArrayExpression ,所以給 vistor 新增 ArrayExpression 方法。

// plugin.js
module.exports = function(babel) {
    var t = babel.types;
    return {
      visitor: {
        ArrayExpression: function(path, state) {
            // ...
        }
      }
    };
};
複製程式碼

當 Babel 遍歷 AST 時,當發現含有 visitor 上有對呀節點方法時,即會觸發這個方法,並且將上下文傳入(path, state),在函式裡面我們進行節點的分析和替換操作:

// plugin.js
module.exports = function(babel) {
    var t = babel.types;
    return {
      visitor: {
        ArrayExpression: function(path, state) {
            // 替換該節點
            path.replaceWith(
              // 建立一個 callExpression 
              t.callExpression(
                t.memberExpression(t.identifier(state.opts.name), t.identifier('init')),
                path.node.elements
              )
            );
        }
      }
    };
};
複製程式碼

我們需要將 ArrayExpression 替換為 CallExpression,可以通過 t.callExpression(callee, arguments) 來生成 CallExpression,第一個引數是 MemberExpression,通過t.memberExpression(object, property) 來生成,然後再將原有的三個 NumericLiteral 設定為第二個引數,於是就完成了我們的需求。

這裡我們要注意 state.opts.name 中指的是配置 plugin 時,設定的 config 引數。

更多的轉換方式和節點屬性,可以查閱 babel-types 的文件

測試plugin

我們回到test.js,執行node test.js,便會得出:

node test.js

=> var bar = vince.init(1, 2, 3);

複製程式碼

到這裡,我們簡易的 Babel 外掛便完成好了,實際上的開發需求要複雜的多,但是主要的邏輯還是離不開上面的幾個概念。

總結

還是回到開始那句話“如果你能熟練地操作 AST ,那麼你真的可以為所欲為。”,我們能夠通過 AST 將原始程式碼轉換成我們所需要的任何程式碼,甚至你能建立一個私人的 ESXXX,新增你創造的新規範。AST 並不是一個很複雜的技術活,很大一部分可以視為“苦力活”,因為遇到複雜的轉換需求可能需要編寫寫很多邏輯程式碼。

通過閱讀這篇文章,我們瞭解了 Babel 外掛的實現原理,並且實踐了一個 Plugin,除此之外,我們也理解了 AST 的概念,認識到了其強大之處。

引用:

Babel 使用者手冊

Babel 外掛手冊

相關文章