前端工程師為什麽要學習編譯原理?
轉自:https://zhuanlan.zhihu.com/p/31096468
前言
普遍的觀點認為,前端就是打好 HTML、CSS、JS 三大基礎,深刻理解語義化標簽,了解 N 種不同的布局方式,掌握語言的語法、特性、內置 API。再學習一些主流的前端框架,使用社區成熟的腳手架,即可快速搭建一個前端項目。勝任前端工作非常容易。再往深處學習,你會發現前端這個領域,總是有學不完的框架、工具、庫,不斷有新的輪子出現。技術推陳出新,版本快速叠代,但萬變不離其宗。工具致力於流程自動化、規範化,服務於簡潔、優雅、高效的編碼,將問題高度抽象化、層次化。在如今前端開源界如此火熱的現狀下,框架的使用者與框架的維護者聯系更加緊密,不僅能深入源碼來更徹底地認識框架,還能夠提出問題,參與討論,貢獻代碼,共同解決技術問題,推進前端生態的發展和壯大。而編譯原理,作為一門基礎理論學科,除了 JS 語言本身的編譯器之外,更成為 Babel、ESLint、Stylus、Flow、Pug、YAML、Vue、React、Marked 等開源前端框架的理論基石之一。了解編譯原理能夠對所接觸的框架有更充分的認識。
什麽是編譯器?
對外部來說,編譯器是一個黑盒子,能夠把一種源語言翻譯為語義上等價的另一種目標語言。從現代高級編譯器的角度講,源語言是高級程序設計語言,容易閱讀與編寫,而目標語言是機器語言,即二進制代碼,能夠被計算機直接識別。從語言系統的處理角度來看,由源程序生成可執行程序的整體工作流程如圖 1 所示:
圖1 源程序生成可執行程序整體工作流程圖
其中,編譯器又分為前端和後端兩個部分。前端包括詞法分析、語法分析、語義分析、中間代碼生成,具有機器無關性,比較有代表性的工具是 Flex、Bison。後端包括中間代碼優化、目標代碼生成,具有機器相關性,比較有代表性的工具是 LLVM。在 Web 前端工程領域,由於宿主環境瀏覽器與 Node.js 的跨平臺特性,我們只需關註編譯器前端部分,就可以充分發揮它的應用價值。為了更好地理解編譯器前端的工作原理,本文將主要以目前被廣泛使用的 Babel 為例,闡述它是如何將源代碼編譯為目標代碼。
Babel
作為新生代 ES 語法編譯器,Babel 在前端工具鏈中占據了非常重要的地位,它嚴格按照 ECMA-262 語言規範,實現對最新語法的解析,而無需等待瀏覽器升級來提供對新特性的支持。Babel 內部所使用的語法解析器是 Babylon,抽象語法樹(簡寫為 AST)的結點類型定義則參考了 Mozilla JS 引擎 SpiderMonkey,並對其進行擴展增強,且支持對 Flow、JSX、TypeScript 語法的解析。它所使用的 Babylon 實現了編譯器中兩個部分,詞法分析和語法分析。
詞法分析
詞法分析是處理源程序的第一部分,主要任務是逐個掃描輸入字符,轉換為詞法單元(Token)序列,傳遞給語法分析器進行語法分析。Token 是一個不可分割的最小單元。例如 var 這三個字符,它只能作為一個整體,語義上不能再被分解,因此它是一個 Token。每個 Token 對象都有能夠被單獨識別的類型屬性和其它附加屬性(操作符優先級、行列號等)。在 Babylon 詞法分析器裏,每個關鍵字是一個 Token ,每個標識符是一個 Token,每個操作符是一個 Token,每個標點符號也都是一個 Token。除此之外,還會過濾掉源程序中的註釋和空白字符(換行符、空格、制表符等)。
對於 Token 的匹配規則,可以根據正則表達式來描述。舉個例子,要匹配一個 Number 類型的 Token,可以檢測是否以 [0-9] 開頭,接著循環或遞歸掃描緊連的後續字符,且需要特別留意 0b、0o、0x 開頭的非十進制數值、科學計數法 e 或 E、小數點等特殊字符,指針不斷後移直至不滿足匹配規則或者到達行末尾。最後生成一個 Number 類型的 Token,附帶值、文件位置等屬性,並加入到 Token 序列中,繼續下一輪掃描。
一個簡單的 Number 類型狀態轉換如圖 2 所示:
圖2 Number 類型狀態轉換示意圖
當然除了 Babylon 手寫詞法分析器之外,這個過程還可以采用有窮自動機(DFA/NFA)的方式實現,通過詞法分析器生成器,把輸入程序(模式匹配規則)自動轉換成一個詞法分析器,這裏不展開闡述。
語法分析
語法分析是詞法分析的下一步,主要任務是掃描來自詞法分析器產生的 Token 序列,根據文法和結點類型定義構造出一棵 AST,傳遞給編譯器前端余下部分。文法描述了程序設計語言的構造規則,用於指導整個語法分析的過程。它由四個部分組成,一組終結符號(也稱 Token)、一組非終結符號、一組產生式和一個開始符號。例如,函數聲明語句的產生式表示形式如圖 3 所示:
圖3 函數聲明語句的產生式
根據文法,語法分析器將 Token 逐個讀入,不斷替換文法產生式體的非終結符號,直至全部將非終結符號替換為終結符號,這個過程被稱為推導。推導又分為兩種方式,最左推導和最右推導。如果總是優先替換產生式體最左側的非終結符號,被稱為最左推導,如果總是優先替換產生式體最右側的非終結符號,被稱為最右推導。
語法分析器按照工作方式來劃分,分為自頂向下分析法和自底向上分析法。自頂向下分析法要求通過最左推導從頂部 ( 根結點 ) 開始構造 AST,常用的分析器有遞歸下降語法分析器、 LL 語法分析器。而自底向上分析法要求通過最右推導從底部 ( 葉子結點 ) 開始構造 AST,常用的分析器有 LR 語法分析器、SLR 語法分析器、LALR 語法分析器。這兩種分析方式在 Babylon 中都有所實踐。
首先是自頂向下分析法,例如變量聲明語句:
var foo = "bar";
經由詞法分析器處理後,會生成 Token 序列:
Token(‘var‘)
Token(‘foo‘)
Token(‘=‘)
Token(‘"bar"‘)
Token(‘;‘)
由 LL(1) 語法分析器進行遞歸下降分析,每次向前查看一個輸入 Token,來決定該用哪種產生式展開。對於變量聲明語句的 FIRST 集合(推導結果的首個 Token 集合),只需檢查輸入 Token 為 Token(‘var‘)、Token(‘let‘)、Token(‘const‘) 三者其中之一,那麽就使用該產生式展開。首先構造 AST 最頂層結點 VariableDeclaration,把 Token(‘var‘) 的值加入到該結點屬性中, 接著逐個讀入其余 Token,根據產生式的非終結符號從左到右的順序,依次構造它的子結點,不斷遞歸下降分析,直至所有 Token 讀入完畢。最後生成的一棵 AST 如圖 4 所示:
圖4 自頂向下分析法產生的 AST 樹
另一種是自底向上分析法,例如成員表達式語句:
foo.bar.baz.qux
我們都知道這條語句等價於:
((foo.bar).baz).qux
而不是:
foo.(bar.(baz.qux))
原因就在於它所設計的文法是左遞歸的,而 LL 語法分析器是無法做到解析左遞歸的文法,這時候只能使用 LR 語法分析器的方式,自底向上地構造 AST。LR 語法分析器的核心是移入 - 歸約分析技術,通過維護一個棧,由下一個輸入 Token 來決定是把它移入棧中還是將棧頂的部分符號進行歸約(把產生式體替換為產生式頭),先構造子結點,再構造父結點,直至棧中所有符號全部歸約。最後生成的一棵 AST 如圖 5 所示:
圖5 自底向上分析法產生的 AST 樹
此外,由 Babylon 構建的完整的 AST 還擁有特殊頂層結點 File 和 Program,它們描述了文件的基本信息、模塊類型等等。
生成代碼
工業級別的語言編譯器,通常還會有語義分析階段,檢查程序上下文是否和語言所定義的語義一致,比如類型檢查,作用域檢查,另一個則是生成中間代碼,比如三地址代碼,用地址和指令來線性描述程序。但由於 Babel 的定位僅僅是對 ES 語法的轉換,這一部分工作可以交給 JS 解釋器引擎來處理。而 Babel 最為特色的部分是它的插件機制,針對不同的瀏覽器版本環境,調用不同的 Babel 插件。通過訪問者模式(一種設計模式)的接口定義,對 AST 進行一遍深度優先遍歷,對指定的匹配到的結點進行修改、刪除、新增、移位,使原先的 AST 轉換為另一棵經過修改的 AST。
一個訪問者模式的接口定義如下:
visitor: {
Identifier(path) {
enter() {
//遍歷AST進入Identifier結點時執行
...
},
exit() {
//遍歷AST離開Identifier結點時執行
...
}
},
...
}
最後一個階段則是生成目標代碼,從 AST 的根結點出發,遞歸下降遍歷,對每個結點都調用一個相關函數,執行語義動作,不斷打印代碼片段,最終生成目標代碼,即經過 babel 編譯後的代碼。
模板引擎
再講到模板引擎,最早誕生於服務端動態頁面的開發,如 JSP、PHP、ASP 等模板引擎,自 Node.js 快速發展以後,前端界又產出了非常多的輪子,包括 EJS、Handlebars、Pug (前身為 Jade)、Mustache 等等,數不勝數。模板引擎技術使得結合數據渲染視圖變得更加靈活,給邏輯的抽象帶來了更多的可能性,數據與內容互不依賴。模板引擎的實現方式有很多種,比較簡單的模板引擎,直接利用字符串替換、拼接的方式實現,比較復雜的模板引擎,例如 Pug,則會有比較完整的詞法分析和語法分析過程,將模板預編譯成 JS 代碼再去動態執行。
例如模板語句:
h1 hello #{name}
經由 Pug 解析器生成的 AST 如圖 6 所示:
圖6 由 Pug 解析器生成的 AST
生成器生成的目標代碼為(偽代碼):
‘<h1>‘ + ‘hello‘ + name + ‘<h1>‘
運行時再調用 new Function 來動態執行代碼:
var compiledFn = new Function(‘local‘, `
with (local) {
return ‘<h1>‘ + ‘hello‘ + name + ‘<h1>‘;
}
`)
compiledFn({
name: ‘world‘
})
最後輸出 HTML 語句:
<h1>hello world</h1>
整個過程由兩部分組成,預編譯階段和運行時階段。當然一個好的模板引擎還會考慮功能、性能與安全兼備,上面的`with`語句是要避免的,還要引入緩存機制,XSS 防範機制,以及更加強大、友好、易於使用的語法糖。
另外值得一提的是以 Angular、React、Vue 為代表的前端 MVVM 框架,無一不引入了模板編譯技術。Vue 作為漸進式的前端解決方案,受到眾多開發者們的青睞,它對視圖的渲染提供了渲染函數和模板兩種方式。使用渲染函數需要調用核心 API 來構建 Virtual DOM 類型,過程相對復雜,編碼量非常大,一旦 DOM 層次嵌套過深,就會造成代碼難以掌控和維護的局面。為了應對這種復雜性,另一種方式則是編寫基於 HTML 的模板,並加入 Vue 特有的標簽、指令、插值等語法,由編譯器來進行從模板到渲染函數的編譯和優化,相對前者更優雅、便捷、易於編碼。
CSS 預處理器
前端布局方式從刀耕火種的純 CSS 年代演進到以 Sass、Less、Stylus 為代表的預處理語言,賦予了 CSS 可編程的能力,定義變量,函數,表達式計算、模塊化等特性,極大地提升了開發人員的生產效率。這些都是編譯技術所帶來的變化。同樣,編譯器對原樣式代碼進行詞法分析,產生 Token 序列。接著,語法分析,生成中間表示,一棵符合定義的 AST。同時,還會為每個程序塊建立一個符號表來記錄變量的名字,屬性,為代碼生成階段的變量作用域分析提供幫助。最後,遞歸下降訪問 AST,生成能夠在瀏覽器環境中直接執行的 CSS 代碼。
以預處理器 Stylus 語法為例:
foo = 14px
body
font-size foo
編譯生成的 AST 為圖 7 所示:
圖7 由 Stylus 解析器生成的 AST
最後生成的目標代碼為:
body {
font-size: 14px;
}
看似簡單容易的代碼轉換背後,編譯器為我們做了許多語法層面的處理,給 CSS 帶來了從未有過的強大的擴展能力,以及底層對編譯速度的持續優化,讓 CSS 的編寫方式更加簡潔高效,易於維護和管理。
寫在最後
寫這篇文章的目的是希望告訴讀者,編譯原理在前端工程領域的應用非常廣泛,可以用來幫助我們解決工程技術上的難點。當然在實際編碼過程中,需要非常得有耐心,細心,考慮各種文法,分析方式,優化手段,寫好測試用例等等。一個良好的編譯器需要精心打磨,不斷優化升級,全方位為開發者服務。如果你沒有學習過編譯原理相關知識,建議尋找相關書籍,系統地學習一遍知識體系。即使在實際日常工作中接觸不到編譯原理,但它對基礎知識的積累與掌握,對編程語言的認識與理解,對框架的學習與運用,對日後職業生涯的發展道路,或多或少都有幫助。
發布於 2018-04-08前端工程師為什麽要學習編譯原理?