接着上篇文章: 《深刻淺出 Babel 上篇:架構和原理 + 實戰 🔥》 歡迎轉載,讓更多人看到個人文章,轉載請註明出處javascript
這篇文章乾貨很多於上篇文章,這篇咱們深刻討論一下宏這個玩意 —— 我想咱們對宏並不陌生,由於不少程序員第一門語言就是 C/C++
; 一些 Lisp
方言也支持宏(如 Clojure
、Scheme
), 據說它們的宏寫起來很優雅;一些現代的編程語言對宏也有必定的支持,如 Rust
、Nim
、Julia
、Elixir
,它們是如何解決技術問題, 實現類Lisp的宏系統的?宏在這些語言中扮演這什麼角色...html
若是沒讀過上篇文章,請先閱讀一下,避免影響對本篇文章內容的理解。前端
文章大綱vue
Wiki
上面對‘宏’的定義是:宏(Macro), 是一種批處理的稱謂,它根據一系列的預約義規則轉換必定的文本模式。解釋器
或編譯器
在遇到宏時會自動進行這一模式轉換,這個轉換過程被稱爲「宏展開(Macro Expansion)」。對於編譯語言,宏展開在編譯時發生,進行宏展開的工具常被稱爲宏展開器。java
你能夠認爲,宏就是用來生成代碼的代碼,它有能力進行一些句法解析和代碼轉換。宏大體能夠分爲兩種: 文本替換和語法擴展react
你們或多或少有接觸過宏,不少程序員第一門語言是C/C++
(包括C的衍生語言Objective-C
), 在C
中就有宏的概念。使用#define
指令定義一個宏:git
#define MIN(X, Y) ((X) < (Y) ? (X) : (Y))
複製代碼
若是咱們的程序使用了這個宏,就會在編譯階段被展開,例如:程序員
MIN(a + b, c + d)
複製代碼
會被展開爲:github
((a + b) < (c + d) ? (a + b) : (c + d))
複製代碼
除了函數宏
, C
中還有對象宏
, 咱們一般使用它來聲明'常量':vue-cli
#define PI 3.1214
複製代碼
如上圖,宏本質上不是C
語言的一部分, 它由C預處理器
提供,預處理器在編譯以前對源代碼進行文本替換,生成‘真正’的 C
代碼,再傳遞給編譯器。
固然 C 預處理器不只僅會處理宏,它還包含了頭文件引入、條件編譯、行控制等操做
除此以外,GNU m4
是一個更專業/更強大/更通用的預處理器(宏展開器)。這是一個通用的宏展開器,不只能夠用於 C,也能夠用於其餘語言和文本文件的處理(參考這篇有趣的文章:《使用 GNU m4 爲 Markdown 添加目錄支持》), 關於m4
能夠看讓這世界再多一份 GNU m4 教程 系列文章.
文本替換式宏很容易理解、實現也簡單,由於它們只是純文本替換, 換句話說它就像‘文本編輯器’。因此相對而言,這種形式的宏能力有限,好比它不會檢驗語法是否合法, 使用它常常會出現問題。
因此隨着現代編程語言表達能力愈來愈強,不少語言都再也不推薦使用宏/不提供宏,而是使用語言自己的機制(例如函數)來解決問題,這樣更安全、更容易理解和調試。沒用宏機制,現代語言能夠經過提供強大的反射機制或者動態編程特性(如Javascript的Proxy、Python的裝飾器)來彌補缺失宏致使的元編程短板。 因此反過來推導,之因此C
語言須要宏,正是由於C
語言的表達能力太弱了。
‘真正’的宏起源於Lisp
. 這個得益於Lisp語言自己的一些特性:
因爲 Lisp 這種簡單的語法結構,使得數據和程序之間只有一線之隔(quote修飾就是數據, 沒有quote就是程序), 換句話說就是程序和數據之間能夠靈活地轉換。這種數據即程序、程序即數據
的概念,使得Lisp能夠輕鬆地自定義宏. 不妨來看一下Lisp定義宏的示例:
; 使用defmacro定義一個nonsense宏, 接收一個function-name參數. 宏須要返回一個quoted
; ` 這是quote函數的簡寫,表示quote,即這段‘程序’是一段‘數據’, 或者說將‘程序’轉換爲‘數據’. quote不會被‘求值’
; defun 定義一個函數
; , 這是unquote函數的簡寫, 表示unquote,即將‘數據’轉換爲‘程序’. unquote會進行求值
; intern 將字符串轉換爲symbol,即標識符
(defmacro nonsense (function-name)
`(defun ,(intern (concat "nonsense-" function-name)) (input) ; 定義一個nonsense-${function-name} 方法
(print (concat ,function-name input)))) ; 輸入`${function-name}${input}`
複製代碼
注意:‘宏’通常在編譯階段被展開, 下面代碼只是爲了協做你理解上述的Lisp代碼
function nonsense(name) {
let rtn
eval(`rtn = function nonsense${name}(input) { console.log('${name}', input) }`)
return rtn
}
複製代碼
應用宏展開:
(nonsense "apple") ; 展開宏,這裏會建立一個nonsense-apple函數
(nonsense-apple " is good") ; 調用剛剛建立的宏
; => "apple is good"
複製代碼
對於Lisp而言,宏有點像一個函數, 只不過這個函數必須返回一個quoted數據
; 當調用這個宏時,Lisp會使用unquote
函數將宏返回的quoted數據
轉換爲程序
。
經過上面的示例,你會感嘆Lisp的宏實現居然如此清奇,如此簡單。 搞得我想跟着題葉學一波Clojure,可是後來我學了Elixir 😂.
Lisp宏的靈活性得益於簡單的語法(S-表達式能夠等價於它的AST),對於複雜語法的語言(例如Javascript),要實現相似Lisp的宏就可貴多. 所以不多有現代語言提供宏機制可能也是這個緣由。
儘管如此,如今不少技術難點慢慢被解決,不少現代語言也引入'類' Lisp的宏機制,如Rust、Julia, 還有Javascript的 Sweet.js
Sweet.js 和 Rust 師出同門,因此兩個的宏語法和很是接近(初期)。 不過須要注意的是: 官方認爲 Sweet.js 目前仍處於實驗階段,並且Github最後提交時間停留在2年前,社區上也未見大規模的使用。因此不要在生產環境中使用它,可是不妨礙咱們去學習一個現代編程語言的宏機制。
咱們先使用 Sweet.js
來實現上面咱們經過 Lisp
實現的nosense
宏, 對比起來更容易理解:
import { unwrap, fromIdentifier, fromStringLiteral } from '@sweet-js/helpers' for syntax;
syntax nosense = function (ctx) {
let name = ctx.next().value;
let funcName = 'nonsense' + unwrap(name).value
return #`function ${fromIdentifier(name, funcName)} () {
console.log(${fromStringLiteral(name, unwrap(name).value)} + input)
}`;
};
nosense Apple
nosenseApple(" is Good") // Apple is Good
複製代碼
首先,Sweet.js使用syntax
關鍵字來定義一個宏,其語法相似於const
或者let
。
本質上一個宏就是一個函數, 只不過在編譯階段被執行. 這個函數接收一個 TransformerContext
對象,你也經過這個對象獲取宏應用傳入的語法對象(Syntax Object)數組,最終這個宏也要返回語法對象數組。
什麼是語法對象?語法對象是 Sweet.js 關於語法的內部表示, 你能夠類比上文Lisp的 quoted 數據。在複雜語法的語言中,沒辦法使用 quoted 這麼簡單的序列來表示語法,而使用 AST 則更復雜,開發者更難以駕馭。因此大部分宏實現會參考 Lisp 的S-表達式
,取折中方案,將傳入的程序轉換爲Tokens,再組裝成相似quoted的數據結構。
舉個例子,Sweet.js 會將 foo,bar('baz', 1)
轉換成這樣的數據結構:
從上圖可知,Sweet.js 會將傳入的程序解析成嵌套的Token序列,這個結構和Lisp的S-表達式
很是類似。也就是, 說對於閉合的詞法單元會被嵌套存儲,例如上例的('baz', 1)
.
Elixir 也採用了相似的quote/unquote機制,能夠結合着一塊兒理解
TransformerContext
實現了迭代器方法,因此咱們經過調用它的next()
來遍歷獲取語法對象。最後宏必須返回一個語法對象數組,Sweet.js 使用了相似字符串模板
的語法(稱爲語法模板
)來簡化開發,這個模板最終轉換爲語法對象數組。
須要注意的是
語法模板
的內嵌值只能是語法對象、語法對象序列或者TransformerContext.
macro define {
rule { $x } => {
var $x
}
rule { $x = $expr } => {
var $x = $expr
}
}
define y;
define y = 5;
複製代碼
說了這麼多,相似Sweet.js 語法對象
的設計是現代編程語言爲了貼近 Lisp 宏的一個關鍵技術點。我發現Elixir
、Rust
等語言也使用了相似的設計。 除了數據結構的設計,現代編程語言的宏機制還包含如下特性:
1️⃣ 衛生宏(Hygiene)
衛生宏指的是在宏內生成的變量不會污染外部做用域,也就是說,在宏展開時,Sweet.js 會避免宏內定義的變量和外部衝突.
舉個例子,咱們建立一個swap宏,交換變量的值:
syntax swap = (ctx) => {
const a = ctx.next().value
ctx.next() // 吃掉','
const b = ctx.next().value
return #`
let temp = ${a}
${a} = ${b}
${b} = temp
`;
}
swap foo,bar
複製代碼
展開會輸出爲
let temp_10 = foo; // temp變量被重命名爲temp_10
foo = bar;
bar = temp_10;
複製代碼
若是你想引用外部的變量,也能夠。不過不建議這麼作,宏不該該假定其被展開的上下文:
syntax swap = (ctx) => {
// ...
return #`
temp = ${a} // 不使用 let 聲明
${a} = ${b}
${b} = temp
`;
}
複製代碼
2️⃣ 模塊化
Sweet.js 的宏是模塊化的:
'lang sweet.js';
// 導出宏
export syntax class = function (ctx) {
// ...
};
複製代碼
導入:
import { class } from './es2015-macros';
class Droid {
constructor(name, color) {
this.name = name;
this.color = color;
}
rollWithIt(it) {
return this.name + " is rolling with " + it;
}
}
複製代碼
相對Babel(編譯器)來講,Sweet.js的宏是模塊化/顯式的。Babel你須要在配置文件中配置各類插件和選項,尤爲是團隊項目構建有統一規範和環境時,項目構建腳本修改可能有限制。而模塊化的宏是源代碼的一部分,而不是構建腳本的一部分,這使得它們能夠被靈活地使用、重構以及廢棄。
下文介紹的 babel-plugin-macros
最大的優點就在這裏, 一般咱們但願構建環境是統一的、穩定的、開發人員應該專一於代碼的開發,而不是如何去構建程序,正是由於代碼多變性,才催生出了這些方案。
須要注意的是宏是在編譯階段展開的,因此沒法運行用戶代碼,例如:
let log = msg => console.log(msg); // 用戶代碼, 運行時被求值,因此沒法被訪問
syntax m = ctx => {
// 宏函數在編譯階段被執行
log('doing some Sweet things'); // ERROR: 未找到變量log
// ...
};
複製代碼
Sweet.js 和其餘語言的宏同樣,有了它你能夠:
🤕很遺憾!Sweet.js 基本死了。因此如今當個玩具玩玩尚可,切勿用於生產環境。即便沒有死,Sweet.js 這種非標準的語法, 和現有的Javascript工具鏈生態格格不入,開發和調試都會比較麻煩(好比Typescript).
歸根到底,Sweet.js 的失敗,是社區拋棄了它。Javascript語言表達能力愈來愈強,版本迭代快速,加上有了Babel和Typescript這些解決方案,實在拿不出什麼理由來使用 Sweet.js
Sweet.js 相關論文能夠看這裏
這一節扯得有點多,將宏的歷史和分類講了個遍。 最後的總結是Elixir官方教程裏面的一句話:顯式好於隱式,清晰的代碼優於簡潔的代碼(Clear code is better than concise code)
能力越大、責任越大。宏強大,比正常程序要更難以駕馭,你可能須要必定的成本去學習和理解它, 因此能不用宏就不用宏,宏是應該最後的法寶.
🤓還沒完, 一會兒扯了好遠,掰回正題。既然 Babel 有了 Plugin 爲何又冒出了個 babel-plugin-macros
?
若是你尚不瞭解Babel Macro,能夠先讀一下官方文檔, 另外Creact-React-APP 已經內置
這個得從 Create-React-App(CRA) 提及,CRA 將全部的項目構建邏輯都封裝在react-scripts
服務中。這樣的好處是,開發者不須要再關心構建的細節, 另外構建工具的升級也變得很是方便, 直接升級 react-scripts
便可。
若是本身維護構建腳本的話,升一次級你須要升級一大堆的依賴,若是你要維護跨項目的構建腳本,那就更蛋疼了。
我在《爲何要用vue-cli3?》 裏闡述了 CRA 以及 Vue-cli這類的工具對團隊項目維護的重要性。
CRA 是強約定的,它是按照React社區的最佳實踐給你準備的,爲了保護封裝帶來的紅利,它不推薦你去手動配置Webpack、Babel... 因此才催生除了 babel-plugin-macros, 你們能夠看這個 Issue: RFC - babel-macros
因此爲 Babel 尋求一個'零配置'的機制是 babel-plugin-macros
誕生的主要動機。
這篇文章正好證明了這個動機:《Zero-config code transformation with babel-plugin-macros》, 這篇文章引述了一個重要的觀點:"Compilers are the New Frameworks"
的確,Babel 在現代的前端開發中扮演着一個很重要的角色,愈來愈多的框架或庫會建立本身的 Babel 插件,它們會在編譯階段作一些優化,來提升用戶體驗、開發體驗以及運行時的性能。好比:
上面列舉的插件場景中,並非全部插件都是通用的,它們要麼是跟某一特定的框架綁定、要麼用於處理特定的文件類型或數據。這些非通用的插件是最適合使用macro取代的。
用 preval
舉個例子. 使用插件形式, 你首先要配置插件:
{
"plugins": ["preval"]
}
複製代碼
代碼:
// 傳遞給preval的字符串會在編譯階段被執行
// preval插件會查找preval標識符,將字符串提取出來執行,在將執行的結果賦值給greeting
const greeting = preval` const fs = require('fs') module.exports = fs.readFileSync(require.resolve('./greeting.txt'), 'utf8') `
複製代碼
使用Macro方式:
// 首先你要顯式導入
import preval from 'preval.macro'
// 和上面同樣
const greeting = preval` const fs = require('fs') module.exports = fs.readFileSync(require.resolve('./greeting.txt'), 'utf8') `
複製代碼
這二者達到的效果是同樣的,但意義卻不太同樣。有哪些區別?
1️⃣ 很顯然,Macro不須要配置.babelrc
(固然babel-plugin-macros這個基座須要裝好). 這個對於CRA這種不推薦配置構建腳本的工具來講頗有幫助
2️⃣ 由隱式轉換爲了顯式。上一節就說了「顯式好於隱式」。你必須在源代碼中經過導入語句
聲明你使用了 Macro; 而基於插件的方式,你可能不知道preval
這個標識符哪裏來的? 如何被應用?什麼時候被應用?並且一般你還須要和其餘工具鏈的配合,例如ESlint、Typescript聲明等等。
Macro 由代碼顯式地應用,咱們更明確它被應用的目的和時機,對源代碼的侵入性最小。由於中間多了 babel-plugin-macro
這一層,咱們下降了對構建環境的耦合,讓咱們的代碼更方便被遷移。
3️⃣ Macro相比Plugin 更容易被實現。由於它專一於具體的 AST 節點,見下文
4️⃣ 另外,當配置出錯時,Macro能夠獲得更好的錯誤提示
有利有弊,Babel Macro 確定也有些缺陷,例如相對於插件來講只能顯式轉換,這樣代碼可能會比較囉嗦,不過我的以爲在某些場景利大於弊, 能顯式的就顯式。
那麼Babel Macro也是宏?相對於 Sweet.js 這些'正統'的宏機制有哪些不足?
首先 Babel Macro 必須是合法的 Javascript 語法。不支持自定義語法,也要分兩面討論,合法的Javascript語法不至於打破現有的工具協做鏈,若是容許用戶毫無限制地建立新的語法,未來指不定會和標準的語法發生歧義。 反過來不能自定義語法的‘宏’,是否顯得不太地道,不夠'強大'?
由於必須是合法的Javascript語法,Babel Macro 實現DSL(Domain-specific languages)能力就弱化了
再者,Babel Macro 和 Babel Plugin沒有本質的區別,相比Sweet.js提供了顯式定義和應用宏的語法,Babel Macro直接操做 AST 則要複雜得多,你仍是須要了解一些編譯原理,這把通常的開發者擋在了門外。
Babel 能夠實現自定義語法,只不過你須要Fork
@babel/parser
, 對它進行改造(能夠看這篇文章《精讀《用 Babel 創造自定義 JS 語法》》)。這個有點折騰,不太推薦
總之,Babel Macro 本質上和Babel Plugin沒有什麼區別,它只是在Plugin 之上封裝了一層(分層架構模式的強大),建立了一個新的平臺,讓開發者能夠在源代碼層面顯式地應用代碼轉換。因此,任何適合顯式去轉換的場景都適合用Babel Macro來作:
styled-components
preval
graphql-tag.macro
、yaml.macro
、svgr.macro
因此,Babel Macro是如何運做的呢? babel-plugin-macros
要求開發者必須顯式地導入 Macro,它會遍歷匹配全部導入語句,若是導入源匹配/[./]macro(\.js)?$/
正則,就會認爲你在啓用Macro。例以下面這些導入語句都匹配正則:
import foo from 'my.macro'
import { bar } from './bar/macro'
import { baz as _baz} from 'baz/macro.js'
// 不支持命名空間導入
複製代碼
Ok, 當匹配到導入語句後,babel-plugin-macros
就會去導入你指定的 macro
模塊或者npm包(Macro 便可以是本地文件,也能夠是公開的 npm 包, 或者是npm包中的子路徑)。
那麼 macro
文件裏面要包含什麼內容呢?以下:
const { createMacro } = require('babel-plugin-macros')
module.exports = createMacro(({references, state, babel}) => {
// ... macro 邏輯
})
複製代碼
macro
文件必須默認導出一個由 ceateMacro
建立的實例, 在其回調中能夠獲取到一些關鍵對象:
babel
和普通的Babel插件同樣,Macro 能夠獲取到一個 babel-core
對象state
這個咱們也比較熟悉,Babel 插件的 visitor 方法的第二個參數就是它, 咱們能夠經過它獲取一些配置信息以及保存一些自定義狀態references
獲取 Macro 導出標識符的全部引用。上一篇文章介紹了做用域,你應該還沒忘記綁定和引用的概念。以下假設用戶這樣子使用你的 Macro:
import foo, {bar, baz as Baz} from './my.macro' // 建立三個綁定
// 下面開始引用這些綁定
foo(1)
foo(2)
bar`by tagged Template`
;<Baz>by JSX</Baz>
複製代碼
那麼你將拿到references
結構是這樣的:
{
// key 爲'綁定', value 爲'引用數組'
default: [NodePath/*Identifier(foo)*/, NodePath/*Identifier(foo)*/], // 默認導出,即foo
bar: [NodePath/*Identifier(bar)*/],
baz: [NodePath/*JSXIdentifier(Baz)*/], // 注意key爲baz,不是Baz
}
複製代碼
查看詳細開發指南
AST Explorer 也支持 babel-plugin-macros,能夠玩一下. 下面的實戰實例,也建議在這裏探索一下
接下來你就能夠遍歷references
, 對這些節點進行轉換,實現你想要的宏功能。開始實戰!
這一次咱們模範preval
建立一個eval.macro
Macro, 利用它在編譯階段執行(eval)一些代碼。例如:
import evalm from 'eval.macro'
const x = evalm` function fib(n) { const SQRT_FIVE = Math.sqrt(5); return Math.round(1/SQRT_FIVE * (Math.pow(0.5 + SQRT_FIVE/2, n) - Math.pow(0.5 - SQRT_FIVE/2, n))); } fib(20) `
// ↓ ↓ ↓ ↓ ↓ ↓
const x = 6765
複製代碼
建立 Macro 文件. 按照上一節的介紹,① 咱們使用createMacro
來建立一個 Macro
實例, ② 並從references
中拿出全部導出標識符
的引用路徑, ③接着就是對這些引用路徑進行AST轉換:
const { createMacro, MacroError } = require('babel-plugin-macros')
function myMacro({ references, state, babel }) {
// 獲取默認導出的全部引用
const { default: defaultImport = [] } = references;
// 遍歷引用並進行求值
defaultImport.forEach(referencePath => {
if (referencePath.parentPath.type === "TaggedTemplateExpression") {
const val = referencePath.parentPath.get("quasi").evaluate().value
const res = eval(val)
const ast = objToAst(res)
referencePath.parentPath.replaceWith(ast)
} else {
// 輸出友好的報錯信息
throw new MacroError('只支持標籤模板字符串, 例如:evalm`1`')
}
});
}
module.exports = createMacro(myMacro);
複製代碼
爲了行文簡潔,本案例中只支持標籤模板字符串
形式調用,可是標籤模板字符串中可能包含內插的字符串,例如:
hello` hello world ${foo} + ${bar + baz} `
複製代碼
其 AST 結構以下:
咱們須要將 TaggedTemplateExpression
節點轉換爲字符串。手動去拼接會很麻煩,好在每一個 AST 節點的 Path 對象都有一個evaluate
方法,這個方法能夠對節點進行‘靜態求值’:
t.evaluate(parse("5 + 5")) // { confident: true, value: 10 }
t.evaluate(parse("!true")) // { confident: true, value: false }
// ❌兩個變量相加沒法求值,由於變量值在運行時才存在,這裏confident爲false:
t.evaluate(parse("foo + foo")) // { confident: false, value: undefined }
複製代碼
所以這樣子的標籤模板字符串是沒法求值的:
evalm`1 + ${foo}` // 包含變量
evalm`1 + ${bar(1)}` // 包含函數調用
複製代碼
這個和 Typescript
的 enum
, 還有一些編譯語言的常量是同樣的,它們在編譯階段被求值,只有一些原始值以及一些原始值的表達式才支持在編譯階段被求值.
So,上面的代碼還不夠健壯,咱們再優化一下,在求值失敗時給用戶更好的提示:
defaultImport.forEach(referencePath => {
if (referencePath.parentPath.type === "TaggedTemplateExpression") {
const evaluated = referencePath.parentPath.get("quasi").evaluate();
// 轉換標籤模板字符串失敗
if (!evaluated.confident) {
throw new MacroError("標籤模板字符串內插值只支持原始值和原始值表達式");
}
try {
const res = eval(evaluated.value);
const ast = objToAst(res);
// 替換掉調用節點
referencePath.parentPath.replaceWith(ast);
} catch (err) {
throw new MacroError(`求值失敗: ${err.message}`);
}
} else {
throw new MacroError("只支持標籤模板字符串, 例如:evalm`1 + 1`");
}
});
複製代碼
接下來將執行後的值轉換爲 AST,而後替換掉TaggedTemplateExpression
:
function objToAst(res) {
let str = JSON.stringify(res);
if (str == null) {
str = "undefined";
}
const variableDeclarationNode = babel.template(`var x = ${str}`, {})();
// 取出初始化表達式的 AST
return variableDeclarationNode.declarations[0].init;
}
複製代碼
這裏@babel/template
就派上用場了,它能夠將字符串代碼解析成 AST,固然直接使用parse
方法解析也是能夠的。
Ok, 文章到這裏基本結束了。本文對‘宏’進行了深刻的討論,從 C
語言的文本替換宏到瀕死的Sweet.js
, 最後介紹了babel-plugin-macros
.
Babel Macro 本質上仍是Babel 插件,只不過它是模塊化的,你要使用它必須顯式地導入。和‘正統’宏相比, Babel Macro 直接操做 AST,須要你掌握編譯原理, ‘正統’宏能夠實現的東西, Babel Macro也能夠實現(例如衛生宏). 雖然相比Babel插件略有簡化,仍是比較囉嗦。另外Babel Macro 不能建立新的語法,這使得它能夠和現有的工具生態保持兼容。
最後!打開腦洞 🧠,Babel Macro 能夠作不少有意思的東西,查看《Awesome babel macros》。不過要謹記:‘顯式好於隱式,清晰的代碼優於簡潔的代碼’
截止 2019.10.10 掘金粉絲數已經突破 ✨2000✨,繼續關注我,點贊給我支持。