Babel 插件起手式

前言

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

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

  • 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 角逐移動開發,它高喊着自由、民主,逐漸俘獲一個又一個少年少女的心扉。node

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

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

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

抽象語法樹

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

爲了直觀展現,咱們先來定義一個函數:數組

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 都實現瞭如下規範:bash

interface Node {
  type: string;
  loc: SourceLocation | null;
}
複製代碼

type 表示不一樣的語法類型,上面的 AST 中具備 FunctionDeclaration、BlockStatement、ReturnStatement 等類型,咱們能夠經過每一個 Node 中的 type 字段進行分別,全部 type 可見文檔

工做流程

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

image.png

編譯

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 插件時,再繼續進行探索吧。

參考

相關文章
相關標籤/搜索