Babel插件起手式

NO IMAGE

前言

據聖經記載,曾經有一種很高很高的塔,是由一群說著同樣語言、勤勞而又團結的人民興修的,他們希望由此能通往天堂,上帝攔阻了人的計劃,是出於愛和保護,讓人依靠上帝認識上帝,於是將他們的語言打亂,讓他們再也不能明白對方的意思,並把他們分散到了世界各地。因此曾經高聳入雲的塔,被世人稱作“巴別塔(Babel)”,也稱為混亂之塔。

木秀於林,風必摧之,JavaScript 也沒能逃過這種命運。它自誕生以來,以迅雷不及掩耳之勢,憑藉著自身的靈活性與易用性,在瀏覽器端大放異彩,廣泛的應用於不同標準的各個瀏覽器。可是好景不長,一個被稱作 ECMA 的邪惡組織在暗中不斷對 JavaScript 進行著實驗,將其培養為恐怖的生化武器。科學家們們為了滿足各自的私慾,在 ES4 上集成了各自所需的特性,以此想要達成對語言規範的控制權,可被寄予厚望的 ES4 還是沒能頂住壓力,最終因難產而死。為了繼續將實驗進行下去,名為 DC 和 M$ 的科學家起了一個更為保守、漸進的提案,被人們廣泛接受並時隔兩年問世,稱為 ES5。長期以來,名為 TC39 的實驗室在暗中制定了 TC39 process 流水線,它包含 5 個 Stage:

  • Stage 0Strawman階段)- 該階段是一個開放提交階段,任何在TC39註冊過的貢獻都或TC39成員都可以進行提交
  • Stage 1Proposal階段)- 該階段是對所提交新特性的正式建議
  • Stage 2Draft階段)- 該階段是會出現標準中的第一個版本
  • Stage 3Canidate階段)- 該階段的提議已接近完成
  • Stage 4Finished階段)- 該階段的會被包括到標準之中

自 2015 年來,JavaScript 邁入了一個嶄新的 ES6 紀元,它代表著集眾家之長的 ES2015 的問世,這使得 JavaScript 它不僅擁有了自己的 ES Module 規範,還解鎖了 Proxy、Async、Class、Generator等特性,它已經逐漸成長為一個健壯的語言,並且憑著高性能的 Node 框架開始佔領服務端市場,近幾年攜手 React Native 角逐移動開發,它高喊著自由、民主,逐漸俘獲一個又一個少年少女的心扉。

任何語言都依賴於一個執行環境,對於 JavaScript 這樣的腳本語言來講,它始終依賴於 JavaScript 引擎,而引擎一般會附帶在瀏覽器上,不同瀏覽器間的引擎版本與實現是不同的,因此就很容易帶來一個問題——各個瀏覽器對 JavaScript 語言的解析結果上會有很大的不同。對於開發者而言,我們需要放棄語言新特性並寫出兼容代碼以此來支持不同的瀏覽器用戶的使用;對於用戶來講,強制用戶更換最新瀏覽器是不合理也不現實的。

這種狀況直到 Babel 的出現才得以解決,Babel 是一個 JavaScript 編譯器,主要用於將 ES2015+ 語法標準的代碼轉換為向後兼容的版本,以此來適應老版本的運行環境。Babel 不僅是一個編譯器,它更是 JavaScript 走向統一、標準化的橋樑,軟件開發者能夠以偏好的編程語言或風格來寫作源代碼,並將其利用 Babel 翻譯成統一的 JavaScript 形式。

Babel 是混亂誕生之地,同時也是混亂終結之地,為了世界的和平,我們都需要嘗試學習一下 Babel 插件的基礎知識,以備不時之需。

抽象語法樹

在計算機科學中,抽象語法和抽象語法樹其實是源代碼的抽象語法結構的樹狀表現形式,又稱為 AST(Abstract Syntax Tree)。AST 常用來進行語法檢查、代碼風格的檢查、代碼的格式、代碼的高亮、代碼錯誤提示、代碼自動補全等,它的應用十分廣泛,在 JavaScript 裡 AST 遵循 ESTree 的規範。

為了直觀展示,我們先來定義一個函數:

function square(n) {
  return n * n;
}

它的 AST 轉換結果如下(省略了一些空字段和位置字段):

{
  "type": "File",
  "program": {
    "type": "Program",
    "body": [
      {
        "type": "FunctionDeclaration",
        },
        "id": {
          "type": "Identifier",
            "identifierName": "square"
          },
          "name": "square"
        },
        "params": [
          {
            "type": "Identifier",
            "name": "n"
          }
        ],
        "body": {
          "type": "BlockStatement",
          "body": [
            {
              "type": "ReturnStatement",
              "argument": {
                "type": "BinaryExpression",
                "left": {
                  "type": "Identifier",
                  "name": "n"
                },
                "operator": "*",
                "right": {
                  "type": "Identifier",
                  "name": "n"
                }
              }
            }
          ],
        }
      }
    ],
  },
}

AST 既然是樹形結構,那我們就可以將它看作是一個個 Node,每個 Node 都實現了以下規範:

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

type 表示不同的語法類型,上面的 AST 中具有 FunctionDeclaration、BlockStatement、ReturnStatement 等類型,我們可以通過每個 Node 中的 type 字段進行分別,所有 type 可見文檔

工作流程

通過配置 Babel 的 presets、plugin等信息,Babel 會將源代碼進行特定的轉換,並輸出更為通用的目標代碼,其中最主要的三部分為:編譯(parse)、轉換(transform)、生成(generate)。

Babel插件起手式

編譯

Babel 的編譯功能主要由 @babel/parser 完成,它的最終目標是轉換為 AST 抽象語法樹,在此過程中主要包含兩個步驟:

  1. 詞法分析(Lexical Analysis),它會將源代碼轉換為扁平的語法片段數組,也稱作令牌流(tokens)
  2. 語法分析(Syntactic Analysis),它將上階段得到的令牌流轉換成 AST 形式

為了得到編譯結果,我們引入 @babel/parser 包,對一段普通函數進行編譯,然後查看打印結果:

import * as parser from '@babel/parser';

function square(n) {
  return n * n;
}

const ast = parser.parse(square.toString());
console.log(ast);

轉換

轉換步驟會對 AST 進行節點遍歷,並對節點進行 CRUD 操作。在 Babel 中是通過 @babel/traverse 完成的,我們接著上一段代碼的編譯過程進行編寫,我們希望將 n * n ,轉化為 Math.pow(n, 2) :

import traverse from '@babel/traverse';
// ...
const ast = parser.parse(square.toString());

traverse(ast, {
  enter(path) {
    if (t.isReturnStatement(path.parent) && t.isBinaryExpression(path.node)) {
      path.replaceWith(t.callExpression(
        t.memberExpression(t.identifier('Math'), t.identifier('pow')),
        [t.stringLiteral('n'), t.numericLiteral(2)]
      ))
    }
  }
});

console.log(JSON.stringify(ast));

在此過程中,我們使用了 @babel/types 用來做類型判斷與生成指定類型的節點。

生成

在 Babel 中主要是用 @babel/generator 進行生成,它將經過轉換的 AST 重新生成為代碼字符串。根據上面 Demo,改寫下代碼:

import generator from '@babel/generator';
// ...同上
console.log(generator(ast));

最終我們得到了轉化後的代碼結果:

{ 
  code: 'function square(n) {\n  return Math.pow("n", 2);\n}',
  map: null,
  rawMappings: null
}

插件構造

我們先來看來定義一個插件基本結構:

// plugins/hello.js
export default function(babel) {
  return {
    visitor: {}
  };
}

然後我們在配置文件中可以按以下方式進行簡單引用:

// babel.config.js
module.exports = { plugins: ['./plugins/hello.js'] };

visitor

在插件中,有個 visitor 對象,它代表訪問者模式,Babel 內部是通過上面提到的 @babel/traverse 進行遍歷節點,我們可以通過指定節點類型進行訪問 AST:

module.exports = function(babel) {
  return {
    visitor: {
      Identifier(path) {
        console.log('visiting:', path.node.name)
      }
    }
  };
};

這樣當進行編譯 n * n 時,就能看到兩次輸出。visitor 也提供針對節點的 enter 與exit 訪問方式,讓我們改寫下程序:

    visitor: {
      Identifier: {
        enter(path) {
          console.log('enter:', path.node.name);
        },
        exit(path) {
          console.log('exit:', path.node.name);
        }
      }
    }

這樣一來,再編譯剛才的程序,就有了 4 次打印,visitor 是按照 AST 的自上到下進行深度優先遍歷,進入節點時會訪問節點一次,退出節點時也會訪問一次。讓我們寫一段代碼來測試一下 traverse 的訪問順序:

import * as parser from '@babel/parser';
import traverse from '@babel/traverse';

function square(n) {
  return n * n;
}
const ast = parser.parse(square.toString());

traverse(ast, {
  enter(path) {
    console.log('enter:', path.node.type, path.node.name || '');
  },
  exit(path) {
    console.log('exit:', path.node.type, path.node.name || '');
  }
});

打印結果:

enter: Program
enter: FunctionDeclaration
enter: Identifier square
exit: Identifier square
enter: Identifier n
exit: Identifier n
enter: BlockStatement
enter: ReturnStatement
enter: BinaryExpression
enter: Identifier n
exit: Identifier n
enter: Identifier n
exit: Identifier n
exit: BinaryExpression
exit: ReturnStatement
exit: BlockStatement
exit: FunctionDeclaration
exit: Program

path

path 作為節點訪問的第一個參數,它表示節點的訪問路徑,基礎結構是這樣的:

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

其中 node 代表當前節點,parent 代表父節點,同時 path 還包含一些 node 元信息和操作節點的一些方法:

  • findParent  向父節點搜尋節點
  • getSibling 獲取兄弟節點
  • replaceWith  用AST節點替換該節點
  • replaceWithMultiple 用多個AST節點替換該節點
  • insertBefore  在節點前插入節點
  • insertAfter 在節點後插入節點
  • remove   刪除節點

路徑是一個節點在樹中的位置以及關於該節點各種信息的響應式 Reactive 表示。 當你調用一個修改樹的方法後,路徑信息也會被更新。 Babel 幫你管理這一切,從而使得節點操作簡單,儘可能做到無狀態。

opts

在使用插件時,用戶可傳人 babel 插件配置信息,插件再根據不同配置來處理代碼,首先,在引入插件時,修改為數組引入方式,數組中第一個對象為路徑,第二個元素為配置項 opts:

module.exports = {
  presets,
  plugins: [
    [
      './src/plugins/xxx.js',
      {
        op1: true
      }
    ]
  ]
};

在插件中,可通過 state 進行訪問:

module.exports = function(babel) {
  return {
    visitor: {
      Identifier: {
        enter(_, state) {
          console.log(state.opts)
          // { op1: true }
        }
      }
    }
  };
};

nodes

當在編寫 Babel 插件時,我們時常需要對 AST 節點進行插入或修改操作,這時可以使用 @babel/types 提供的內置函數進行構造節點,以下兩種方式等效:

import * as t from '@babel/types';
module.exports = function({ types: t }) {}

構建 Node 的函數名通常與 type 相符,除了首字母小寫,比如構建一個 MemberExpression 對象就使用 t.memberExpression(...) 方法,其中構造參數取決於節點的定義。

Babel 插件實踐

上面列舉了一些 Babel 插件基本的用法,最重要的還是在於在代碼工程中進行實踐,想象一下哪些場景我們可以通過編寫 Babel 插件來解決實際問題,然後 Just Do It。

一個最簡單的插件實例

為了拋磚引玉,我們來舉一個最簡單的示例。在代碼調試過程中,我們常常使用到 Debugger 這個語句,便於進行函數運行時調試,我們希望通過使用 Babel 插件,當在開發環境時打印當前 Debugger 節點的位置,便於提醒我們,而在生產環境直接將節點刪除。

為了實現這樣的插件,首先通過 ASTExplorer 找到 Debugger 的 Node type 為 DebuggerStatement,我們需要使用這個節點訪問器,再通過 NODE_ENV 判斷運行環境,若為 production 則調用 path.remove方法,否則打印堆棧信息。

首先,創建一個名為 babel-plugin-drop-debugger.js 的插件,並編寫代碼:

module.exports = function() {
  return {
    name: 'drop-debugger',
    visitor: {
      DebuggerStatement(path, state) {
        if (process.env.NODE_ENV === 'production') {
          path.remove();
          return;
        }
        const {
          start: { line, column }
        } = path.node.loc;
        console.log(
          `Debugger exists in file: ${
            state.filename
          }, at line ${line}, column: ${column}`
        );
      }
    }
  };
};

然後在 babel.config.js 中引用插件:

module.exports = {
  plugins: ['./babel-plugin-drop-debugger.js']
};

再創建一個測試文件 test-plugin.js :

function square(n) {
  debugger;
  return () => 2 * n;
}

當我們執行: npx babel test-plugin.js 時打印:

Debugger exists in file: /Users/xxx/test-plugin.js, at line 2, column: 2

若執行: NODE_ENV=production npx babel test-plugin.js 時打印:

function square(n) {
  return () => 2 * n;
}

總結

目前在工程中還沒遇到需要 Babel 解決問題的場景,因此就先不再繼續深入了,希望之後能進行補充。在這篇文章中我們對 Babel 插件有了一個基本的印象,若要了解 Babel 插件的基本使用方式請訪問用戶手冊

Babel 主要由三部分組成:編譯(parse)、轉換(transform)、生成(generate),插件機制不開以下幾個核心庫:

  • @babel/parser ,Babel AST 解析器,原名為 babylon,由 acorn 改造而來
  • @babel/traverse ,對 AST Node 進行遍歷與更新
  • @babel/generator ,根據 AST 與相關選項重新構建代碼
  • @babel/types ,判斷 AST 節點類型與構造新的節點

以下為一些實用的開發輔助:

值得一提的是,Babel 官方 Github 庫的 API 文檔和 Doc 不太健全,有時候只能通過源碼去學習。希望下次需要實現一個完整的 Babel 插件時,再繼續進行探索吧。

參考

相關文章

MySQL實戰技能包

struts結果類型

CSS攻擊:記錄用戶密碼

Redux梳理分析【一:reducer和dispatch】