國慶放假了,我還在利用碎片時間在寫文章,不知道長假還有沒有人看,試試水吧!css
這個文章系列將帶你們深刻淺出 Babel
, 這個系列將分爲上下兩篇:上篇主要介紹 Babel 的架構和原理,順便實踐一下插件開發的;下篇會介紹 `babel-plugin-macros , 利用它來寫屬於 Javascript 的’宏‘,前端
✨滿滿的乾貨,不容錯過哦. 寫文不易,點贊是最大的鼓勵。node
注意: 本文不是 Babel 的基礎使用教程!若是你對 Babel 尚不瞭解,請查看官方網站, 或者這個用戶手冊react
文章下篇已經更新:深刻淺出 Babel 下篇:既生 Plugin 何生 Macros 有點冷清,贊起來。歡迎轉載,讓更多人看到個人文章,轉載請註明出處git
文章大綱github
上圖是 Babel 的處理流程, 若是讀者學習過編譯器原理
,這個過程就至關親切了.shell
首先從源碼 解析(Parsing)
開始,解析包含了兩個步驟:express
1️⃣詞法解析(Lexical Analysis): 詞法解析器(Tokenizer)
在這個階段將字符串形式的代碼轉換爲Tokens(令牌)
. Tokens 能夠視做是一些語法片斷組成的數組. 例如for (const item of items) {}
詞法解析後的結果以下:設計模式
從上圖能夠看,每一個 Token 中包含了語法片斷、位置信息、以及一些類型信息. 這些信息有助於後續的語法分析。數組
2️⃣語法解析(Syntactic Analysis):這個階段語法解析器(Parser)
會把Tokens
轉換爲抽象語法樹(Abstract Syntax Tree,AST)
什麼是AST?
它就是一棵'對象樹',用來表示代碼的語法結構,例如console.log('hello world')
會解析成爲:
Program
、CallExpression
、Identifier
這些都是節點的類型,每一個節點都是一個有意義的語法單元。 這些節點類型定義了一些屬性來描述節點的信息。
JavaScript的語法愈來愈複雜,並且 Babel 除了支持最新的JavaScript規範語法, 還支持 JSX
、Flow
、如今還有Typescript
。想象一下 AST 的節點類型有多少,其實咱們不須要去記住這麼多類型、也記不住. 插件開發者會利用 ASTExplorer
來審查解析後的AST樹, 很是強大👍。
AST 是 Babel 轉譯的核心數據結構,後續的操做都依賴於 AST。
接着就是**轉換(Transform)**了,轉換階段會對 AST 進行遍歷,在這個過程當中對節點進行增刪查改。Babel 全部插件都是在這個階段工做, 好比語法轉換、代碼壓縮。
Javascript In Javascript Out, 最後階段仍是要把 AST 轉換回字符串形式的Javascript,同時這個階段還會生成Source Map。
我在《透過現象看本質: 常見的前端架構風格和案例🔥》 說起 Babel
和 Webpack
爲了適應複雜的定製需求和頻繁的功能變化,都使用了微內核 的架構風格。也就是說它們的核心很是小,大部分功能都是經過插件擴展實現的。
因此簡單地瞭解一下 Babel 的架構和一些基本概念,對後續文章內容的理解, 以及Babel的使用仍是有幫助的。
一圖勝千言。仔細讀過我文章的朋友會發現,個人風格就是能用圖片說明的就不用文字、能用文字的就不用代碼。雖然個人原創文章篇幅都很長,圖片仍是值得看看的。
Babel 是一個 MonoRepo
項目, 不過組織很是清晰,下面就源碼上咱們能看到的模塊進行一下分類, 配合上面的架構圖讓你對Babel有個大概的認識:
1️⃣ 核心:
@babel/core
這也是上面說的‘微內核’架構中的‘內核’。對於Babel來講,這個內核主要幹這些事情:
Parser
進行語法解析,生成 AST
Traverser
遍歷AST,並使用訪問者模式
應用'插件'對 AST 進行轉換2️⃣ 核心周邊支撐
Parser(@babel/parser
): 將源代碼解析爲 AST 就靠它了。 它已經內置支持不少語法. 例如 JSX、Typescript、Flow、以及最新的ECMAScript規範。目前爲了執行效率,parser是不支持擴展的,由官方進行維護。若是你要支持自定義語法,能夠 fork 它,不過這種場景很是少。
Traverser(@babel/traverse
): 實現了訪問者模式
,對 AST 進行遍歷,轉換插件
會經過它獲取感興趣的AST節點,對節點繼續操做, 下文會詳細介紹訪問器模式
。
Generator(@babel/generator
): 將 AST 轉換爲源代碼,支持 SourceMap
3️⃣ 插件
打開 Babel 的源代碼,會發現有好幾種類型的‘插件’。
語法插件(@babel/plugin-syntax-*
):上面說了 @babel/parser
已經支持了不少 JavaScript 語法特性,Parser也不支持擴展. 所以plugin-syntax-*
實際上只是用於開啓或者配置Parser的某個功能特性。
通常用戶不須要關心這個,Transform 插件裏面已經包含了相關的plugin-syntax-*
插件了。用戶也能夠經過parserOpts
配置項來直接配置 Parser
轉換插件: 用於對 AST 進行轉換, 實現轉換爲ES5代碼、壓縮、功能加強等目的. Babel倉庫將轉換插件劃分爲兩種(只是命名上的區別):
@babel/plugin-transform-*
: 普通的轉換插件@babel/plugin-proposal-*
: 還在'提議階段'(非正式)的語言特性, 目前有這些預約義集合(@babel/presets-*
): 插件集合或者分組,主要方便用戶對插件進行管理和使用。好比preset-env
含括全部的標準的最新特性; 再好比preset-react
含括全部react相關的插件.
4️⃣ 插件開發輔助
@babel/template
: 某些場景直接操做AST太麻煩,就好比咱們直接操做DOM同樣,因此Babel實現了這麼一個簡單的模板引擎,能夠將字符串代碼轉換爲AST。好比在生成一些輔助代碼(helper)時會用到這個庫
@babel/types
: AST 節點構造器和斷言. 插件開發時使用很頻繁
@babel/helper-*
: 一些輔助器,用於輔助插件開發,例如簡化AST操做
@babel/helper
: 輔助代碼,單純的語法轉換可能沒法讓代碼運行起來,好比低版本瀏覽器沒法識別class關鍵字,這時候須要添加輔助代碼,對class進行模擬。
5️⃣ 工具
@babel/node
: Node.js CLI, 經過它直接運行須要 Babel 處理的JavaScript文件
@babel/register
: Patch NodeJs 的require方法,支持導入須要Babel處理的JavaScript模塊
@babel/cli
: CLI工具
轉換器會遍歷 AST 樹,找出本身感興趣的節點類型, 再進行轉換操做. 這個過程和咱們操做DOM
樹差很少,只不過目的不太同樣。AST 遍歷和轉換通常會使用訪問者模式
。
想象一下,Babel 有那麼多插件,若是每一個插件本身去遍歷AST,對不一樣的節點進行不一樣的操做,維護本身的狀態。這樣子不只低效,它們的邏輯分散在各處,會讓整個系統變得難以理解和調試, 最後插件之間關係就糾纏不清,亂成一鍋粥。
因此轉換器操做 AST 通常都是使用訪問器模式
,由這個訪問者(Visitor)
來 ① 進行統一的遍歷操做,② 提供節點的操做方法,③ 響應式維護節點之間的關係;而插件(設計模式中稱爲‘具體訪問者’)只須要定義本身感興趣的節點類型,當訪問者訪問到對應節點時,就調用插件的訪問(visit)方法。
假設咱們的代碼以下:
function hello(v) {
console.log('hello' + v + '!')
}
複製代碼
解析後的 AST 結構以下:
File
Program (program)
FunctionDeclaration (body)
Identifier (id) #hello
Identifier (params[0]) #v
BlockStatement (body)
ExpressionStatement ([0])
CallExpression (expression)
MemberExpression (callee) #console.log
Identifier (object) #console
Identifier (property) #log
BinaryExpression (arguments[0])
BinaryExpression (left)
StringLiteral (left) #'hello'
Identifier (right) #v
StringLiteral (right) #'!'
複製代碼
訪問者會以深度優先
的順序, 或者說遞歸地對 AST 進行遍歷,其調用順序以下圖所示:
上圖中綠線
表示進入該節點,紅線
表示離開該節點。下面寫一個超簡單的'具體訪問者'來還原上面的遍歷過程:
const babel = require('@babel/core')
const traverse = require('@babel/traverse').default
const ast = babel.parseSync(code)
let depth = 0
traverse(ast, {
enter(path) {
console.log(`enter ${path.type}(${path.key})`)
depth++
},
exit(path) {
depth--
console.log(` exit ${path.type}(${path.key})`)
}
})
複製代碼
enter Program(program)
enter FunctionDeclaration(0)
enter Identifier(id)
exit Identifier(id)
enter Identifier(0)
exit Identifier(0)
enter BlockStatement(body)
enter ExpressionStatement(0)
enter CallExpression(expression)
enter MemberExpression(callee)
enter Identifier(object)
exit Identifier(object)
enter Identifier(property)
exit Identifier(property)
exit MemberExpression(callee)
enter BinaryExpression(0)
enter BinaryExpression(left)
enter StringLiteral(left)
exit StringLiteral(left)
enter Identifier(right)
exit Identifier(right)
exit BinaryExpression(left)
enter StringLiteral(right)
exit StringLiteral(right)
exit BinaryExpression(0)
exit CallExpression(expression)
exit ExpressionStatement(0)
exit BlockStatement(body)
exit FunctionDeclaration(0)
exit Program(program)
複製代碼
當訪問者進入一個節點時就會調用 enter(進入)
方法,反之離開該節點時會調用 exit(離開)
方法。 通常狀況下,插件不會直接使用enter
方法,只會關注少數幾個節點類型,因此具體訪問者也能夠這樣聲明訪問方法:
traverse(ast, {
// 訪問標識符
Identifier(path) {
console.log(`enter Identifier`)
},
// 訪問調用表達式
CallExpression(path) {
console.log(`enter CallExpression`)
},
// 上面是enter的簡寫,若是要處理exit,也能夠這樣
// 二元操做符
BinaryExpression: {
enter(path) {},
exit(path) {},
},
// 更高級的, 使用同一個方法訪問多種類型的節點
"ExportNamedDeclaration|Flow"(path) {}
})
複製代碼
那麼 Babel 插件是怎麼被應用的呢?
Babel 會按照插件定義的順序來應用訪問方法,好比你註冊了多個插件,babel-core 最後傳遞給訪問器的數據結構大概長這樣:
{
Identifier: {
enter: [plugin-xx, plugin-yy,] // 數組形式
}
}
複製代碼
當進入一個節點時,這些插件會按照註冊的順序被執行。大部分插件是不須要開發者關心定義的順序的,有少數的狀況須要稍微注意如下,例如plugin-proposal-decorators
:
{
"plugins": [
"@babel/plugin-proposal-decorators", // 必須在plugin-proposal-class-properties以前
"@babel/plugin-proposal-class-properties"
]
}
複製代碼
全部插件定義的順序,按照慣例,應該是新的或者說實驗性的插件在前面,老的插件定義在後面。由於可能須要新的插件將 AST 轉換後,老的插件才能識別語法(向後兼容)。下面是官方配置例子, 爲了確保前後兼容,stage-*
階段的插件先執行:
{
"presets": ["es2015", "react", "stage-2"]
}
複製代碼
注意Preset的執行順序相反,詳見官方文檔
訪問者在訪問一個節點時, 會無差異地調用 enter
方法,咱們怎麼知道這個節點在什麼位置以及和其餘節點的關聯關係呢?
經過上面的代碼,讀者應該能夠猜出幾分,每一個visit
方法都接收一個 Path
對象, 你能夠將它當作一個‘上下文’對象,相似於JQuery
的 JQuery
(const $el = $('.el')
) 對象,這裏麪包含了不少信息:
下面是它的主要結構:
export class NodePath<T = Node> {
constructor(hub: Hub, parent: Node);
parent: Node;
hub: Hub;
contexts: TraversalContext[];
data: object;
shouldSkip: boolean;
shouldStop: boolean;
removed: boolean;
state: any;
opts: object;
skipKeys: object;
parentPath: NodePath;
context: TraversalContext;
container: object | object[];
listKey: string; // 若是節點在一個數組中,這個就是節點數組的鍵
inList: boolean;
parentKey: string;
key: string | number; // 節點所在的鍵或索引
node: T; // 🔴 當前節點
scope: Scope; // 🔴當前節點所在的做用域
type: T extends undefined | null ? string | null : string; // 🔴節點類型
typeAnnotation: object;
// ... 還有不少方法,實現增刪查改
}
複製代碼
你能夠經過這個手冊來學習怎麼經過 Path 來轉換 AST. 後面也會有代碼示例,這裏就不展開細節了
實際上訪問者的工做比咱們想象的要複雜的多,上面示範的是靜態 AST 的遍歷過程。而 AST 轉換自己是有反作用的,好比插件將舊的節點替換了,那麼訪問者就沒有必要再向下訪問舊節點了,而是繼續訪問新的節點, 代碼以下。
traverse(ast, {
ExpressionStatement(path) {
// 將 `console.log('hello' + v + '!')` 替換爲 `return ‘hello’ + v`
const rtn = t.returnStatement(t.binaryExpression('+', t.stringLiteral('hello'), t.identifier('v')))
path.replaceWith(rtn)
},
}
複製代碼
上面的代碼, 將console.log('hello' + v + '!')
語句替換爲return "hello" + v;
, 下圖是遍歷的過程:
咱們能夠對 AST 進行任意的操做,好比刪除父節點的兄弟節點、刪除第一個子節點、新增兄弟節點... 當這些操做'污染'了 AST 樹後,訪問者須要記錄這些狀態,響應式(Reactive)更新 Path 對象的關聯關係, 保證正確的遍歷順序,從而得到正確的轉譯結果。
訪問者能夠確保正確地遍歷和修改節點,可是對於轉換器來講,另外一個比較棘手的是對做用域的處理,這個責任落在了插件開發者的頭上。插件開發者必須很是謹慎地處理做用域,不能破壞現有代碼的執行邏輯。
const a = 1, b = 2
function add(foo, bar) {
console.log(a, b)
return foo + bar
}
複製代碼
好比你要將 add
函數的第一個參數 foo
標識符修改成a
, 你就須要遞歸遍歷子樹,查出foo
標識符的全部引用
, 而後替換它:
traverse(ast, {
// 將第一個參數名轉換爲a
FunctionDeclaration(path) {
const firstParams = path.get('params.0')
if (firstParams == null) {
return
}
const name = firstParams.node.name
// 遞歸遍歷,這是插件經常使用的模式。這樣能夠避免影響到外部做用域
path.traverse({
Identifier(path) {
if (path.node.name === name) {
path.replaceWith(t.identifier('a'))
}
}
})
},
})
console.log(generate(ast).code)
// function add(a, bar) {
// console.log(a, b);
// return a + bar;
// }
複製代碼
🤯慢着,好像沒那麼簡單,替換成 a
以後, console.log(a, b)
的行爲就被破壞了。因此這裏不能用 a
,得換個標識符, 譬如c
.
這就是轉換器須要考慮的做用域問題,AST 轉換的前提是保證程序的正確性。 咱們在添加和修改引用
時,須要確保與現有的全部引用不衝突。Babel自己不能檢測這類異常,只能依靠插件開發者謹慎處理。
Javascript採用的是詞法做用域, 也就是根據源代碼的詞法結構來肯定做用域:
在詞法區塊(block)中,因爲新建變量、函數、類、函數參數等建立的標識符,都屬於這個區塊做用域. 這些標識符也稱爲綁定(Binding),而對這些綁定的使用稱爲引用(Reference)
在Babel中,使用Scope
對象來表示做用域。 咱們能夠經過Path對象的scope
字段來獲取當前節點的Scope
對象。它的結構以下:
{
path: NodePath;
block: Node; // 所屬的詞法區塊節點, 例如函數節點、條件語句節點
parentBlock: Node; // 所屬的父級詞法區塊節點
parent: Scope; // ⚛️指向父做用域
bindings: { [name: string]: Binding; }; // ⚛️ 該做用域下面的全部綁定(即該做用域建立的標識符)
}
複製代碼
Scope
對象和 Path
對象差很少,它包含了做用域之間的關聯關係(經過parent指向父做用域),收集了做用域下面的全部綁定(bindings), 另外還提供了豐富的方法來對做用域僅限操做。
咱們能夠經過bindings
屬性獲取當前做用域下的全部綁定(即標識符),每一個綁定由Binding
類來表示:
export class Binding {
identifier: t.Identifier;
scope: Scope;
path: NodePath;
kind: "var" | "let" | "const" | "module";
referenced: boolean;
references: number; // 被引用的數量
referencePaths: NodePath[]; // ⚛️獲取全部應用該標識符的節點路徑
constant: boolean; // 是不是常量
constantViolations: NodePath[];
}
複製代碼
經過Binding
對象咱們能夠肯定標識符被引用的狀況。
Ok,有了 Scope
和 Binding
, 如今有能力實現安全的變量重命名轉換了。 爲了更好地展現做用域交互,在上面代碼的基礎上,咱們再增長一下難度:
const a = 1, b = 2
function add(foo, bar) {
console.log(a, b)
return () => {
const a = '1' // 新增了一個變量聲明
return a + (foo + bar)
}
}
複製代碼
如今你要重命名函數參數 foo
, 不只要考慮外部的做用域
, 也要考慮下級做用域
的綁定狀況,確保這二者都不衝突。
上面的代碼做用域和標識符引用狀況以下圖所示:
來吧,接受挑戰,試着將函數的第一個參數從新命名爲更短的標識符:
// 用於獲取惟一的標識符
const getUid = () => {
let uid = 0
return () => `_${(uid++) || ''}`
}
const ast = babel.parseSync(code)
traverse(ast, {
FunctionDeclaration(path) {
// 獲取第一個參數
const firstParam = path.get('params.0')
if (firstParam == null) {
return
}
const currentName = firstParam.node.name
const currentBinding = path.scope.getBinding(currentName)
const gid = getUid()
let sname
// 循環找出沒有被佔用的變量名
while(true) {
sname = gid()
// 1️⃣首先看一下父做用域是否已定義了該變量
if (path.scope.parentHasBinding(sname)) {
continue
}
// 2️⃣ 檢查當前做用域是否認義了變量
if (path.scope.hasOwnBinding(sname)) {
// 已佔用
continue
}
// 再檢查第一個參數的當前的引用狀況,
// 若是它所在的做用域定義了同名的變量,咱們也得放棄
if (currentBinding.references > 0) {
let findIt = false
for (const refNode of currentBinding.referencePaths) {
if (refNode.scope !== path.scope && refNode.scope.hasBinding(sname)) {
findIt = true
break
}
}
if (findIt) {
continue
}
}
break
}
// 開始替換掉
const i = t.identifier(sname)
currentBinding.referencePaths.forEach(p => p.replaceWith(i))
firstParam.replaceWith(i)
},
})
console.log(generate(ast).code)
// const a = 1,
// b = 2;
// function add(_, bar) {
// console.log(a, b);
// return () => {
// const a = '1'; // 新增了一個變量聲明
// return a + (_ + bar);
// };
// }
複製代碼
上面的例子雖然沒有什麼實用性,並且還有Bug(沒考慮label
),可是正好能夠揭示了做用域處理的複雜性。
Babel的 Scope
對象其實提供了一個generateUid
方法來生成惟一的、不衝突的標識符。咱們利用這個方法再簡化一下咱們的代碼:
traverse(ast, {
FunctionDeclaration(path) {
const firstParam = path.get('params.0')
if (firstParam == null) {
return
}
let i = path.scope.generateUidIdentifier('_') // 也可使用generateUid
const currentBinding = path.scope.getBinding(firstParam.node.name)
currentBinding.referencePaths.forEach(p => p.replaceWith(i))
firstParam.replaceWith(i)
},
})
複製代碼
能不能再短點!
traverse(ast, {
FunctionDeclaration(path) {
const firstParam = path.get('params.0')
if (firstParam == null) {
return
}
let i = path.scope.generateUid('_') // 也可使用generateUid
path.scope.rename(firstParam.node.name, i)
},
})
複製代碼
generateUid(name: string = "temp") {
name = t
.toIdentifier(name)
.replace(/^_+/, "")
.replace(/[0-9]+$/g, "");
let uid;
let i = 0;
do {
uid = this._generateUid(name, i);
i++;
} while (
this.hasLabel(uid) ||
this.hasBinding(uid) ||
this.hasGlobal(uid) ||
this.hasReference(uid)
);
const program = this.getProgramParent();
program.references[uid] = true;
program.uids[uid] = true;
return uid;
}
複製代碼
很是簡潔哈?做用域操做最典型的場景是代碼壓縮,代碼壓縮會對變量名、函數名等進行壓縮... 然而實際上不多的插件場景須要跟做用域進行復雜的交互,因此關於做用域這一塊就先講到這裏。
等等別走,還沒完呢,這纔到2/3。學了上面得了知識,總得寫一個玩具插件試試水吧?
如今打算模仿babel-plugin-import, 寫一個極簡版插件,來實現模塊的按需導入. 在這個插件中,咱們會將相似這樣的導入語句:
import {A, B, C as D} from 'foo'
複製代碼
轉換爲:
import A from 'foo/A'
import 'foo/A/style.css'
import B from 'foo/B'
import 'foo/B/style.css'
import D from 'foo/C'
import 'foo/C/style.css'
複製代碼
首先經過 AST Explorer 看一下導入語句的 AST 節點結構:
經過上面展現的結果,咱們須要處理 ImportDeclaration
節點類型,將它的specifiers
拿出來遍歷處理一下。另外若是用戶使用了默認導入
語句,咱們將拋出錯誤,提醒用戶不能使用默認導入.
基本實現以下:
// 要識別的模塊
const MODULE = 'foo'
traverse(ast, {
// 訪問導入語句
ImportDeclaration(path) {
if (path.node.source.value !== MODULE) {
return
}
// 若是是空導入則直接刪除掉
const specs = path.node.specifiers
if (specs.length === 0) {
path.remove()
return
}
// 判斷是否包含了默認導入和命名空間導入
if (specs.some(i => t.isImportDefaultSpecifier(i) || t.isImportNamespaceSpecifier(i))) {
// 拋出錯誤,Babel會展現出錯的代碼幀
throw path.buildCodeFrameError("不能使用默認導入或命名空間導入")
}
// 轉換命名導入
const imports = []
for (const spec of specs) {
const named = MODULE + '/' + spec.imported.name
const local = spec.local
imports.push(t.importDeclaration([t.importDefaultSpecifier(local)], t.stringLiteral(named)))
imports.push(t.importDeclaration([], t.stringLiteral(`${named}/style.css`)))
}
// 替換原有的導入語句
path.replaceWithMultiple(imports)
}
})
複製代碼
邏輯還算簡單,babel-plugin-import
可比這複雜得多。
接下來,咱們將它封裝成標準的 Babel 插件。 按照規範,咱們須要建立一個babel-plugin-*
前綴的包名:
mkdir babel-plugin-toy-import
cd babel-plugin-toy-import
yarn init -y
touch index.js
複製代碼
你也能夠經過 generator-babel-plugin 來生成項目模板.
在 index.js
文件中填入咱們的代碼。index.js
默認導出一個函數,函數結構以下:
// 接受一個 babel-core 對象
export default function(babel) {
const {types: t} = babel
return {
pre(state) {
// 前置操做,可選,能夠用於準備一些資源
},
visitor: {
// 咱們的訪問者代碼將放在這裏
ImportDeclaration(path, state) {
// ...
}
},
post(state) {
// 後置操做,可選
}
}
}
複製代碼
咱們能夠從訪問器方法的第二個參數state
中獲取用戶傳入的參數。假設用戶配置爲:
{
plugins: [['toy-plugin', {name: 'foo'}]]
}
複製代碼
咱們能夠這樣獲取用戶傳入的參數:
export default function(babel) {
const {types: t} = babel
return {
visitor: {
ImportDeclaration(path, state) {
const mod = state.opts && state.opts.name
if (mod == null) {
return
}
// ...
}
},
}
}
複製代碼
打完收工 🙏,發佈!
yarn publish # good luck
複製代碼
新世界的大門已經打開: ⛩
本文主要介紹了 Babel 的架構和原理,還實踐了一下 Babel 插件開發,讀到這裏,你算是入了 Babel 的門了.
接下來你能夠去熟讀Babel手冊, 這是目前最好的教程, ASTExplorer是最好的演練場,多寫代碼多思考。 你也能夠去看Babel的官方插件實現, 邁向更高的臺階。
本文還有下篇,我將在下篇文章中介紹babel-plugin-macros, 敬請期待!
點贊是對我最好鼓勵。