由 Babel 理解前端編譯原理
大廠技術(shù)??堅(jiān)持周更??精選好文
背景
我們知道編程語言主要分為「編譯型語言」和「解釋型語言」,編譯型語言是在代碼運(yùn)行前編譯器將編程語言轉(zhuǎn)換成機(jī)器語言,運(yùn)行時(shí)不需要重新翻譯,直接使用編譯的結(jié)果就行了。而解釋型語言也是需要將編程語言轉(zhuǎn)換成機(jī)器語言,但是是在運(yùn)行時(shí)轉(zhuǎn)換的。
通常我們都將 JavaScript 歸類為「解釋型語言」,以至于很多人都誤以為前端代碼是不需要編譯的,但其實(shí) JavaScript 引擎進(jìn)行編譯的步驟和傳統(tǒng)的編譯語言非常相似,只不過與傳統(tǒng)的編譯語言不同,它不是提前編譯的。并且隨著現(xiàn)代瀏覽器和前端領(lǐng)域的蓬勃發(fā)展,編譯器在前端領(lǐng)域的應(yīng)用越來越廣泛,就日常工作而言,包括但不限于以下幾個(gè)方面:
v8 引擎、typescript 編譯器(tsc)
webpack loader 編譯器(acorn[19]),babel、SWC 等編譯工具。
angular、Vue 等框架的模板編譯器、jsx
作為前端開發(fā),我們沒必要對(duì)這些編譯器或者底層的編譯原理了如指掌,但是如果能對(duì)編譯原理有一些基本的認(rèn)識(shí),也能夠?qū)窈蟮娜粘i_發(fā)很有幫助。本文就帶領(lǐng)大家學(xué)習(xí)下編譯原理的一些基本概念,并以 Babel 為例講解下前端編譯的基本流程。

概述
我們先來回顧下編譯原理的基本知識(shí),從宏觀上來說,編譯本質(zhì)上是一種轉(zhuǎn)換技術(shù),從一門編程語言轉(zhuǎn)換成另一門編程語言,或者從高級(jí)語言轉(zhuǎn)換成低級(jí)語言,或者從高級(jí)語言到高級(jí)語言,所謂的高級(jí)語言和低級(jí)語言主要是指下面的區(qū)分:
高級(jí)語言:有很多用于描述邏輯的語言特性,比如分支、循環(huán)、函數(shù)、面向?qū)ο蟮?,接近人的思維,可以讓開發(fā)者快速的通過它來表達(dá)各種邏輯。比如 c++、javascript。
低級(jí)語言:與硬件和執(zhí)行細(xì)節(jié)有關(guān),會(huì)操作寄存器、內(nèi)存,具體做內(nèi)存與寄存器之間的復(fù)制,需要開發(fā)者理解熟悉計(jì)算機(jī)的工作原理,熟悉具體的執(zhí)行細(xì)節(jié)。比如匯編語言、機(jī)器語言。
無論是怎樣的編譯過程,基本都會(huì)是下面的一個(gè)過程:
上面的約定的編譯規(guī)則,就是指各種編程語言的語法規(guī)則,不同的編譯器會(huì)產(chǎn)出不同的“編譯結(jié)果”,例如 C/C++ 語言經(jīng)過編譯得到二進(jìn)制的機(jī)器碼,然后交給操作系統(tǒng),例如當(dāng)我們運(yùn)行 tsc 命令就會(huì)將 TS 代碼編譯為 js 代碼,再比如執(zhí)行 babel 命令會(huì)將 es6+ 的代碼編譯為指定目標(biāo)(es5)的 js 代碼。
一般來說,整個(gè)編譯過程主要分為兩個(gè)階段:編譯 前端和編譯后端,大致分為下面的幾個(gè)過程:
從上圖可以看到,編譯前端主要就是幫助計(jì)算機(jī)閱讀源代碼并理解源代碼的結(jié)構(gòu)、含義、作用等,將源代碼由一串無意義的字符流解析為一個(gè)個(gè)的有特定含義的構(gòu)件。通常情況下,編譯前端會(huì)產(chǎn)生一種用于給編譯后端消費(fèi)的中間產(chǎn)物,比如我們常見的抽象語法樹 AST,而編譯后端則是在前端解析的結(jié)果和基礎(chǔ)上,進(jìn)一步優(yōu)化和轉(zhuǎn)換并生成最終的目標(biāo)代碼。
上下文無關(guān)文法
前面提到編譯器會(huì)根據(jù)「約定的編譯規(guī)則」進(jìn)行編譯,這里「約定的編譯規(guī)則」就是指「上下文無關(guān)文法(CFG)[20]」。CFG 用于在理論上的形式化定義一門語言的語法,或者說,用于系統(tǒng)地描述程序設(shè)計(jì)語言的構(gòu)造(比如表達(dá)式和語句)。
實(shí)際上,幾乎所有程序設(shè)計(jì)語言都是通過上下文無關(guān)文法來定義的,與正則表達(dá)式比較像,但是比正則表達(dá)式功能更強(qiáng)大,它能表達(dá)非常復(fù)雜的文法,比如 C 語言語法用正則表達(dá)式來表示不可能做到,但是可以用 CFG 的一組規(guī)則來表達(dá)。
要理解上下文無關(guān)文法,需要先理解下面幾個(gè)概念:
終結(jié)符:可以理解為基礎(chǔ)符號(hào),詞法符號(hào),是不可替代的,是固定存在的,不能通過文法規(guī)則生成的。
非終結(jié)符:句法變量,是可以替代的
產(chǎn)生式規(guī)則:語法是由終結(jié)符集、非終結(jié)符集和產(chǎn)生式規(guī)則共同組成。產(chǎn)生式規(guī)則定義了符號(hào)之間如何轉(zhuǎn)換替代。規(guī)則的左側(cè)是規(guī)則頭,即非終結(jié)符,是可以被替代的符號(hào);右側(cè)是產(chǎn)生式,是具體的內(nèi)容。
例如下面 a, b, c, d 為終結(jié)符(用小寫表示),(S, A) 為非終結(jié)符(用大寫表示)。S -> cAd, A -> a | ab 表示產(chǎn)生式規(guī)則。S->cAd,然后可以產(chǎn)生"cad",“cabd” 等符合文法的內(nèi)容
S?->?cAd
A?->?a?|?ab
上下文無關(guān)文法比較抽象,不是這里學(xué)習(xí)的重點(diǎn),感興趣的話可以專門深入了解下,這里知乎上也有篇回答可以參考下 應(yīng)該如何理解「上下文無關(guān)文法」?[21]
下面我們來簡(jiǎn)單模擬下如何用 CFG 來定義一門語言的語法, 我們假設(shè)一個(gè)極其簡(jiǎn)單的語言,這個(gè)語言只能像 js 那樣聲明整數(shù)型常量,以及聲明不接受任何參數(shù)且只能直接返回常量加法的箭頭函數(shù)。
const?a?=?10
const?b?=?20
const?c?=?()?=>??a?+?b
這個(gè)語言的文法表達(dá)如下:
program?::?statement+
statement?::?declare?|?func
declare?::?CONST?VARIABLE?ASSIGN?INTEGER
func?::?CONST?LPAREN?RPAREN?ARROW?expression
expression?::?VARIABLE?+?VARIABLE
CONST??::?"const"
ASSIGN?::?"="
LPAREN?::?"("
RPAREN?::?")"
ARROW??::?"=>"
INTEGER?::?\d+
VARIABLE?::?\w[\w\d$_]*
可以看出,整個(gè)文法的表達(dá),涵蓋了很多正則表達(dá)式的概念。該表達(dá)是一種自頂向下的規(guī)范:
首先入口約束了程序(program)是由一條(及以上)的表達(dá)式(statement)構(gòu)成,
而表達(dá)式又可以由聲明語句(declare)或函數(shù)語句(func)構(gòu)成。
聲明語句依次由 const、關(guān)鍵字、符號(hào) =、整數(shù) 從左到右排列構(gòu)成。
整數(shù)的定義則直接使用正則表達(dá)式來約束。函數(shù)語句也是類似。
大家可以觀察到,上述的文法分成了上下兩個(gè)大的部分。上半部分定義了語句以及由語句遞歸構(gòu)造的表達(dá),通常稱為「語法規(guī)則」(grammar rules);下半部分定義了可通過排列構(gòu)成語句的基本詞匯,通常稱為「詞法規(guī)則」(lexer rules)。
在實(shí)踐中,詞法規(guī)則往往沒有單獨(dú)羅列,而是直接寫入到語法規(guī)則中。比如上述文法可簡(jiǎn)化為:
program?::?statement+
statement?::?declare?|?func
declare?::?"const"?variable?"="?integer
func?::?"const"?variable?"="?"("?")"?"=>"?expression
expression?::?variable?+?variable
variable?::?\w[\w\d$_]*
integer??::?\d+
上面的文法表達(dá)形式叫做 BNF[22],它是用來描述上下文無關(guān)文法的一種描述語言,形式為<符號(hào)> ::= <使用符號(hào)的表達(dá)式>,這里的 <符號(hào)> 是非終結(jié)符,而表達(dá)式由一個(gè)符號(hào)序列,或用指示選擇的豎杠 '|' 分隔的多個(gè)符號(hào)序列構(gòu)成,每個(gè)符號(hào)序列整體都是左端的符號(hào)的一種可能的替代。從未在左端出現(xiàn)的符號(hào)就是終結(jié)符。
有了 BNF,我們就可以實(shí)現(xiàn)語言文法的具體化、公式化,甚至可以自己實(shí)現(xiàn)一個(gè)語言來解決特定領(lǐng)域的問題。再來看個(gè)例子,用 BNF 來描述四則運(yùn)算:
result?::=?number?("+"|"-")?exp?|?number?//?非終結(jié)符
exp?::=?number("*"|"/")?exp?|?number?//?非終結(jié)符
number?::=?[0-9]+?//?終結(jié)符
另外,我們也可以窺探下 ECMA 和 JSON 的 BNF:
JSON Schema 的 BNF:Syntax - JSON Schema[23]
ECMA 的 BNF:function&class bnf[24]
編譯器工作流程
接下來我們就相對(duì)深入的來看一下編譯的各個(gè)階段。
詞法分析
就像我們學(xué)習(xí)一門語言的第一步是學(xué)習(xí)單詞一樣,編譯器識(shí)別源代碼的第一步就是要要進(jìn)行分詞,識(shí)別出每一個(gè)單詞或符號(hào)。這個(gè)階段,詞法分析器會(huì)將源代碼拆分成一組 token 串:
首先,通過對(duì)源代碼的字符串從左到右進(jìn)行掃描,以空白字符(空格、換行、制表符等)為分隔符,拆分為一個(gè)個(gè)無類型的 token 。
其次,再根據(jù)詞法規(guī)則,利用「有限狀態(tài)機(jī)[25]」對(duì)第一步拆分的 Token 進(jìn)行字符串模式匹配,以識(shí)別每一個(gè) Token 的類型(v8 token.h[26])。
一般而言,token 是一個(gè)有類型和值的數(shù)據(jù)結(jié)構(gòu),而 token 流簡(jiǎn)單理解可以是 token 數(shù)組。以下面一行代碼為例:
const?name?=?'xujianglong';
//?根據(jù)?js?的語法規(guī)則,?大致會(huì)生成如下的?token?流
[
??{?type:?"CONST",?value:?"const"?},
??{?type:?"IDENTIFIER",?value:?"name"?},
??{?type:?"ASSIGN",?value:?"="?},
??{?type:?"STRING",?value:?"xujianglong"?},
??{?type:?"SEMICOLON",?value:?";"?},
]
那么有限狀態(tài)機(jī)是個(gè)什么概念呢?它是怎么把字符串代碼轉(zhuǎn)化為 token 的呢?
首先我們想一下,詞法描述的是最小的單詞格式,比如上面例子的那一行代碼為例,利用空白字符拆分成這幾個(gè) token:['const','name','=','xujianglong',';'],但是怎么去識(shí)別每種 token 的類型呢,最簡(jiǎn)單粗暴的方式我們可以寫個(gè) if else語句或者寫個(gè)正則,但是這樣貌似不太優(yōu)雅且不容易維護(hù),而使用狀態(tài)機(jī)是許多編程語言都使用的方式。
有限狀態(tài)機(jī)(英語:finite-state machine,縮寫:FSM)又稱有限狀態(tài)自動(dòng)機(jī)(英語:finite-state automation,縮寫:FSA),簡(jiǎn)稱狀態(tài)機(jī),是表示有限個(gè)狀態(tài)以及在這些狀態(tài)之間的轉(zhuǎn)移和動(dòng)作等行為的數(shù)學(xué)計(jì)算模型。
如圖所示,用戶從其他狀態(tài)機(jī)進(jìn)入 S1 狀態(tài)機(jī),如果用戶輸入 1,則繼續(xù)進(jìn)入 S1 狀態(tài)機(jī),如果用戶輸入了 0,則進(jìn)入下一個(gè)狀態(tài)機(jī) S2。在 S2 狀態(tài)機(jī)中,如果用戶繼續(xù)輸入 1,則繼續(xù)進(jìn)入 S2 狀態(tài)機(jī),如果用戶輸入了 0,則回到 S1 狀態(tài)機(jī)。這是一個(gè)循環(huán)的過程。
聽起來有點(diǎn)抽象,對(duì)比到代碼分詞中來說,我們可以把每個(gè)單詞的處理過程當(dāng)成一種狀態(tài),將整體的輸入(源代碼)按照每個(gè)字符依次去讀取,根據(jù)每次讀取到的字符來更改當(dāng)前的狀態(tài),每個(gè) token 識(shí)別完了就可以拋出來。我們舉個(gè)簡(jiǎn)單的四則運(yùn)算的例子:10 + 20 - 30
首先我們定義了三種狀態(tài)機(jī),分別是 NUMBER 代表數(shù)值,ADD 代表加號(hào),SUB 代表減號(hào):
當(dāng)分析到 "1" 時(shí),因?yàn)楸敬屋斎胛覀冃枰淖儬顟B(tài)機(jī)內(nèi)部狀態(tài)為 NUMBER,繼續(xù)迭代下一個(gè)字符 “0”,此時(shí)因?yàn)?"1" 和 "0" 是一個(gè)整體可以不被分開的。
當(dāng)分析到 "+" 時(shí),狀態(tài)機(jī)中輸入為 “+”, 顯然 “+” 是一個(gè)運(yùn)算符號(hào),它并不能和上一次的 “10” 拼接在一起。所以此時(shí)狀態(tài)改變,我們將上一次的 currentToken 也就是 "10" 推入 tokens 中,同時(shí)改變狀態(tài)機(jī)狀態(tài)為 ADD。
依次類推,最終會(huì)輸出如下 tokens 數(shù)組:
[
??{?type:?"NUMBER",?value:?"10"?},
??{?type:?"ADD",?value:?"+"?},
??{?type:?"NUMBER",?value:?"20"?},
??{?type:?"SUB",?value:?"-"?},
??{?type:?"NUMBER",?value:?"30"?},
]
語法分析
在這個(gè)階段,語法分析器(parser)會(huì)將詞法分析中得到的 token 數(shù)組轉(zhuǎn)化為 抽象語法樹 AST 。比如前面定義變量的那行代碼可以在這個(gè)在線工具 AST explorer[27] 中查看生成的 AST:
{
??"type":?"Program",
??"start":?0,
??"end":?28,
??"body":?[
????{
??????"type":?"VariableDeclaration",
??????"start":?1,
??????"end":?28,
??????"declarations":?[
????????{
??????????"type":?"VariableDeclarator",
??????????"start":?7,
??????????"end":?27,
??????????"id":?{
????????????"type":?"Identifier",
????????????"start":?7,
????????????"end":?11,
????????????"name":?"name"
??????????},
??????????"init":?{
????????????"type":?"Literal",
????????????"start":?14,
????????????"end":?27,
????????????"value":?"xujianglong",
????????????"raw":?"'xujianglong'"
??????????}
????????}
??????],
??????"kind":?"const"
????}
??],
??"sourceType":?"module"
}
對(duì)于 JavaScript 語言來說,AST 也有一套約定的規(guī)范:GitHub - estree/estree: The ESTree Spec[28],社區(qū)稱之為 estree,借助這個(gè)規(guī)范,整個(gè)前端社區(qū)的一些工具便可以產(chǎn)出一套統(tǒng)一的數(shù)據(jù)格式而無需關(guān)心下游,下游的消費(fèi)類工具統(tǒng)一使用這個(gè)統(tǒng)一的格式進(jìn)行處理而無需關(guān)心上游,這樣就做到了上下游的解耦。以 webpack 為例,其底層是 acorn[29] 工具,acorn 會(huì)把 js 源碼轉(zhuǎn)化為上述的標(biāo)準(zhǔn) estree,而 webpack 作為下游便可以消費(fèi)該 estree,比如遍歷,提取和分析 require/import 依賴,轉(zhuǎn)換代碼并輸出。
生成 AST 的過程需要遵循語法規(guī)則(用上下文無關(guān)文法表示),在上面代碼中,我們用到了「VariableDeclaration」,其語法規(guī)則可以表示為:
VariableDeclaration?::?Kind?Identifier?Init?;
Kind?::?Const?|?Let?|?Var;
Init?::?'='?Expression?|?Identifier?|?Literal;
Expression?::?BinaryExpression?|?ConditionalExpression?|?...;
Literal?::?StringLiteral?|?...;
有了語法規(guī)則之后,我們就需要思考編譯器是如何將 token 流,在語法規(guī)則的約束下,轉(zhuǎn)換成 AST 的呢?生成 AST 也主要是兩大方向:
一是將文法規(guī)則的約束硬編碼到編譯器的代碼邏輯中,這種是特定語言的編譯器使用的常見方案,這種方案往往是人工編寫 parse 代碼,對(duì)輸入源碼的各種錯(cuò)誤和異??梢愿?xì)致地報(bào)告和處理。比如前面提到的 arorn,以及 tsc,babel,以及熟悉的 vue,angular 的模板編譯器等,都主要是這種方法。
二是使用自動(dòng)生成工具將文法規(guī)則直接轉(zhuǎn)換成語法 parse 代碼。這種更常用于非特定的編程語言,比如一些業(yè)務(wù)中自定義的簡(jiǎn)單但易變的語法,或僅僅只是字符串文本的復(fù)雜處理規(guī)則。
我們這里以第一種方式最基礎(chǔ)的 「遞歸下降算法」(遞歸下降的編譯技術(shù),是業(yè)界最常使用的手寫編譯器實(shí)現(xiàn)編譯前端的技術(shù)之一,感興趣可以專門去深入研究下)為例,簡(jiǎn)單描述示例代碼生成 AST 的過程:
嘗試匹配?VariableDeclaration
匹配到?Const
匹配到?Identifier
嘗試匹配?Init,遞歸下降
匹配到?'='
嘗試匹配?Expression,遞歸下降
匹配失敗,回溯
匹配到?Literal,回溯
VariableDeclaration?匹配成功,構(gòu)造相應(yīng)類型節(jié)點(diǎn)插入?AST
語義分析
并不是所有的編譯器都有語義分析,比如 Babel 就沒有。不過對(duì)于其它大部分編程語言(包括 TypeScript)的編譯器來說,是有語義分析這一步驟的,特別是靜態(tài)類型語言,類型檢查就屬于語義分析的其中一個(gè)步驟
語義分析階段,編譯器開始對(duì) AST 進(jìn)行一次或多次的遍歷,檢查程序的語義規(guī)則。主要包括聲明檢查和類型檢查,如上一個(gè)賦值語句中,就需要檢查:
語句中的變量 name 是否被聲明過
const 類型變量是否被改變
加號(hào)運(yùn)算的兩個(gè)操作數(shù)的類型是否匹配
函數(shù)的參數(shù)數(shù)量和類型是否與其聲明的參數(shù)數(shù)量及類型匹配
語義檢查的步驟和人對(duì)源代碼的閱讀和理解的步驟差不多,一般都是在遍歷 AST 的過程中,遇到變量聲明和函數(shù)聲明時(shí),則將變量名--類型、函數(shù)名--返回類型--參數(shù)數(shù)量及類型等信息保存到符號(hào)表里,當(dāng)遇到使用變量和函數(shù)的地方,則根據(jù)名稱在符號(hào)表中查找和檢查,查找該名稱是否被聲明過,該名稱的類型是否被正確的使用等等。
語義檢查時(shí),也會(huì)對(duì)語法樹進(jìn)行一些優(yōu)化,比如將只含常量的表達(dá)式先計(jì)算出來,如:
a?=?1?+?2?*?9;
會(huì)被優(yōu)化成:
a?=?19;
語義分析完成后,源代碼的結(jié)構(gòu)解析就已經(jīng)完成了,所有編譯期錯(cuò)誤都已被排除,所有使用到的變量名和函數(shù)名都綁定到其聲明位置(地址)了,至此編譯器可以說是真正理解了源代碼,可以開始進(jìn)行代碼生成和代碼優(yōu)化了。
中間代碼生成與優(yōu)化
一般的編譯器并不直接生成目標(biāo)代碼,而是先生成某種中間代碼,然后再生成目標(biāo)代碼。之所以先生成中間代碼,主要有以下幾個(gè)原因:
為了降低編譯器開發(fā)的難度,將高級(jí)語言翻譯成中間代碼、將此中間代碼再翻譯成目標(biāo)代碼的難度都比直接將高級(jí)語言翻譯成目標(biāo)代碼的難度要低。
為了增加編譯器的模塊化、可移植性和可擴(kuò)展性,一般來說,中間代碼既獨(dú)立于任何高級(jí)語言,也獨(dú)立于任何目標(biāo)機(jī)器架構(gòu),這就為開發(fā)出適應(yīng)性廣泛的編譯器提供了媒介
為了代碼優(yōu)化,一般來說,計(jì)算機(jī)直接生成的代碼比人手寫的匯編要龐大、重復(fù)很多,計(jì)算機(jī)科學(xué)家們對(duì)一些具有固定格式的中間代碼的進(jìn)行大量的研究工作,提出了很多廣泛應(yīng)用的、效率非常高的優(yōu)化算法,可以對(duì)中間代碼進(jìn)行優(yōu)化,比直接對(duì)目標(biāo)代碼進(jìn)行優(yōu)化的效果要好很多。
JavaScript 的編譯器 v8 引擎早期是沒有中間代碼生成的,直接從 AST 生成本地可執(zhí)行的代碼,但由于缺少了轉(zhuǎn)換為字節(jié)碼這一中間過程,也就減少了優(yōu)化代碼的機(jī)會(huì)。為了提高性能, v8 開始采用了引入字節(jié)碼的架構(gòu),先把 AST 編譯為字節(jié)碼,再通過 JIT 工具轉(zhuǎn)換成本地代碼。
v8 引擎的編譯過程這里不做過多介紹,后續(xù)可作為單獨(dú)的分享。
V8 引擎詳解(三)——從字節(jié)碼看 V8 的演變[30]
理解 V8 的字節(jié)碼「譯」[31]
生成目標(biāo)代碼
有了中間代碼后,目標(biāo)代碼的生成是相對(duì)容易的,因?yàn)橹虚g代碼在設(shè)計(jì)的時(shí)候就考慮到要能輕松生成目標(biāo)代碼。不同的高級(jí)語言的編譯器生成的目標(biāo)代碼不一樣,如:
C、C++、Go,目標(biāo)代碼都是匯編語言(可能目標(biāo)代碼不一樣),在經(jīng)過匯編器最終得到機(jī)器碼;
Java 經(jīng)過 javac 編譯后,生成字節(jié)碼,只經(jīng)過了編譯的詞法分析、語法分析、語義分析和中間代碼生成,運(yùn)行時(shí)再由解釋器逐條將字節(jié)碼解釋為機(jī)器碼來執(zhí)行;
JavaScript,在運(yùn)行時(shí)經(jīng)過整個(gè)編譯流程,不過也是先生成字節(jié)碼,然后解析器解析字節(jié)碼執(zhí)行;
至于 webpack、babel 這些前端工具則是最終編譯成相應(yīng)的 js 代碼。
Babel 的編譯流程
前面我們提到,一般編譯器是指高級(jí)語言到低級(jí)語言的轉(zhuǎn)換工具,特殊地像前端的一些工具類型的轉(zhuǎn)換,如 ts 轉(zhuǎn) js,js 轉(zhuǎn) js 等這些都是高級(jí)語言到高級(jí)語言的轉(zhuǎn)換工具,通常被叫做轉(zhuǎn)換編譯器,簡(jiǎn)稱轉(zhuǎn)譯器 (Transpiler),所以 Babel 就是是一個(gè) JavaScript 編譯器。
Babel 主要用于將采用 ECMAScript 2015+ 語法編寫的代碼轉(zhuǎn)換為向后兼容的 JavaScript 語法,并且還可以把目標(biāo)環(huán)境不支持的 api 進(jìn)行 polyfill。以便能夠運(yùn)行在當(dāng)前和舊版本的瀏覽器或其他環(huán)境中。例如下面的例子:
// Babel 輸入:ES2015 箭頭函數(shù)
[1,?2,?3].map(n?=>?n?+?1);
// Babel 輸出:ES5 語法實(shí)現(xiàn)的同等功能
[1,?2,?3].map(function(n)?{
??return?n?+?1;
});
Babel 的工作流程可分為如下幾個(gè)步驟:

下面詳細(xì)介紹下 babel 工作流程的各個(gè)階段。
parse(解析)
parse 這個(gè)階段將原始代碼字符串轉(zhuǎn)為 AST 樹,parse 廣義上來說包括了我們前面編譯流程中講到的 詞法分析、語法分析這兩個(gè)階段。parse 過程中會(huì)有一些 babel 插件,讓 babel 可以解析出更多的語法,比如 jsx。
parse(sourceCode)?=>?AST
parse 階段,主要通過 @babel/parser這個(gè)包進(jìn)行轉(zhuǎn)換,之前叫 babylon,是基于 acorn 實(shí)現(xiàn)的,擴(kuò)展了很多語法,可以支持 esnext(現(xiàn)在支持到 es2020)、jsx、flow、typescript 等語法的解析,其中 jsx、flow、typescript 這些非標(biāo)準(zhǔn)的語法的解析需要指定語法插件。
我們可以手動(dòng)調(diào)用下 parser 的方法進(jìn)行轉(zhuǎn)換,便會(huì)得到一份 AST:
const?parser?=?require("@babel/parser");
const?fs?=?require('fs');
const?code?=?`function?square(n)?{
??return?n?*?n;};`
const?result?=?parser.parse(code)
console.log(result);
最終輸出的 AST 如下所示:
{
??type:?"FunctionDeclaration",
??id:?{
????type:?"Identifier",
????name:?"square"
??},
??params:?[{
????type:?"Identifier",
????name:?"n"
??}],
??body:?{
????type:?"BlockStatement",
????body:?[{
??????type:?"ReturnStatement",
??????argument:?{
????????type:?"BinaryExpression",
????????operator:?"*",
????????left:?{
??????????type:?"Identifier",
??????????name:?"n"
????????},
????????right:?{
??????????type:?"Identifier",
??????????name:?"n"
????????}
??????}
????}]
??}
}
可以看到 AST 的每一層都擁有相似的結(jié)構(gòu),這樣的每一層結(jié)構(gòu)也被叫做 ? 節(jié)點(diǎn)(Node), 一個(gè) AST 可以由單一的節(jié)點(diǎn)或是成百上千個(gè)節(jié)點(diǎn)構(gòu)成,它們組合在一起可以描述用于靜態(tài)分析的程序語法。每個(gè)節(jié)點(diǎn)都有個(gè)字符串的 type 類型,用來表示節(jié)點(diǎn)的類型,babel 中定義了包含所有 JavaScript 語法的類型,如:
聲明語句:如 FunctionDeclaration、VaraibaleDeclaration、ClassDeclaration等聲明;
標(biāo)識(shí)符: Identifier,變量或函數(shù)參數(shù)。
字面量: StringLiteral、NumbericLiteral、BooleanLiteral等字面量類型;
語句: WhileStatement、ReturnStatement等語句;
表達(dá)式: FunctionExpression、BinaryExpression、AssignmentExpression等。
所有的這些節(jié)點(diǎn)通過嵌套形成了 AST 樹,例如一個(gè)變量賦值語句形成的樹形結(jié)構(gòu)如下所示:

transform(轉(zhuǎn)化)
transform 階段主要是對(duì)上一步 parse 生成的 AST 進(jìn)行深度優(yōu)先遍歷,從而對(duì)于匹配節(jié)點(diǎn)進(jìn)行增刪改查來修改樹形結(jié)構(gòu)。在 babel 中會(huì)用所配置的 plugin 或 presets 對(duì) AST 進(jìn)行修改后,得到新的 AST,我們的 babel 插件大部分用于這個(gè)階段。
transform(AST,?BabelPlugins)?=>?newAST
babel 中通過 @babel/traverse 這個(gè)包來對(duì) AST 進(jìn)行遍歷,找出需要修改的節(jié)點(diǎn)再進(jìn)行轉(zhuǎn)換,這個(gè)過程有點(diǎn)類似我們操作 DOM 樹。當(dāng)我們談及“進(jìn)入”一個(gè)節(jié)點(diǎn),實(shí)際上是說我們?cè)谠L問它們, 之所以使用這樣的術(shù)語是因?yàn)橛幸粋€(gè)訪問者模式(visitor)[32]的概念。訪問者 visitor 是一個(gè)用于 AST 遍歷的跨語言的模式。簡(jiǎn)單的說它就是一個(gè)對(duì)象,定義了用于在一個(gè)樹狀結(jié)構(gòu)中獲取具體節(jié)點(diǎn)的方法。
visitor 是一個(gè)由各種 type 或者是 enter 和 exit 組成的對(duì)象,完成某種類型節(jié)點(diǎn)的"進(jìn)入"或"退出"兩個(gè)步驟則為一次訪問,在其中可以定義在遍歷 AST 的過程中匹配到某種類型的節(jié)點(diǎn)后該如何操作,目前支持的寫法如下:
traverse(ast,?{
??/*?VisitNodeObject?*/
??enter(path,?state)?{},
??exit(path,?state)?{},
??/*?[Type?in?t.Node["type"]]?*/
??Identifier(path,?state)?{},?//?進(jìn)入?Identifier(標(biāo)識(shí)符節(jié)點(diǎn))?節(jié)點(diǎn)時(shí)調(diào)用
??StringLiteral:?{?//?進(jìn)入?StringLiteral(字符串節(jié)點(diǎn))?節(jié)點(diǎn)時(shí)調(diào)用
????enter(path,?state)?{},?//?進(jìn)入該節(jié)點(diǎn)時(shí)的操作
????exit(path,?state)?{},??//?離開該節(jié)點(diǎn)時(shí)的操作
??},
??'FunctionDeclaration|VariableDeclaration'(path,?state)?{},?//?//?進(jìn)入?FunctionDeclaration?和?VariableDeclaration?節(jié)點(diǎn)時(shí)調(diào)用
})
訪問一個(gè)節(jié)點(diǎn)的過程如下:
下面我們看一個(gè)簡(jiǎn)單的例子:
const?parser?=?require('@babel/parser')
const?traverse?=?require('@babel/traverse').default
const?fs?=?require('fs');
const?code?=?`function?square(n)?{
??return?n?*?n;
}`;
const?ast?=?parser.parse(code);
const?newAst?=?traverse(ast,?{
??enter(path)?{
????if?(path.isIdentifier({
????????name:?"n"
??????}))?{
??????path.node.name?=?"x";
????}
??},
??FunctionDeclaration:?{
????enter()?{
??????console.log('enter?function?declaration')
????},
????exit()?{
??????console.log('exit?function?declaration')
????}
??}
});
上面的例子中,通過識(shí)別標(biāo)識(shí)符把 "n" 換成了 "x",其中的 path 是遍歷過程中的路徑,會(huì)保留上下文信息,有很多屬性和方法,可以在訪問到指定節(jié)點(diǎn)后,根據(jù) path 進(jìn)行自定義操作,比如:
path.node指向當(dāng)前 AST 節(jié)點(diǎn),path.parent指向父級(jí) AST 節(jié)點(diǎn);
path.getSibling、path.getNextSibling、path.getPrevSibling獲取兄弟節(jié)點(diǎn);
path.isxxx判斷當(dāng)前節(jié)點(diǎn)是不是 xx 類型;
path.insertBefore、path.insertAfter插入節(jié)點(diǎn);
path.replaceWith、path.replaceWithMultiple、replaceWithSourceString替換節(jié)點(diǎn);
path.skip跳過當(dāng)前節(jié)點(diǎn)的子節(jié)點(diǎn)的遍歷,path.stop結(jié)束后續(xù)遍。
有了 @babel/traverse 我們可以在 tranform 階段做很多自定義的事情,例如刪除 console.log 語句,在特定的地方插入一些表達(dá)式等等,從而影響輸出結(jié)果。我們?cè)倥e一個(gè)例子,在 console 語句中增加位置信息的輸出,形如:console.log('[18,0]', 111)。
import?*?as?t?from?"@babel/types";?//?用來創(chuàng)建一些?AST?和判斷?AST?的類型
traverse(ast,?{
??visitor:?{
????CallExpression(path,?state)?{
??????const?callee?=?path.node.callee;
??????if?(
????????callee.object.name?===?'console'?&&
????????['log',?'info',?'error'].includes(callee.property.name)
??????)?{
????????const?{?line,?column?}?=?path.node.loc.start;
????????const?locationNode?=?types.stringLiteral(?`[?${line}?,?${column}?]`?);
????????path?.node.arguments.unshift(locationNode);??????}
????},
??},
})
generate(生成)
AST 轉(zhuǎn)換完之后就要輸出目標(biāo)代碼字符串,這個(gè)階段是個(gè)逆向操作,用新的 AST 來生成我們所需要的代碼,在生成階段本質(zhì)上也是遍歷抽象語法樹,根據(jù)抽象語法樹上每個(gè)節(jié)點(diǎn)的類型和屬性遞歸調(diào)用從而生成對(duì)應(yīng)的字符串代碼,在 babel 中通過 @babel/generator 包的 api 來實(shí)現(xiàn)。
generate(newAST)?=>?newSourceCode
還拿前面的 transform 的代碼舉例,如下所示:
const?parser?=?require('@babel/parser');
const?traverse?=?require('@babel/traverse').default;
const?generate?=?require('@babel/generator').default;
const?code?=?`function?square(n)?{
??return?n?*?n;
}`;
const?ast?=?parser.parse(code);
traverse(ast,?{
??enter(path)?{
????if?(path.isIdentifier({
????????name:?"n"
??????}))?{
??????path.node.name?=?"x";
????}
??},
});
const?output?=?generate(ast,?{},?code);
console.log(output)
//?{
//???code:?'function?square(x)?{?return?x?*?x;}',
//???map:?null,
//???rawMappings:?undefined
//?}
由上面代碼可以看到,函數(shù)里的變量 "n" 被替換成了 "x"。
總結(jié)
編譯原理涉及大量的概念及知識(shí),以實(shí)現(xiàn)完整編譯鏈路的 GCC 編譯器來看,單源代碼就 600w 行,足以說明其水深且寬,但編譯原理帶來的價(jià)值是巨大的,可以說是編譯原理的進(jìn)步才有各種高級(jí)語言百花齊放,進(jìn)而提高軟件行業(yè)生產(chǎn)力。Babel 作為前端工程化領(lǐng)域一個(gè)很重要的工具,明白了其編譯流程,對(duì)諸如其他工具 v8 引擎、 tsc、jsx 模板等前端方面的編譯原理便可以融匯貫通,觸類旁通。
???H5-Dooring,讓H5制作更簡(jiǎn)單
???謝謝支持
以上便是本次分享的全部內(nèi)容,希望對(duì)你有所幫助^_^
喜歡的話別忘了?分享、點(diǎn)贊、收藏?三連哦~。
歡迎關(guān)注公眾號(hào)?趣談前端?收獲前端一手好文章~


