如何用 Babel 為代碼自動引入依賴
2020 年 5 月寫的一篇文章。
前言
最近在嘗試玩一玩已經(jīng)被大家玩膩的?Babel,今天給大家分享如何用?Babel?為代碼自動引入依賴,通過一個簡單的例子入門?Babel?插件開發(fā)。
需求
const?a?=?require('a');
import?b?from?'b';
console.log(axuebin.say('hello?babel'));
同學們都知道,如果運行上面的代碼,一定是會報錯的:
VM105:2?Uncaught?ReferenceError:?axuebin?is?not?defined
我們得首先通過?import axuebin from 'axuebin'?引入?axuebin?之后才能使用。。
為了防止這種情況發(fā)生(一般來說我們都會手動引入),或者為你省去引入這個包的麻煩(其實有些編譯器也會幫我們做了),我們可以在打包階段分析每個代碼文件,把這個事情做了。
在這里,我們就基于最簡單的場景做最簡單的處理,在代碼文件頂部加一句引用語句:
import?axuebin?from?'axuebin';
console.log(axuebin.say('hello?babel'));
前置知識
什么是 Babel
簡單地說,Babel?能夠轉(zhuǎn)譯?ECMAScript 2015+?的代碼,使它在舊的瀏覽器或者環(huán)境中也能夠運行。我們?nèi)粘i_發(fā)中,都會通過?webpack?使用?babel-loader?對?JavaScript?進行編譯。
Babel 是如何工作的
首先得要先了解一個概念:抽象語法樹(Abstract Syntax Tree, AST),Babel?本質(zhì)上就是在操作?AST?來完成代碼的轉(zhuǎn)譯。
了解了?AST?是什么樣的,就可以開始研究?Babel?的工作過程了。
Babel?的功能其實很純粹,它只是一個編譯器。
大多數(shù)編譯器的工作過程可以分為三部分,如圖所示:

Parse(解析)?將源代碼轉(zhuǎn)換成更加抽象的表示方法(例如抽象語法樹) Transform(轉(zhuǎn)換)?對(抽象語法樹)做一些特殊處理,讓它符合編譯器的期望 Generate(代碼生成)?將第二步經(jīng)過轉(zhuǎn)換過的(抽象語法樹)生成新的代碼
所以我們?nèi)绻胍薷?Code,就可以在?Transform?階段做一些事情,也就是操作?AST。
AST 節(jié)點
我們可以看到?AST?中有很多相似的元素,它們都有一個?type?屬性,這樣的元素被稱作節(jié)點。一個節(jié)點通常含有若干屬性,可以用于描述?AST?的部分信息。
比如這是一個最常見的?Identifier?節(jié)點:
{
??type:?'Identifier',
??name:?'add'
}
所以,操作?AST?也就是操作其中的節(jié)點,可以增刪改這些節(jié)點,從而轉(zhuǎn)換成實際需要的?AST。
更多的節(jié)點規(guī)范可以查閱?https://github.com/estree/estree[1]
AST 遍歷
AST?是深度優(yōu)先遍歷的,遍歷規(guī)則不用我們自己寫,我們可以通過特定的語法找到的指定的節(jié)點。
Babel?會維護一個稱作?Visitor?的對象,這個對象定義了用于?AST?中獲取具體節(jié)點的方法。
一個?Visitor?一般是這樣:
const?visitor?=?{
??ArrowFunction(path)?{
????console.log('我是箭頭函數(shù)');
??},
??IfStatement(path)?{
????console.log('我是一個if語句');
??},
??CallExpression(path)?{}
};
visitor?上掛載以節(jié)點?type?命名的方法,當遍歷?AST?的時候,如果匹配上?type,就會執(zhí)行對應的方法。
操作 AST 的例子
通過上面簡單的介紹,我們就可以開始任意造作了,肆意修改?AST?了。先來個簡單的例子熱熱身。
箭頭函數(shù)是?ES5?不支持的語法,所以?Babel?得把它轉(zhuǎn)換成普通函數(shù),一層層遍歷下去,找到了?ArrowFunctionExpression?節(jié)點,這時候就需要把它替換成?FunctionDeclaration?節(jié)點。所以,箭頭函數(shù)可能是這樣處理的:
import?*?as?t?from?"@babel/types";
const?visitor?=?{
??ArrowFunction(path)?{
????path.replaceWith(t.FunctionDeclaration(id,?params,?body));
??}
};
開發(fā) Babel 插件的前置工作
在開始寫代碼之前,我們還有一些事情要做一下:
分析 AST
將原代碼和目標代碼都解析成?AST,觀察它們的特點,找找看如何增刪改?AST?節(jié)點,從而達到自己的目的。
我們可以在?https://astexplorer.net[2]?上完成這個工作,比如文章最初提到的代碼:
const?a?=?require('a');
import?b?from?'b';
console.log(axuebin.say('hello?babel'));
轉(zhuǎn)換成?AST?之后是這樣的:

可以看出,這個?body?數(shù)組對應的就是根節(jié)點的三條語句,分別是:
VariableDeclaration:? const a = require('a')ImportDeclaration:? import b from 'b'ExpressionStatement:? console.log(axuebin.say('hello babel'))
我們可以打開?VariableDeclaration?節(jié)點看看:

它包含了一個?declarations?數(shù)組,里面有一個?VariableDeclarator?節(jié)點,這個節(jié)點有?type、id、init?等信息,其中?id?指的是表達式聲明的變量名,init?指的是聲明內(nèi)容。
通過這樣查看/對比?AST?結(jié)構(gòu),就能分析出原代碼和目標代碼的特點,然后可以開始動手寫程序了。
查看節(jié)點規(guī)范
節(jié)點規(guī)范:https://github.com/estree/estree[3]
我們要增刪改節(jié)點,當然要知道節(jié)點的一些規(guī)范,比如新建一個?ImportDeclaration?需要傳遞哪些參數(shù)。
寫代碼
準備工作都做好了,那就開始吧。
初始化代碼
我們的?index.js?代碼為:
//?index.js
const?path?=?require('path');
const?fs?=?require('fs');
const?babel?=?require('@babel/core');
const?TARGET_PKG_NAME?=?'axuebin';
function?transform(file)?{
??const?content?=?fs.readFileSync(file,?{
????encoding:?'utf8',
??});
??const?{?code?}?=?babel.transformSync(content,?{
????sourceMaps:?false,
????plugins:?[
??????babel.createConfigItem(({?types:?t?})?=>?({
????????visitor:?{
????????}
??????}))
????]
??});
??return?code;
}
然后我們準備一個測試文件?test.js,代碼為:
//?test.js
const?a?=?require('a');
import?b?from?'b';
require('c');
import?'d';
console.log(axuebin.say('hello?babel'));
分析 AST / 編寫對應 type 代碼
我們這次需要做的事情很簡單,做兩件事:
尋找當前? AST?中是否含有引用?axuebin?包的節(jié)點如果沒引用,則修改? AST,插入一個?ImportDeclaration?節(jié)點
我們來分析一下?test.js?的?AST,看一下這幾個節(jié)點有什么特征:
ImportDeclaration 節(jié)點

ImportDeclaration?節(jié)點的?AST?如圖所示,我們需要關(guān)心的特征是?value?是否等于?axuebin, 代碼這樣寫:
if?(path.isImportDeclaration())?{
??return?path.get('source').isStringLiteral()?&&?path.get('source').node.value?===?TARGET_PKG_NAME;
}
其中,可以通過?path.get?來獲取對應節(jié)點的?path,嗯,比較規(guī)范。如果想獲取對應的真實節(jié)點,還需要?.node。
滿足上述條件則可以認為當前代碼已經(jīng)引入了?axuebin?包,不用再做處理了。
VariableDeclaration 節(jié)點

對于?VariableDeclaration?而言,我們需要關(guān)心的特征是,它是否是一個?require?語句,并且?require?的是?axuebin,代碼如下:
/**
?*?判斷是否?require?了正確的包
?*?@param?{*}?node?節(jié)點
?*/
const?isTrueRequire?=?node?=>?{
??const?{?callee,?arguments?}?=?node;
??return?callee.name?===?'require'?&&?arguments.some(item?=>?item.value?===?TARGET_PKG_NAME);
};
if?(path.isVariableDeclaration())?{
??const?declaration?=?path.get('declarations')[0];
??return?declaration.get('init').isCallExpression?&&?isTrueRequire(declaration.get('init').node);
}
ExpressionStatement 節(jié)點

require('c'),語句我們一般不會用到,我們也來看一下吧,它對應的是?ExpressionStatement?節(jié)點,我們需要關(guān)心的特征和?VariableDeclaration?一致,這也是我把?isTrueRequire?抽出來的原因,所以代碼如下:
if?(path.isExpressionStatement())?{
??return?isTrueRequire(path.get('expression').node);
}
插入引用語句
如果上述分析都沒找到代碼里引用了?axuebin,我們就需要手動插入一個引用:
import?axuebin?from?'axuebin';
通過?AST?分析,我們發(fā)現(xiàn)它是一個?ImportDeclaration:

簡化一下就是這樣:
{
??"type":?"ImportDeclaration",
??"specifiers":?[
????"type":?"ImportDefaultSpecifier",
????"local":?{
??????"type":?"Identifier",
??????"name":?"axuebin"
????}
??],
??"source":?{
????"type":?"StringLiteral",
????"value":?"axuebin"
??}
}
當然,不是直接構(gòu)建這個對象放進去就好了,需要通過?babel?的語法來構(gòu)建這個節(jié)點(遵循規(guī)范):
const?importDefaultSpecifier?=?[t.ImportDefaultSpecifier(t.Identifier(TARGET_PKG_NAME))];
const?importDeclaration?=?t.ImportDeclaration(importDefaultSpecifier,?t.StringLiteral(TARGET_PKG_NAME));
path.get('body')[0].insertBefore(importDeclaration);
這樣就插入了一個?import?語句。
Babel Types?模塊是一個用于?AST?節(jié)點的?Lodash?式工具庫,它包含了構(gòu)造、驗證以及變換?AST?節(jié)點的方法。
結(jié)果
我們?node index.js?一下,test.js?就變成:
import?axuebin?from?"axuebin";?//?已經(jīng)自動加在代碼最上邊
const?a?=?require('a');
import?b?from?'b';
require('c');
import?'d';
console.log(axuebin.say('hello?babel'));
彩蛋
如果我們還想幫他再多做一點事,還能做什么呢?
既然都自動引用了,那當然也要自動安裝一下這個包呀!
/**
?*?判斷是否安裝了某個包
?*?@param?{string}?pkg?包名
?*/
const?hasPkg?=?pkg?=>?{
??const?pkgPath?=?path.join(process.cwd(),?`package.json`);
??const?pkgJson?=?fs.existsSync(pkgPath)???fse.readJsonSync(pkgPath)?:?{};
??const?{?dependencies?=?{},?devDependencies?=?{}?}?=?pkgJson;
??return?dependencies[pkg]?||?devDependencies[pkg];
}
/**
?*?通過?npm?安裝包
?*?@param?{string}?pkg?包名
?*/
const?installPkg?=?pkg?=>?{
??console.log(`開始安裝?${pkg}`);
??const?npm?=?shell.which('npm');
??if?(!npm)?{
????console.log('請先安裝?npm');
????return;
??}
??const?{?code?}?=?shell.exec(`${npm.stdout}?install?${pkg}?-S`);
??if?(code)?{
????console.log(`安裝?${pkg}?失敗,請手動安裝`);
??}
};
//?biu~
if?(!hasPkg(TARGET_PKG_NAME))?{
??installPkg(TARGET_PKG_NAME);
}
判斷一個應用是否安裝了某個依賴,有沒有更好的辦法呢?
總結(jié)
我也是剛開始學?Babel,希望通過這個?Babel?插件的入門例子,可以讓大家了解?Babel?其實并沒有那么陌生,大家都可以玩起來 ~
完整代碼見:https://github.com/axuebin/babel-inject-dep-demo[4]
Babel 用戶手冊[5] Babel 插件手冊[6] ast 分析[7] 節(jié)點規(guī)范[8]
參考資料
https://github.com/estree/estree:?https://github.com/estree/estree
[2]?https://astexplorer.net:?https://astexplorer.net
[3]?https://github.com/estree/estree:?https://github.com/estree/estree
[4]?https://github.com/axuebin/babel-inject-dep-demo:?https://github.com/axuebin/babel-inject-dep-demo
[5]?Babel 用戶手冊:?https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/user-handbook.md
[6]?Babel 插件手冊:?https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md
[7]?ast 分析:?https://astexplorer.net/
[8]?節(jié)點規(guī)范:?https://github.com/estree/estree
推薦閱讀
