前端與編譯原理——用JS寫一個JS直譯器

發表於2018-12-12

圖片描述

說起編譯原理,印象往往只停留在本科時那些枯燥的課程和晦澀的概念。作為前端開發者,編譯原理似乎離我們很遠,對它的理解很可能僅僅侷限於“抽象語法樹(AST)”。但這僅僅是個開頭而已。編譯原理的使用,甚至能讓我們利用JS直接寫一個能執行JS程式碼的直譯器。

專案地址:https://github.com/jrainlau/c…

線上體驗:https://codepen.io/jrainlau/p…

一、為什麼要用JS寫JS的直譯器

接觸過小程式開發的同學應該知道,小程式執行的環境禁止new Functioneval等方法的使用,導致我們無法直接執行字串形式的動態程式碼。此外,許多平臺也對這些JS自帶的可執行動態程式碼的方法進行了限制,那麼我們是沒有任何辦法了嗎?既然如此,我們便可以用JS寫一個解析器,讓JS自己去執行自己。

在開始之前,我們先簡單回顧一下編譯原理的一些概念。

二、什麼是編譯器

說到編譯原理,肯定離不開編譯器。簡單來說,當一段程式碼經過編譯器的詞法分析、語法分析等階段之後,會生成一個樹狀結構的“抽象語法樹(AST)”,該語法樹的每一個節點都對應著程式碼當中不同含義的片段。

比如有這麼一段程式碼:

經過編譯器處理後,它的AST長這樣:

常見的JS編譯器有babylonacorn等等,感興趣的同學可以在AST explorer這個網站自行體驗。

可以看到,編譯出來的AST詳細記錄了程式碼中所有語義程式碼的型別、起始位置等資訊。這段程式碼除了根節點Program外,主體包含了兩個節點VariableDeclarationExpressionStatement,而這些節點裡面又包含了不同的子節點。

正是由於AST詳細記錄了程式碼的語義化資訊,所以Babel,Webpack,Sass,Less等工具可以針對程式碼進行非常智慧的處理。

三、什麼是直譯器

如同翻譯人員不僅能看懂一門外語,也能對其藝術加工後把它翻譯成母語一樣,人們把能夠將程式碼轉化成AST的工具叫做“編譯器”,而把能夠將AST翻譯成目標語言並執行的工具叫做“直譯器”。

在編譯原理的課程中,我們思考過這麼一個問題:如何讓計算機執行算數表示式1+2+3:

當機器執行的時候,它可能會是這樣的機器碼:

而執行這段機器碼的程式,就是直譯器。

在這篇文章中,我們不會搞出機器碼這樣複雜的東西,僅僅是使用JS在其runtime環境下去解釋JS程式碼的AST。由於直譯器使用JS編寫,所以我們可以大膽使用JS自身的語言特性,比如this繫結、new關鍵字等等,完全不需要對它們進行額外處理,也因此讓JS直譯器的實現變得非常簡單。

在回顧了編譯原理的基本概念之後,我們就可以著手進行開發了。

四、節點遍歷器

通過分析上文的AST,可以看到每一個節點都會有一個型別屬性type,不同型別的節點需要不同的處理方式,處理這些節點的程式,就是“節點處理器(nodeHandler)”

定義一個節點處理器:

關於節點處理器的具體實現,會在後文進行詳細探討,這裡暫時不作展開。

有了節點處理器,我們便需要去遍歷AST當中的每一個節點,遞迴地呼叫節點處理器,直到完成對整棵語法書的處理。

定義一個節點遍歷器(NodeIterator):

理論上,節點遍歷器這樣設計就可以了,但仔細推敲,發現漏了一個很重要的東西——作用域處理。

回到節點處理器的VariableDeclaration()方法,它用來處理諸如const a = 1這樣的變數宣告節點。假設它的程式碼如下:

問題在於,處理完變數宣告節點以後,理應把這個變數儲存起來。按照JS語言特性,這個變數應該存放在一個作用域當中。在JS解析器的實現過程中,這個作用域可以被定義為一個scope物件。

改寫節點遍歷器,為其新增一個scope物件

然後節點處理函式VariableDeclaration()就可以通過scope儲存變數了:

關於作用域的處理,可以說是整個JS直譯器最難的部分。接下來我們將對作用域處理進行深入的剖析。

五、作用域處理

考慮到這樣一種情況:

執行結果必然是能夠列印出a的值,然後報錯:Uncaught ReferenceError: b is not defined

這段程式碼就是涉及到了作用域的問題。塊級作用域或者函式作用域可以讀取其父級作用域當中的變數,反之則不行,所以對於作用域我們不能簡單地定義一個空物件,而是要專門進行處理。

定義一個作用域基類Scope

這裡使用了一個叫做simpleValue()的函式來定義變數值,主要用於處理常量:

處理作用域問題思路,關鍵的地方就是在於JS語言本身尋找變數的特性——優先當前作用域,父作用域次之,全域性作用域最後。反過來,在節點處理函式VariableDeclaration()裡,如果遇到塊級作用域且關鍵字為var,則需要把這個變數也定義到父級作用域當中,這也就是我們常說的“全域性變數汙染”。

JS標準庫注入

細心的讀者會發現,在定義Scope基類的時候,其全域性作用域globalScope被賦值了一個standardMap物件,這個物件就是JS標準庫。

簡單來說,JS標準庫就是JS這門語言本身所帶有的一系列方法和屬性,如常用的setTimeoutconsole.log等等。為了讓解析器也能夠執行這些方法,所以我們需要為其注入標準庫:

這樣就相當於往解析器的全域性作用域當中注入了console這個物件,也就可以直接被使用了。

六、節點處理器

在處理完節點遍歷器、作用域處理的工作之後,便可以來編寫節點處理器了。顧名思義,節點處理器是專門用來處理AST節點的,上文反覆提及的VariableDeclaration()方法便是其中一個。下面將對部分關鍵的節點處理器進行講解。

在開發節點處理器之前,需要用到一個工具,用於判斷JS語句當中的returnbreakcontinue關鍵字。

關鍵字判斷工具Signal

定義一個Signal基類:

有了它,就可以對語句當中的關鍵字進行判斷處理,接下來會有大用處。

1、變數定義節點處理器——VariableDeclaration()

最常用的節點處理器之一,負責把變數註冊到正確的作用域。

2、識別符號節點處理器——Identifier()

專門用於從作用域中獲取識別符號的值。

3、字元節點處理器——Literal()

返回字元節點的值。

4、表示式呼叫節點處理器——CallExpression()

用於處理表示式呼叫節點的處理器,如處理func()console.log()等。

5、表示式節點處理器——MemberExpression()

區分於上面的“表示式呼叫節點處理器”,表示式節點指的是person.sayconsole.log這種函式表示式。

6、塊級宣告節點處理器——BlockStatement()

非常常用的處理器,專門用於處理塊級宣告節點,如函式、迴圈、try...catch...當中的情景。

可以看到這個處理器裡面有兩個for...of迴圈。第一個用於處理塊級內語句,第二個專門用於識別關鍵字,如迴圈體內部的breakcontinue或者函式體內部的return

7、函式定義節點處理器——FunctionDeclaration()

往作用當中宣告一個和函式名相同的變數,值為所定義的函式:

8、函式表示式節點處理器——FunctionExpression()

用於定義一個函式:

9、this表示式處理器——ThisExpression()

該處理器直接使用JS語言自身的特性,把this關鍵字從作用域中取出即可。

10、new表示式處理器——NewExpression()

this表示式類似,也是直接沿用JS的語言特性,獲取函式和引數之後,通過bind關鍵字生成一個建構函式,並返回。

11、For迴圈節點處理器——ForStatement()

For迴圈的三個引數對應著節點的inittestupdate屬性,對著三個屬性分別呼叫節點處理器處理,並放回JS原生的for迴圈當中即可。

同理,for...inwhiledo...while迴圈也是類似的處理方式,這裡不再贅述。

12、If宣告節點處理器——IfStatemtnt()

處理If語句,包括ifif...elseif...elseif...else

同理,switch語句、三目表示式也是類似的處理方式。

上面列出了幾個比較重要的節點處理器,在es5當中還有很多節點需要處理,詳細內容可以訪問這個地址一探究竟。

七、定義呼叫方式

經過了上面的所有步驟,解析器已經具備處理es5程式碼的能力,接下來就是對這些散裝的內容進行組裝,最終定義一個方便使用者呼叫的辦法。

這裡我們定義了一個名為Canjs的基類,接受字串形式的JS程式碼,同時可定義標準庫之外的變數。當執行run()方法的時候就可以得到執行結果。

八、後續

至此,整個JS解析器已經完成,可以很好地執行ES5的程式碼(可能還有bug沒有發現)。但是在當前的實現中,所有的執行結果都是放在一個類似沙盒的地方,無法對外界產生影響。如果要把執行結果取出來,可能的辦法有兩種。第一種是傳入一個全域性的變數,把影響作用在這個全域性變數當中,藉助它把結果帶出來;另外一種則是讓解析器支援export語法,能夠把export語句宣告的結果返回,感興趣的讀者可以自行研究。

最後,這個JS解析器已經在我的Github上開源,歡迎前來交流~

https://github.com/jrainlau/c…

參考資料

從零開始寫一個Javascript解析器

微信小程式也要強行熱更程式碼,鵝廠不服你來肛我呀

jkeylu/evil-eval

相關文章