最近在一些項目編譯系統的工作中涉及到了很多關於babel插件的開發,關於babel大多數人的感受可能是既陌生又熟悉,可能大多數人對於babel的應用場景的認識就是在webpack中使用一個babel-loader,但當你真正了解他掌握它的時候,會發現他其實還有些更強的用法。。。
基本概念
babel是什么?
Babel 是一個編譯器(輸入源碼 => 輸出編譯后的代碼)。就像其他編譯器一樣,編譯過程分為三個階段:解析、轉換和打印輸出。(官網的解釋)。
babel plugin和babel preset是什么?
babel中有很多概念,比如:插件(plugin),預設(preset)和一些比較基礎的工具(例如@babel/parser,@babel/traverse等等)。關於他們的關系,可以理解為babel的plugin構建在基礎工具之上,而babel的preset是多個babel plugin的打包集合,例如我們所熟悉的@babel/preset-env,@babel/preset-react。
babel深入
本篇文章不對babel官方的plugin,preset庫做過多闡述,畢竟這是一篇深入教程。我們要提的是一個更本質的問題:babel是如何轉譯代碼的?
我們大體上把這個轉譯代碼的過程分為三步:
- 第一步(parse):code=>ast
- 第二步(transform):ast=>修改過的ast
- 第三步(generate):修改過的ast=>編譯后的code
這三步分別對應babel的三個基本工具,第一步對應@babel/parser,第二步對應@babel/traverse,第三步對應@babel/generator。下面就來詳述一下這三個過程。
parse(@babel/parser)
這一步是babel將code轉化為ast。ast是Abstract syntax tree的縮寫,即抽象語法樹,單說抽象語法樹可能不太好理解,我們可以先來看一下一個具體的例子,你可以使用https://astexplorer.net/來幫你運行@babel/parser:
function mirror(something) { return something }
被轉譯成ast:
{ "type": "File", "start": 0, "end": 49, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 3, "column": 1 } }, "program": { "type": "Program", "start": 0, "end": 49, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 3, "column": 1 } }, "sourceType": "module", "interpreter": null, "body": [ { "type": "FunctionDeclaration", "start": 0, "end": 49, "loc": { "start": { "line": 1, "column": 0 }, "end": { "line": 3, "column": 1 } }, "id": { "type": "Identifier", "start": 9, "end": 15, "loc": { "start": { "line": 1, "column": 9 }, "end": { "line": 1, "column": 15 }, "identifierName": "mirror" }, "name": "mirror" }, "generator": false, "async": false, "params": [ { "type": "Identifier", "start": 16, "end": 25, "loc": { "start": { "line": 1, "column": 16 }, "end": { "line": 1, "column": 25 }, "identifierName": "something" }, "name": "something" } ], "body": { "type": "BlockStatement", "start": 27, "end": 49, "loc": { "start": { "line": 1, "column": 27 }, "end": { "line": 3, "column": 1 } }, "body": [ { "type": "ReturnStatement", "start": 31, "end": 47, "loc": { "start": { "line": 2, "column": 2 }, "end": { "line": 2, "column": 18 } }, "argument": { "type": "Identifier", "start": 38, "end": 47, "loc": { "start": { "line": 2, "column": 9 }, "end": { "line": 2, "column": 18 }, "identifierName": "something" }, "name": "something" } } ], "directives": [] } } ], "directives": [] }, "comments": [] }
乍一看似乎很復雜,但是你要做的是從中找到關鍵信息,我們將當中影響閱讀的字段去除(去除loc,start,end,以及函數體外層的嵌套):
{ "type": "FunctionDeclaration", "id": { "type": "Identifier", "name": "mirror" }, "generator": false, "async": false, "params": [ { "type": "Identifier", "name": "something" } ], "body": { "type": "BlockStatement", "body": [ { "type": "ReturnStatement", "argument": { "type": "Identifier", "name": "something" } } ], "directives": [] } }
這樣是不是簡單很多!我們看一下這個json描述了什么:外層是一個叫mirror的函數聲明,他的傳參有一個,叫something,函數體內部return了一個叫something的變量。我們把這個描述與上邊的js代碼對照着看,竟然不謀而合(其實從這一點也能看出code<=>ast這個過程是可逆的)。對於初學者而言,上邊的抽象語法樹難以理解的可能是這些名字冗長的節點type,下邊簡單列舉一下js中的常見的節點名稱(慎看,可以選擇性跳過,但是了解這些節點名稱可以加深你對babel甚至js語言本身的理解)。詳見https://babeljs.io/docs/en/babel-types
FunctionDeclaration(函數聲明) function a() {} FunctionExpression(函數表達式) var a = function() {} ArrowFunctionExpression(箭頭函數表達式) ()=>{}(此處可以思考:為什么沒有箭頭函數聲明,以及Declaration和Expression的區別) AwaitExpression(await表達式) async function a () { await b() } CallExpression(調用表達式) a() MemberExpression(成員表達式) a.b VariableDeclarator(變量聲明) var,const,let(var,const,let用Node中的kind區分) Identifier(變量標識符) var a(這里a是一個Identifier) NumericLiteral(數字字面量) var a = 1 StringLiteral(字符串字面量) var a = 'a' BooleanLiteral(布爾值字面量) var a = true NullLiteral(null字面量) var a = null(此處可以思考:為什么沒有undefined字面量) BlockStatement(塊) {} ArrayExpression(數組表達式) [] ObjectExpression(對象表達式) var a = {} SpreadElement(擴展運算符) {...a},[...a] ObjectProperty(對象屬性) {a:1}(這里的a:1是一個ObjectProperty) ObjectMethod(函數屬性) {a(){}} ExpressionStatement(表達式語句) a() IfStatement(if) if () {} ForStatement(for) for (;;){} ForInStatement(for in) for (a in b) {} ForOfStatement(for of) for (a of b) {} ImportDeclaration(import聲明) import 'a' ImportDefaultSpecifier(import default說明符) import a from 'a' ImportSpecifier(import說明符) import {a} from 'a' NewExpression(new表達式) new A() ClassDeclaration(class聲明) class A {} ClassBody(class body) class A {}(類的內部)
常見的列舉的差不多了。。。就先寫到這吧。
generate(@babel/generator)
generate本來應該是第三步,為什么將第三步放到這里呢?因為他比較簡單,而且當我們使用traverse時,需要用到它。在這里我們簡單的把一段code轉換為ast,再轉換為code:
先安裝好依賴。這一點以后不再贅述
yarn add @babel/parser @babel/generator const parser = require('@babel/parser') const generate = require('@babel/generator').default const code = `function mirror(something) { return something }` const ast = parser.parse(code, { sourceType: 'module', }) const transformedCode = generate(ast).code console.log(transformedCode)
- 結果:
function mirror(something) { return something; }
這就是generator的基本用法,詳細參照https://babeljs.io/docs/en/babel-generator
transform(@babel/traverse,@babel/types,@babel/template)
到了最為關鍵的transform步驟了,這里的主角是@babel/traverse,@babel/types和@babel/template是輔助工具。我們首先來談一下visitor這個概念。
visitor
- visitor是什么
訪問者是一個用於 AST 遍歷的跨語言的模式。 簡單的說它們就是一個對象,定義了用於在一個樹狀結構中獲取具體節點的方法。
假如你這樣寫了一個visitor傳遞給babel:
const visitor = { Identifier () { enter () { console.log('Hello Identifier!') }, exit () { console.log('Bye Identifier!') } } }
那么babel會使用他的遞歸遍歷器去遍歷整棵ast,在進入和退出Identifier節點時,會執行我們定義的函數。
2.在一般情況下exit較少使用,所以可以簡寫成:
const visitor = { Identifier () { console.log('Hello Identifier!') } }
3.如有必要,你還可以把方法名用|分割成a節點類型|b節點類型形式的字符串,把同一個函數應用到多種訪問節點。
const visitor = { 'FunctionExpression|ArrowFunctionExpression' () { console.log('A function expression or a arrow function expression!') } }
好了,現在以上邊的mirror函數為例,來動手寫一個traverse的簡單示例吧:
const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const code = `function mirror(something) { return something }` const ast = parser.parse(code, { sourceType: 'module', }) const visitor = { Identifier (path) { console.log(path.node.name) } } traverse(ast, visitor)
- 結果:mirror,something,something
與你的預估是否一致呢?如果一致,那我們可以繼續往下。此處你可能提出疑問:這個path是什么?
path
可以簡單地認為path是對當前訪問的node的一層包裝。例如使用path.node可以訪問到當前的節點,使用path.parent可以訪問到父節點,這里列出了path所包含的內容(尚未列出path中所包含的一些方法)。
{ "parent": {...}, "node": {...}, "hub": {...}, "contexts": [], "data": {}, "shouldSkip": false, "shouldStop": false, "removed": false, "state": null, "opts": null, "skipKeys": null, "parentPath": null, "context": null, "container": null, "listKey": null, "inList": false, "parentKey": null, "key": null, "scope": null, "type": null, "typeAnnotation": null }
當你有一個 Identifier() 成員方法的訪問者時,你實際上是在訪問路徑而非節點。 通過這種方式,你操作的就是節點的響應式表示(譯注:即路徑)而非節點本身。 babel handbook
path中還提供了一系列的工具函數,例如traverse(在當前path下執行遞歸),remove(刪除當前節點),replaceWith(替換當前節點)等等。
解釋完了path之后,我們試着真正的來轉換一下代碼吧,在這里使用了@babel/generator來將ast轉換為code
const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const generate = require('@babel/generator').default const code = `function mirror(something) { return something }` const ast = parser.parse(code, { sourceType: 'module', }) const visitor = { Identifier (path) { path.node.name = path.node.name.split('').reverse().join('') } } traverse(ast, visitor) const transformedCode = generate(ast).code console.log(transformedCode)
- 結果:
function rorrim(gnihtemos) { return gnihtemos; }
這段代碼應該不難理解,就是將所有的變量做了個字符串翻轉。是不是事情已經變得有趣起來了?
@babel/types
Babel Types模塊是一個用於 AST 節點的 Lodash 式工具庫(譯注:Lodash 是一個 JavaScript 函數工具庫,提供了基於函數式編程風格的眾多工具函數), 它包含了構造、驗證以及變換 AST 節點的方法。 該工具庫包含考慮周到的工具方法,對編寫處理AST邏輯非常有用。(依然是handbook原話)
展示一下最常用的使用方式,用來判斷節點的類型
const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const t = require('@babel/types') const code = `function mirror(something) { return something }` const ast = parser.parse(code, { sourceType: 'module', }) const visitor = { enter(path) { if (t.isIdentifier(path.node)) { console.log('Identifier!') } } } traverse(ast, visitor)
- 結果:Identifier! Identifier! Identifier!
@babel/types還可以用來生成節點,結合上邊的知識,我們試着改動mirror函數的返回值
const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const generate = require('@babel/generator').default const t = require('@babel/types') const code = `function mirror(something) { return something }` const ast = parser.parse(code, { sourceType: 'module', }) const strNode = t.stringLiteral('mirror') const visitor = { ReturnStatement (path) { path.traverse({ Identifier(cpath){ cpath.replaceWith(strNode) } }) } } traverse(ast, visitor) const transformedCode = generate(ast).code console.log(transformedCode)
- 結果:
function mirror(something) { return "mirror"; }
在這里我們用到了t.stringLiteral('mirror')去創建一個字符串字面量節點,然后遞歸遍歷ReturnStatement下的Identifier,並將其替換成我們所創建的字符串字面量節點(注意此處我們已經開始使用了一些path下的公共方法)。
@babel/template
使用@babel/type創建一些簡單節點會很容易,但是如果是大段代碼的話就會變得困難了,這個時候我們可以使用@babel/template。下面寫了一個簡單示例,為mirror函數內部寫了一些邏輯判斷。
const parser = require('@babel/parser') const traverse = require('@babel/traverse').default const generate = require('@babel/generator').default const template = require('@babel/template').default const t = require('@babel/types') const code = `function mirror(something) { return something }` const ast = parser.parse(code, { sourceType: 'module', }) const visitor = { FunctionDeclaration(path) { // 在這里聲明了一個模板,比用@babel/types去生成方便很多 const temp = template(` if(something) { NORMAL_RETURN } else { return 'nothing' } `) const returnNode = path.node.body.body[0] const tempAst = temp({ NORMAL_RETURN: returnNode }) path.node.body.body[0] = tempAst } } traverse(ast, visitor) const transformedCode = generate(ast).code console.log(transformedCode)
- 結果:
function mirror(something) { if (something) { return something; } else { return 'nothing'; } }
完美!以上,babel基本的工具使用方式就介紹的差不多了,下邊步入正題:嘗試寫一個babel插件。
寫一個babel插件
其實到這里,編寫一個babel插件已經非常簡單了,我們嘗試直接將上邊的代碼移植成為一個babel插件
module.exports = function (babel) { const { types: t, template } = babel const visitor = { FunctionDeclaration(path) { const temp = template(` if(something) { NORMAL_RETURN } else { return 'nothing' } `) const returnNode = path.node.body.body[0] const tempAst = temp({ NORMAL_RETURN: returnNode }) path.node.body.body[0] = tempAst } } return { name: 'my-plugin', visitor } }
babel插件暴露了一個函數,函數的傳參是babel,你可以使用解構賦值獲取到types,template這些工具。函數返回值中包含一個name和一個visitor,name是插件的名稱,visitor就是我們上邊多次編寫的visitor。
你可能注意到了一些babel插件是可以傳參的,那我們如何在babel插件中接收參數呢
module.exports = function (babel) { const { types: t, template } = babel const visitor = { FunctionDeclaration(path, state) { const temp = template(` if(something) { NORMAL_RETURN } else { return '${state.opts.whenFalsy}' } `) const returnNode = path.node.body.body[0] const tempAst = temp({ NORMAL_RETURN: returnNode }) path.node.body.body[0] = tempAst } } return { name: 'my-plugin', visitor } }
在上邊的例子中我們看到在visitor中可以傳入第二個參數state,在這個state中,使用state.opts[配置名]就可訪問到用戶所傳遞的對應配置名的值
如何測試你所編寫的babel插件是可以使用的呢?引用你所編寫的插件並測試一下:
const babel = require("@babel/core") const code = `function mirror(something) { return something }` const res = babel.transformSync(code, { plugins: [ [require('你編寫的插件地址'), { whenFalsy: 'Nothing really.' }] ] }) console.log(res.code)
- 結果:
function mirror(something) { if (something) { return something; } else { return 'Nothing really.'; } }
以上,我們基本對babel的原理有了一個基本的認識,並且可以自己寫出一個babel插件了。至於如何將babel的威力發揮在日常的工作中呢?就需要各位去自行探索了。
喜歡這篇文章?歡迎打賞~~