自己寫一個Babel外掛

前言
之前看到一位大佬的部落格, 介紹了babel的原理, 以及如何寫一個babel的外掛, 抱著試試看的想法, 照葫蘆畫瓢的自己寫了一個簡單的babel外掛, 該外掛的作用就是將程式碼字串中的表示式, 直接轉換為對應的計算結果。例如: const code = const result = 1 + 1
轉化為const code = const result = 2
。當然這一篇文章非常的淺顯, 但是對了解Babel的原理以及AST的基本概念是足夠的了。
相關連結
外掛的原始碼
const t = require('babel-types') const visitor = { // 二元表示式型別節點的訪問者 BinaryExpression(path) { // 子節點 // 訪問者會一層層遍歷AST抽象語法樹, 會樹形遍歷AST的BinaryExpression型別的節點 const childNode = path.node let result = null if ( t.isNumericLiteral(childNode.left) && t.isNumericLiteral(childNode.right) ) { const operator = childNode.operator switch (operator) { case '+': result = childNode.left.value + childNode.right.value break case '-': result = childNode.left.value - childNode.right.value break case '/': result = childNode.left.value / childNode.right.value break case '*': result = childNode.left.value * childNode.right.value break } } if (result !== null) { // 替換本節點為數字型別 path.replaceWith( t.numericLiteral(result) ) if (path.parentPath) { const parentType = path.parentPath.type if (visitor[parentType]) { visitor[parentType](path.parentPath) } } } }, // 屬性表示式 MemberExpression(path) { const childNode = path.node let result = null if ( t.isIdentifier(childNode.object) && t.isIdentifier(childNode.property) && childNode.object.name === 'Math' ) { result = Math[childNode.property.name] } if (result !== null) { const parentType = path.parentPath.type if (parentType !== 'CallExpression') { // 替換本節點為數字型別 path.replaceWith( t.numericLiteral(result) ) if (visitor[parentType]) { visitor[parentType](path.parentPath) } } } }, // 一元表示式 UnaryExpression (path) { const childNode = path.node let result = null if ( t.isLiteral(childNode.argument) ) { const operator = childNode.operator switch (operator) { case '+': result = childNode.argument.value break case '-': result = -childNode.argument.value break } } if (result !== null) { // 替換本節點為數字型別 path.replaceWith( t.numericLiteral(result) ) if (path.parentPath) { const parentType = path.parentPath.type if (visitor[parentType]) { visitor[parentType](path.parentPath) } } } }, // 函式執行表示式 CallExpression(path) { const childNode = path.node // 結果 let result = null // 引數的集合 let args = [] // 獲取函式的引數的集合 args = childNode.arguments.map(arg => { if (t.isUnaryExpression(arg)) { return arg.argument.value } }) if ( t.isMemberExpression(childNode.callee) ) { if ( t.isIdentifier(childNode.callee.object) && t.isIdentifier(childNode.callee.property) && childNode.callee.object.name === 'Math' ) { result = Math[childNode.callee.property.name].apply(null, args) } } if (result !== null) { // 替換本節點為數字型別 path.replaceWith( t.numericLiteral(result) ) if (path.parentPath) { const parentType = path.parentPath.type if (visitor[parentType]) { visitor[parentType](path.parentPath) } } } } } module.exports = function () { return { visitor } } 複製程式碼
基本概念
建議先閱讀一下這一篇 文件
babel工作的原理
Babel對程式碼進行轉換,會將JS程式碼轉換為AST抽象語法樹(解析),對樹進行靜態分析(轉換),然後再將語法樹轉換為JS程式碼(生成)。每一層樹被稱為節點。每一層節點都會有type屬性,用來描述節點的型別。其他屬性用來進一步描述節點的型別。
// 將程式碼生成對應的抽象語法樹 // 程式碼 const result = 1 + 1 // 程式碼生成的AST { "type": "Program", "start": 0, "end": 20, "body": [ { "type": "VariableDeclaration", "start": 0, "end": 20, "declarations": [ { "type": "VariableDeclarator", "start": 6, "end": 20, "id": { "type": "Identifier", "start": 6, "end": 12, "name": "result" }, "init": { "type": "BinaryExpression", "start": 15, "end": 20, "left": { "type": "Literal", "start": 15, "end": 16, "value": 1, "raw": "1" }, "operator": "+", "right": { "type": "Literal", "start": 19, "end": 20, "value": 1, "raw": "1" } } } ], "kind": "const" } ], "sourceType": "module" } 複製程式碼
解析
解析分為詞法解析和語法分析, 詞法解析將程式碼字串生成令牌流, 而語法分析則會將令牌流轉換成AST抽象語法樹
轉換
節點的路徑(path)物件上, 會暴露很多新增, 刪除, 修改AST的API, 通過操作這些API實現對AST的修改
生成
生成則是通過對修改後的AST的遍歷, 生成新的原始碼
遍歷
AST是樹形的結構, AST的轉換的步驟就是通過訪問者對AST的遍歷實現的。訪問者會定義處理不同的節點型別的方法。遍歷樹形結構的同時,, 遇到對應的節點型別會執行相對應的方法。
訪問者
Visitors訪問者本身就是一個物件,物件上不同的屬性, 對應著不同的AST節點型別。例如,AST擁有BinaryExpression(二元表示式)型別的節點, 如果在訪問者上定義BinaryExpression屬性名的方法, 則這個方法在遇到BinaryExpression型別的節點, 就會執行, BinaryExpression方法的引數則是該節點的路徑。 注意對每一個節點的遍歷會執行兩次, 進入節點一次, 退出節點一次
const visitors = { enter (path) { // 進入該節點 }, exit (path) { // 退出該節點 } } 複製程式碼
路徑
每一個節點都擁有自身的路徑物件(訪問者的引數, 就是該節點的路徑物件), 路徑物件上定義了不同的屬性和方法。例如: path.node代表了該節點的子節點, path.parent則代表了該節點的父節點。path.replaceWithMultiple方法則定義的是替換該節點的方法。
訪問者中的路徑
節點的路徑資訊, 存在於訪問者的引數中, 訪問者的預設的引數就是節點的路徑物件
第一個外掛
我們來寫一個將 const result = 1 + 1
字串解析為 const result = 2
的簡單外掛。我們首先觀察這段程式碼的AST, 如下。
我們可以看到BinaryExpression型別(二元表示式型別)的節點, 中定義了這段表示式的主體(1 + 1), 1 分別是BinaryExpression節點的子節點left,BinaryExpression節點的子節點right,而加號則是BinaryExpression節點的operator的子節點
// 經過簡化之後 { "type": "Program", "body": [ { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "result" }, "init": { "type": "BinaryExpression", "left": { "type": "Literal", "value": 1 }, "operator": "+", "right": { "type": "Literal", "value": 1 } } } ] } ] } 複製程式碼
接下來我們來處理這個型別的節點,程式碼如下
const t = require('babel-types') const visitor = { BinaryExpression(path) { // BinaryExpression節點的子節點 const childNode = path.node let result = null if ( // isNumericLiteral是babel-types上定義的方法, 用來判斷節點的型別 t.isNumericLiteral(childNode.left) && t.isNumericLiteral(childNode.right) ) { const operator = childNode.operator // 根據不同的操作符, 將left.value, right.value處理為不同的結果 switch (operator) { case '+': result = childNode.left.value + childNode.right.value break case '-': result = childNode.left.value - childNode.right.value break case '/': result = childNode.left.value / childNode.right.value break case '*': result = childNode.left.value * childNode.right.value break } } if (result !== null) { // 計算出結果後 // 將本身的節點,替換為數字型別的節點 path.replaceWith( t.numericLiteral(result) ) } } } 複製程式碼
我們定義一個訪問者, 在上面定義BinaryExpression的屬性的方法。執行結果如我們預期, const result = 1 + 1被處理為了const result = 2。但是我們將程式碼修改為const result = 1 + 2 + 3發現結果變為了 const result = 3 + 3, 這是為什麼呢? 我們來看一下1 + 2 + 3的AST抽象語法樹.
// 經過簡化的AST type: 'BinaryExpression' - left - left - left type: 'Literal' value: 1 - opeartor: '+' - right type: 'Literal' value: 2 - opeartor: '+' - right type: 'Literal' value: 3 複製程式碼
我們上面的程式碼的判斷條件是。t.isNumericLiteral(childNode.left) && t.isNumericLiteral(childNode.right), 在這裡只有最裡層的AST是滿足條件的。因為整個AST結構類似於, (1 + 2) + 3 => (left + rigth) + right。
解決辦法是,將內部的 1 + 2的節點替換成數字節點3之後,將數字節點3的父路徑(parentPath)重新執行BinaryExpression的方法(數字型別的3節點和right節點), 通過遞迴的方式,替換所有的節點。修改後的程式碼如下。
BinaryExpression(path) { const childNode = path.node let result = null if ( t.isNumericLiteral(childNode.left) && t.isNumericLiteral(childNode.right) ) { const operator = childNode.operator switch (operator) { case '+': result = childNode.left.value + childNode.right.value break case '-': result = childNode.left.value - childNode.right.value break case '/': result = childNode.left.value / childNode.right.value break case '*': result = childNode.left.value * childNode.right.value break } } if (result !== null) { // 替換本節點為數字型別 path.replaceWith( t.numericLiteral(result) ) BinaryExpression(path.parentPath) } } 複製程式碼
結果如我們預期, const result = 1 + 2 + 3 可以被正常的解析。但是這個外掛還不具備對Math.abs(), Math.PI, 有符號的數字的處理,我們還需要在訪問者上定義更多的屬性。最後, 對於Math.abs函式的處理可以參考上面的原始碼.