babel原理&plugin實(shí)戰(zhàn)

點(diǎn)擊上方藍(lán)字關(guān)注我們
本文將講解babel是如何運(yùn)行的,AST的結(jié)構(gòu),以及怎么創(chuàng)建一個(gè)babel的插件。
再講babel之前,先不講babel,AST的這些概念,先帶你實(shí)現(xiàn)一個(gè)簡(jiǎn)易的babel解析器,這樣再回過(guò)頭來(lái)講這些概念就容易理解多了。
tiny-compiler 編譯器
想象一下我們有一些新特性的語(yǔ)法,其中add subtract是普通的函數(shù)名,需要轉(zhuǎn)義到正常的javascript語(yǔ)法,以便讓瀏覽器能夠兼容的運(yùn)行。
(add 2 2)(subtract 4 2)(add 2 (subtract 4 2))
要轉(zhuǎn)義成如下
add(2,?2)subtract(4, 2)add(2,?subtract(4,?2))
編譯器都分為三個(gè)步驟:
Parsing解析Transformation轉(zhuǎn)義Code Generation代碼生成
Parsing 解析
Parsing階段分成兩個(gè)子階段,
Lexical Analysis詞法分析Syntactic Analysis語(yǔ)法分析,先寫(xiě)好我們要轉(zhuǎn)化的代碼
// 這是我們要轉(zhuǎn)化的code(add 2 (subtract 4 2))
Lexical Analysis 詞法分析
Lexical Analysis 詞法分析可以理解為把代碼拆分成最小的獨(dú)立的語(yǔ)法單元,去描述每一個(gè)語(yǔ)法,可以是操作符,數(shù)字,標(biāo)點(diǎn)符號(hào)等,最后生成token數(shù)組。
// 第一步,Lexical Analysis,轉(zhuǎn)化成tokens類似如下[{ type: 'paren', value: '(' },{ type: 'name', value: 'add' },{ type: 'number', value: '2' },{ type: 'paren', value: '(' },{ type: 'name', value: 'subtract' },{ type: 'number', value: '4' },{ type: 'number', value: '2' },{ type: 'paren', value: ')' },{ type: 'paren', value: ')' },]
那我們開(kāi)始實(shí)現(xiàn)它吧,干!
function tokenizer(input) {let current = 0;let tokens = [];while (current < input.length) {let char = input[current];// 處理(if (char === '(') {tokens.push({type: 'paren',value: '(',});current++;continue;}// 處理)if (char === ')') {tokens.push({type: 'paren',value: ')',});current++;continue;}// 處理空白字符let WHITESPACE = /\s/;if (WHITESPACE.test(char)) {current++;continue;}// 處理數(shù)字let NUMBERS = /[0-9]/;if (NUMBERS.test(char)) {let value = '';while (NUMBERS.test(char)) {value += char;char = input[++current];}tokens.push({ type: 'number', value });continue;}// 處理字符串if (char === '"') {let value = '';char = input[++current];while (char !== '"') {value += char;char = input[++current];}char = input[++current];tokens.push({ type: 'string', value });continue;}// 處理函數(shù)名let LETTERS = /[a-z]/i;if (LETTERS.test(char)) {let value = '';while (LETTERS.test(char)) {value += char;char = input[++current];}tokens.push({ type: 'name', value });continue;}// 報(bào)錯(cuò)提示throw new TypeError('I dont know what this character is: ' + char);}return tokens;}
Syntactic Analysis 語(yǔ)法分析
Syntactic Analysis 語(yǔ)法分析就是根據(jù)上一步的tokens數(shù)組轉(zhuǎn)化成語(yǔ)法之前的關(guān)系,這就是Abstract Syntax Tree,也就是我們常說(shuō)的AST。
// 第二步,Syntactic Analysis,轉(zhuǎn)化成AST類似如下{type: 'Program',body: [{type: 'CallExpression',name: 'add',params: [{type: 'NumberLiteral',value: '2',}, {type: 'CallExpression',name: 'subtract',params: [{type: 'NumberLiteral',value: '4',}, {type: 'NumberLiteral',value: '2',}]}]}]}
我們?cè)賮?lái)實(shí)現(xiàn)一個(gè)parser,轉(zhuǎn)化成AST。
function parser(tokens) {let current = 0;function walk() {let token = tokens[current];// 處理數(shù)字if (token.type === 'number') {current++;return {type: 'NumberLiteral',value: token.value,}}// 處理字符串if (token.type === 'string') {current++;return {type: 'StringLiteral',value: token.value,};}// 處理括號(hào)表達(dá)式if (token.type === 'paren' &&token.value === '(') {token = tokens[++current];let node = {type: 'CallExpression',name: token.value,params: [],};token = tokens[++current];while ((token.type !== 'paren') ||(token.type === 'paren' && token.value !== ')')) {node.params.push(walk());token = tokens[current];}current++;return node;}throw new TypeError(token.type);}let ast = {type: 'Program',body: [],};while (current < tokens.length) {ast.body.push(walk());}return ast;}
從上述代碼來(lái)看,跟階段AST是根節(jié)點(diǎn)是type=Program,body是一個(gè)嵌套的AST數(shù)組結(jié)構(gòu)。再單獨(dú)處理了number和string類型之后,再遞歸的調(diào)用walk函數(shù),以解決嵌套的括號(hào)表達(dá)式。
Transformation 轉(zhuǎn)義
traverser 遍歷器
我們最終的目的肯定是想轉(zhuǎn)化成我們想要的代碼,那怎么轉(zhuǎn)化呢?答案就是更改我們剛剛得到的AST結(jié)構(gòu)。
那怎么去改AST呢?直接去操作這個(gè)樹(shù)結(jié)構(gòu)肯定是不現(xiàn)實(shí)的,所以我們需要遍歷這個(gè)AST,利用深度優(yōu)先遍歷的方法遍歷這些節(jié)點(diǎn),當(dāng)遍歷到某個(gè)節(jié)點(diǎn)時(shí),再去調(diào)用這個(gè)節(jié)點(diǎn)對(duì)應(yīng)的方法,再方法里面改變這些節(jié)點(diǎn)的值就輕而易舉了。
想象一下我們有這樣的一個(gè)visitor,就是上文說(shuō)道的遍歷時(shí)調(diào)用的方法
var visitor = {NumberLiteral: {enter(node, parent) { },exit(node, parent) { },}};
由于深度優(yōu)先遍歷的特性,我們遍歷到一個(gè)節(jié)點(diǎn)時(shí)有enter和exit的概念,代表著遍歷一些類似于CallExpression這樣的節(jié)點(diǎn)時(shí),這個(gè)語(yǔ)句,enter表示開(kāi)始解析,exit表示解析完畢。比如說(shuō)上文中:
* -> Program (enter)* -> CallExpression (enter)* -> Number Literal (enter)* <- Number Literal (exit)* -> Call Expression (enter)* -> Number Literal (enter)* <- Number Literal (exit)* -> Number Literal (enter)* <- Number Literal (exit)* <- CallExpression (exit)* <- CallExpression (exit)* <- Program (exit)
然后有一個(gè)函數(shù),接受ast和vistor作為參數(shù),實(shí)現(xiàn)遍歷,類似于:
traverse(ast, {CallExpression: {enter(node, parent) {// ...},exit(node, parent) {// ...},}})
先實(shí)現(xiàn)traverser吧。
function traverser(ast, visitor) {// 遍歷一個(gè)數(shù)組節(jié)點(diǎn)function traverseArray(array, parent) {array.forEach(child => {traverseNode(child, parent);});}// 遍歷節(jié)點(diǎn)function traverseNode(node, parent) {let methods = visitor[node.type];// 先執(zhí)行enter方法if (methods && methods.enter) {methods.enter(node, parent);}switch (node.type) {// 一開(kāi)始節(jié)點(diǎn)的類型是Program,去接著解析body字段case 'Program':traverseArray(node.body, node);break;// 當(dāng)節(jié)點(diǎn)類型是CallExpression,去解析params字段case 'CallExpression':traverseArray(node.params, node);break;// 數(shù)字和字符串沒(méi)有子節(jié)點(diǎn),直接執(zhí)行enter和exit就好case 'NumberLiteral':case 'StringLiteral':break;// 容錯(cuò)處理default:throw new TypeError(node.type);}// 后執(zhí)行exit方法if (methods && methods.exit) {methods.exit(node, parent);}}// 開(kāi)始從根部遍歷traverseNode(ast, null);}
transformer 轉(zhuǎn)換器
有了traverser遍歷器后,就開(kāi)始遍歷吧,先看看前后兩個(gè)AST的對(duì)比。
* ----------------------------------------------------------------------------* Original AST | Transformed AST* ----------------------------------------------------------------------------* { | {* type: 'Program', | type: 'Program',* body: [{ | body: [{* type: 'CallExpression', | type: 'ExpressionStatement',* name: 'add', | expression: {* params: [{ | type: 'CallExpression',* type: 'NumberLiteral', | callee: {* value: '2' | type: 'Identifier',* }, { | name: 'add'* type: 'CallExpression', | },* name: 'subtract', | arguments: [{* params: [{ | type: 'NumberLiteral',* type: 'NumberLiteral', | value: '2'* value: '4' | }, {* }, { | type: 'CallExpression',* type: 'NumberLiteral', | callee: {* value: '2' | type: 'Identifier',* }] | name: 'subtract'* }] | },* }] | arguments: [{* } | type: 'NumberLiteral',* | value: '4'* ---------------------------------- | }, {* | type: 'NumberLiteral',* | value: '2'* | }]* (sorry the other one is longer.) | }* | }* | }]* | }* ----------------------------------------------------------------------------?*/
這里注意多了一中ExpressionStatement的type,以表示subtract(4, 2)這樣的結(jié)構(gòu)。
遍歷的過(guò)程就是把左側(cè)AST轉(zhuǎn)化成右側(cè)AST。
function?transformer(ast)?{let newAst = {type: 'Program',body: [],};// 給節(jié)點(diǎn)一個(gè)_context,讓遍歷到子節(jié)點(diǎn)時(shí)可以push內(nèi)容到parent._context中ast._context = newAst.body;traverser(ast, {CallExpression: {enter(node, parent) {let expression = {type: 'CallExpression',callee: {type: 'Identifier',name: node.name,},arguments: [],};// 讓子節(jié)點(diǎn)可以push自己到expression.arguments中node._context = expression.arguments;// 如果父節(jié)點(diǎn)不是CallExpression,則外層包裹一層ExpressionStatementif (parent.type !== 'CallExpression') {expression = {type: 'ExpressionStatement',expression: expression,};}parent._context.push(expression);}},NumberLiteral: {enter(node, parent) {parent._context.push({type: 'NumberLiteral',value: node.value,});}},StringLiteral: {enter(node, parent) {parent._context.push({type: 'StringLiteral',value: node.value,});},},});return newAst;}
CodeGeneration 代碼生成
那最后一個(gè)階段就是用心生成的AST生成我們最后的代碼了,也是生成AST的一個(gè)反過(guò)程。
function codeGenerator(node) {switch (node.type) {// 針對(duì)于Program,處理其中的body屬性,依次再遞歸調(diào)用codeGeneratorcase 'Program':return node.body.map(codeGenerator).join('\n');// 針對(duì)于ExpressionStatement,處理其中的expression屬性,再后面添加一個(gè)分號(hào)case 'ExpressionStatement':return (codeGenerator(node.expression) +';');// 針對(duì)于CallExpression,左側(cè)處理callee,括號(hào)中處理arguments數(shù)組case 'CallExpression':return (codeGenerator(node.callee) +'(' +node.arguments.map(codeGenerator).join(', ') +')');// 直接返回namecase 'Identifier':return node.name;// 返回?cái)?shù)字的valuecase 'NumberLiteral':return node.value;// 字符串類型添加雙引號(hào)case 'StringLiteral':return '"' + node.value + '"';// 容錯(cuò)處理default:throw new TypeError(node.type);}}
總結(jié)
這樣我們一個(gè)tiny-compiler就寫(xiě)好了,最后可以執(zhí)行下面的代碼去試試?yán)病?/p>
function compiler(input) {let tokens = tokenizer(input);let ast = parser(tokens);let newAst = transformer(ast);let output = codeGenerator(newAst);return output;}
從上述代碼中就可以看出來(lái),一個(gè)代碼轉(zhuǎn)化的過(guò)程就把包括了tokenizer詞法分析階段,parser預(yù)發(fā)分析階段(AST生成),transformer轉(zhuǎn)義階段,codeGenerator代碼生成階段。那么在寫(xiě)babel-plugin的時(shí)候,其實(shí)就是在寫(xiě)其中的transformer,其他的部分已經(jīng)被babel完美的實(shí)現(xiàn)了。
babel plugin 概念
先上手看一個(gè)簡(jiǎn)單的babel plugin示例
module.exports = function ({ types: t }) {const TRUE = t.unaryExpression("!", t.numericLiteral(0), true);const FALSE = t.unaryExpression("!", t.numericLiteral(1), true);return {visitor: {BooleanLiteral(path) {path.replaceWith(path.node.value ? TRUE : FALSE)}},};}
這個(gè)plugin造成的效果:
//?源代碼const x = true;// 轉(zhuǎn)義后的的代碼const x = !0;
就是把所有的bool類型的值轉(zhuǎn)化成 !0 或者 !1,這是代碼壓縮的時(shí)候使用的一個(gè)技巧。
那么逐行來(lái)分析這個(gè)簡(jiǎn)單的plugin。一個(gè)plugin就是一個(gè)function,入?yún)⒕褪莃abel對(duì)象,這里利用到了babel中types對(duì)象,來(lái)自于@babel/types這個(gè)庫(kù),然后操作path對(duì)象進(jìn)行節(jié)點(diǎn)替換操作。
path
path是肯定會(huì)用到的一個(gè)對(duì)象。我們可以用過(guò)path訪問(wèn)到當(dāng)前節(jié)點(diǎn),父節(jié)點(diǎn),也可以去調(diào)用添加、更新、移動(dòng)和刪除節(jié)點(diǎn)有關(guān)的其他很多方法。舉幾個(gè)示例
// 訪問(wèn)當(dāng)前節(jié)點(diǎn)的屬性,用path.node.property訪問(wèn)node的屬性path.node.nodepath.node.left// 直接改變當(dāng)前節(jié)點(diǎn)的屬性path.node.name = "x";// 當(dāng)前節(jié)點(diǎn)父節(jié)點(diǎn)path.parent// 當(dāng)前節(jié)點(diǎn)的父節(jié)點(diǎn)的pathpath.parentPath// 訪問(wèn)節(jié)點(diǎn)內(nèi)部屬性path.get('left')// 刪除一個(gè)節(jié)點(diǎn)path.remove();// 替換一個(gè)節(jié)點(diǎn)path.replaceWith();// 替換成多個(gè)節(jié)點(diǎn)path.replaceWithMultiple();// 插入兄弟節(jié)點(diǎn)path.insertBefore();path.insertAfter();// 跳過(guò)子節(jié)點(diǎn)的遍歷path.skip();// 完全跳過(guò)遍歷path.stop();
@babel/types
可以理解它為一個(gè)工具庫(kù),類似于Lodash,里面封裝了非常多的幫做方法,一般用處如下
檢查節(jié)點(diǎn) 一般在類型前面加
is就是判斷是否該類型
//?判斷當(dāng)前節(jié)點(diǎn)的left節(jié)點(diǎn)是否是identifier類型if (t.isIdentifier(path.node.left)) {// ...?}// 判斷當(dāng)前節(jié)點(diǎn)的left節(jié)點(diǎn)是否是identifer類型,并且name='n'if (t.isIdentifier(path.node.left, { name: "n" })) {// ...}// 上述判斷等價(jià)于if (path.node.left != null &&path.node.left.type === "Identifier" &&path.node.left.name === "n") {// ...??}
構(gòu)建節(jié)點(diǎn)
直接手寫(xiě)復(fù)雜的AST結(jié)構(gòu)是不現(xiàn)實(shí)的,所以有了一些幫助方法去構(gòu)建這些節(jié)點(diǎn),示例:
//?調(diào)用binaryExpression和identifier的構(gòu)建方法,生成astt.binaryExpression("*", t.identifier("a"), t.identifier("b"));// 生成如下{type: "BinaryExpression",operator: "*",left: {type: "Identifier",name: "a"},right: {type: "Identifier",name: "b"}}// 最后經(jīng)過(guò)AST反轉(zhuǎn)回來(lái)如下a * b
其中每一種節(jié)點(diǎn)都有自己的構(gòu)造方法,都有自己特定的入?yún)ⅲ敿?xì)請(qǐng)參考官方文檔
scope
最后講一下作用域的概念,每一個(gè)函數(shù),每一個(gè)變量都有自己的作用域,在編寫(xiě)babel plugin的時(shí)候要特別小心,再改變或者添加代碼的時(shí)候要注意不要破壞了原有的代碼結(jié)構(gòu)。
用path.scope中的一些方法可以操作作用域,示例:
//?檢查變量n是否被綁定(是否在上下文已經(jīng)有引用)path.scope.hasBinding("n")// 檢查自己內(nèi)部是否有引用npath.scope.hasOwnBinding("n")// 創(chuàng)建一個(gè)上下文中新的引用 生成類似于{ type: 'Identifier', name: '_n2' }path.scope.generateUidIdentifier("n");// 重命名當(dāng)前的引用path.scope.rename("n", "x");
plugin實(shí)戰(zhàn)
寫(xiě)一個(gè)自定義plugin是什么步驟呢?
這個(gè)plugin用來(lái)干嘛
源代碼的AST
轉(zhuǎn)換后代碼的AST
tip: 可以去這個(gè)網(wǎng)站查看代碼的AST。
plugin的目的
現(xiàn)在就做一個(gè)自定義的plugin,大家在應(yīng)用寫(xiě)代碼的時(shí)候可以通過(guò)webpack配置alias,比如說(shuō)配置@ -> ./src,這樣import的時(shí)候就直接從src目錄下找所需要的代碼了,那么大家有在寫(xiě)組件的時(shí)候用過(guò)這個(gè)功能嗎?這就是我們這個(gè)plugin的目的。
代碼
我們有如下配置
"alias":?{"@": "./src"}
源代碼以及要轉(zhuǎn)化的代碼如下:
//?./src/index.jsimport?add?from?'@/common';?//?->?import?add?from?"./common";// ./src/foo/test/index.jsimport?add?from?'@/common';?//?->??import?add?from?"../../common";
AST
源碼的AST展示如下

那我們看見(jiàn)是不是只需要找到ImportDeclaration節(jié)點(diǎn)中將source改成轉(zhuǎn)換之后的代碼是不是就可以了。
開(kāi)始寫(xiě)plugin
const?localPath?=?require('path');module.exports = function ({ types: t }) {return {visitor: {ImportDeclaration(path, state) {// 從state中拿到外界傳進(jìn)的參數(shù),這里我們外界設(shè)置了aliasconst { alias } = state.opts;if (!alias) {return;}// 拿到當(dāng)前文件的信息const { filename, root } = state.file.opts;// 找到相對(duì)地址const relativePath = localPath.relative(root, localPath.dirname(filename));// 利用path獲取當(dāng)前節(jié)點(diǎn)中source的value,這里對(duì)應(yīng)的就是 '@/common'了let importSource = path.node.source.value;// 遍歷我們的配置,進(jìn)行關(guān)鍵字替換Object.keys(alias).forEach(key => {const reg = new RegExp(`^${key}`);if (reg.test(importSource)) {importSource = importSource.replace(reg, alias[key]);importSource = localPath.relative(relativePath, importSource)}})// 利用t.StringLiteral構(gòu)建器構(gòu)建一個(gè)StringLiteral類型的節(jié)點(diǎn),賦值給sourcepath.node.source = t.StringLiteral(importSource)}},};}
用plugin
回到我們的babel配置文件中來(lái),這里我們用的是babel.config.json
{"plugins": [[// 這里使用本地的文件當(dāng)做plugin,實(shí)際上可以把自己制作的plugin發(fā)布成npm包供大家使用"./plugin.js",// 傳配置到plugin的第二個(gè)參數(shù)state.opts中{"alias": {"@": "./src"}}]]}
這樣一個(gè)plugin的流程就走完了,歡迎大家多多交流。
推薦閱讀
