我在團隊幫助開發 Node 工具時,遇到了需要對多份相似的代碼進行一定的處理,但又不能改變原本倉庫的代碼,這個非常像我們的編譯工具做的事情。在一開始的時候,參考了類似 FIS 的功能,簡單參照使用代碼的 HOOK 配合正則來進行代碼的操作。后面覺得實在難以拓展且無法確保修正后的代碼可用性,還是覺得必須使用 Babel 來實現源碼的操作。
什么是 Babel
babel 可以說是前端工程化用的最多的工具之一。在 ES6 僅僅只有標准時,我們就開始利用 Babel 的 ES6 轉換到 ES5 的功能來使用新的語法特性。包括后續大熱的React 制定的 JSX 的語法,“憑空”創建出來的語法,為什么能這么快且自然的就融入到前端體系里,這都離不開 Babel 這個編譯器的功勞(compiler)。除了編譯器功能外,還可以對代碼進行各類的靜態分析。
原理
babel 利用 babylon 解析器進行語法解析。解析成 AST(抽象語法樹)后,經過一定的規則對這個樹進行處理后再生成新的語法樹。最終新的語法樹在生成對應的合法的 Javascript 語法。
AST
AST,就是編譯時常說的抽象語法樹。樹狀結構可以幫助我們更好的索引和操作數據結構。例如我們前端三板斧 HTML/CSS/Javascript,而我們平常認知最深的 HTML DOM 樹,通過一棵樹就可以表達整個頁面結構,而 CSS 和 Javascript 都可以通過編譯器將語法解析成 AST 抽象語法樹。
代碼生成 AST 需要進行詞法分析和語法分析。
我們可以看一個最簡單的程序語法來管中窺豹一下,我們利用 AST Expoler 看看下面這段語法的 AST 到底是長什么樣的。
if(3 > 5) {
console.log('3 大於 5')
} else {
console.log('5 大於等於 3')
}
簡單畫個示意圖,部分屬性也精簡了一下。
我們可以看到,所有的 AST 樹的根結點都是 Program 節點。但即使一個非常簡單的 if 判斷語法對應的都是一支比較復雜的樹結構。而 if 語句對應的最重要的三個屬性就是 consequent/test/alternate。判斷分支內是一個 BinaryExpression(二元表達式),而在 ture/false 分支內則是 CallExpression(函數調用表達式),而調用的函數來自 MemberExpression(成員表達式)。
下面這個表上面這段代碼用到的
屬性 | 描述 |
---|---|
BlockStatement | 塊級表達式 |
BinaryExpression | 二元表達式 |
CallExpression | 函數調用表達式 |
MemberExpression | 成員表達式 |
Literal | 文字 |
更多詳細的 AST 類型都可以查看 Babylon 提供的 Spec 文檔。
babel 插件
在我們簡單了解 AST 樹后,就可以具體看看如何利用 Babel 插件來玩轉我們的代碼。Babel 提供了幾個核心的 Lib,我們可以關心以下幾個,babel-core
、babel-types
、babel-template
。
- babel-core。Babel 的核心庫,提供了將代碼編譯轉化的能力。
- babel-types。提供 AST 樹節點的類型。
- babel-template。可以將普通字符串轉化成 AST,提供更便捷的使用。
Visitors
Visitors 訪問者模式,是我們訪問 AST 樹上的節點時所用到的方法。
const visitors = {
BinaryExpression(path) {
console.log('訪問二元表達式的節點')
},
Literal(path) {
console.log('訪問文本')
}
}
例如上圖就可以定義不同節點類型的訪問者,對節點路徑進行訪問並對節點進行操作並轉化。
Paths
我們在 我們定義成樹狀結構,除了可以完整描述整個代碼的結構,也方便讓節點和節點之間進行索引和操作。我們通過 visitor 訪問節點時,其實訪問的是節點的 path,並不是實際上的節點。path 上有該節點信息 path.node
,也有類似 DOM 樹上一樣的可以訪問到父節點的信息 path.parent
,方便后續的操作。
// 我們通過 visitor 訪問到的 AST 節點后,回調參數 path 示例
{
"parent": {
"type": "BinaryExpression",
"operator": ">"
....
},
"node": {
"type": "Literal",
"name": "3",
....
}
}
// in vistors
const visitors = {
Literal(path) {
console.log(path.node.name)
console.log(path.parent.operator)
}
}
示例
我們在了解 visitors 和 paths 后,就可以正式看看如何編寫自己的 babel 插件了。
替換操作符
我們最開頭的例子的判斷條件是一個二元表達式,我們希望將大於號(>)都轉換成小於等於(<=)號。
const babel = require('babel-core')
const t = require('babel-types')
const code = `
if(3>5) {}
`
const visitor = {
BinaryExpression(path) {
if(path.node.operator === '>') {
path.replaceWith(
t.binaryExpression(
'<=',
path.node.left,
path.node.right
)
);
}
}
}
const result = babel.transform(code, {
plugins: [
{ visitor }
]
})
console.log(result.code)
import 拆分
在網上很多有關 babel 插件的例子,都會提到這個 import 語句拆分的案例,我也覺得這個案例非常實用且有意義。
我們在使用 lodash 時,如果是用普通的方式引入時,會引入非常大的包,有的時候你只需要使用其中一兩個小函數,所以 lodash 提供了分包按需引入的方式。但兩者的寫法不一樣,所以我們需要使用 babel 插件來實現這個功能。
// 轉化前,bad
import { uniq, sort as _sort } from 'lodash'
// 轉化后,better
import uniq from 'lodash/uniq'
import _sort from 'lodash/sort'
我們可以先去 AST explore 先看看具體 AST 的樹結構。然后再來寫出我們的插件。
const babel = require('babel-core')
const t = require('babel-types')
const code = `import { unqi, sort as _sort } from 'lodash'`
const visitor = {
ImportDeclaration(path) {
const specifiers = path.node.specifiers
const source = path.node.source
if(!t.isImportDefaultSpecifier(specifiers[0])) {
const myImportDeclaration = specifiers.map(specifier => {
return t.importDeclaration(
[t.importDefaultSpecifier(specifier.local)],
t.stringLiteral(`${source.value}/${specifier.imported.name}`)
)
})
path.replaceWithMultiple(myImportDeclaration)
}
}
}
const result = babel.transform(code, {
plugins: [
{ visitor }
]
})
console.log(result.code)
為你的函數插入一行代碼
我們如何對一個函數內部新增 AST 節點,“憑空”插入一行正確無誤的代碼呢。
const babel = require('babel-core')
const t = require('babel-types')
const code = `
class Page {
onLoad() {
console.log('Hello')
}
}
`
const visitor = {
BlockStatement(path) {
if(path.parent.key.name === 'onLoad' && path.node.body.length === 1) {
const body = path.node.body
const newBlockStatement = t.blockStatement(body.concat(
t.expressionStatement(
t.callExpression(
t.memberExpression(
t.identifier('console'),
t.identifier('log')
),
[t.stringLiteral('World!')]
)
)
))
path.replaceWith(newBlockStatement)
}
}
}
const result = babel.transform(code, {
plugins: [
{ visitor }
]
})
console.log(result.code)
其實思路是一樣的,只要我們構建出一個 AST 節點,再把這個節點再插到原本的 AST 樹上,那么這個 AST 樹就可以轉化成我們想要的代碼了。
最后
這里我們了解了 AST 樹,理解了我們學習工作中常用的 Babel 到底背后做了哪些事,我們就可以更好的融入到前端開發的社區,去做更多的事情。更多更復雜更詳細的用法,都可以參照 Babel 官網,或 Babel 插件手冊。當然有興趣的同學也可以把玩一下 CSS 的相關 AST 編譯,原理都是一樣的。