- 深入淺出WebAssembly
- 于航
- 14762字
- 2019-07-09 11:25:01
1.1 JavaScript的發展和弊端
一直以來,在Web前端領域我們都主要使用JavaScript語言來編寫運行在瀏覽器上的Web應用。不僅如此,隨著 React Native、Electron 及 Vue.js 等用于各種目的的開發框架的出現,JavaScript 語言正變得越來越流行,直至一躍成為 Github 語言排行榜的年度冠軍。但反觀如今的各類Web應用,其功能逐漸復雜化,對性能的要求逐漸提高。而瀏覽器作為運行平臺,雖然其內部的JavaScript引擎也在不斷被優化,但限于JavaScript語言本身的一些特性,根本無法滿足日益增長的應用性能需求。
1.1.1 快速發展與基準測試
自1997年 ECMAScript 1.1版本標準作為一個正式草案被提交給歐洲計算機制造商協會(ECMA),使得ECMAScript這種腳本語言規范開始逐漸走向標準化,一直到2017年6月,ECMAScript 2017(ES8)標準的正式發布,ECMAScript標準的不斷發展帶動著JavaScript這門以其作為標準實現的腳本語言不知不覺地走過了20個年頭。
在這20個年頭里,不只是ECMAScript標準本身在語法和特性上有了翻天覆地的變化,包括用來解析和執行JavaScript腳本語言的 Web 瀏覽器、用來快速進行前端構建的各種前端JavaScript框架、基于Chrome V8用來進行服務端應用開發的Node.js運行時引擎,甚至是那些用于輔助前端應用開發的包括各種構建和自動化工具在內的與JavaScript相關的開源軟件,都有了十足的發展。
首先值得一提的是JavaScript這門腳本語言在編程語法和功能特性上的發展。自1997年ECMAScript標準誕生一直到2015年,在這十幾年時間里,ECMAScript標準本身并沒有做過太多的改動,只是在不斷調整語言穩定性的過程中,使語義更加嚴格,同時還增加了少許的新特性。1999年發布的ECMAScript 3版本增加了對“正則表達式”的支持,而在這其后的整整十年內,ECMAScript標準一直處于一個相對的平穩期,其間沒有做任何修訂和改動。直到十年后的2009年,在ECMAScript 5標準中又增加了“嚴格模式”,以及對“JSON”這種輕型數據交換格式進行編/解碼等操作的支持(除此之外,還有少許其他新特性)。在六年后的2015年,ECMAScript標準迎來了史上最大的一次在語言特性上的改動,那就是ECMAScript 6標準的誕生。ECMAScript 6標準提供了很多豐富的新特性和語法糖,包括從Python借鑒來的迭代器和生成器特性、集合類型、箭頭函數、類型數組,以及可以用于面向OOP編程的“類”關鍵字和用于元編程的“代理”特性等。自ECMAScript 6標準發布開始,在接下來的兩年內ECMAScript標準又相繼發布了ECMAScript 2017和ECMAScript 2018版本,同時ECMAScript標準也開始以發布年份作為版本號重新命名,從此ECMAScript標準也進入了每年一次版本迭代的快速發展新時代。ECMAScript標準的快速發展也同樣促使前端開發領域跟著快速發展,一大批前端JavaScript框架和前端技術架構體系也由此誕生(這里ECMAScript 6對應ECMAScript 2015)。
從十年前擅長直接操作DOM對象的jQuery到十年后以MVVM模式架構見長的React以及Vue.js等框架,前端框架的發展突飛猛進,不斷地改變著人們在前端應用領域的日常開發模式。從最初碎片組件化的開發模式到現在已經逐漸成體系化、完整組件化和分層次的開發模式,前端開發的效率也在不斷提升。當然,前端框架的日益增多也離不開JavaScript這門編程語言所應用的領域變得愈加廣泛。現在JavaScript語言可以被應用在Web前端開發、移動端APP開發、服務端應用開發、桌面端應用開發、深度學習應用開發甚至是硬件開發等多個領域。不僅如此,自基于V8構建的Node.js引擎出現后,JavaScript語言也正逐漸“力挽狂瀾”,變得“無所不能”。
通過統計近十年來(從2008年1月1日到2017年11月1日)全球開發者每年在Github上所創建的 JavaScript 開源項目的數量(如圖1-1所示),可以發現,每年所創建的 JavaScript開源項目的數量與前一年相比幾乎都呈指數型增長。毋庸置疑,2015年是JavaScript最流行的一年,從2014年到2016年的三年時間里,Angular.js、React和Vue.js這三個Web前端開發框架“巨頭”相繼出現,在Web前端開發領域呈分庭抗禮之勢。

圖1-1 近十年來Github上基于JavaScript語言構建的開源項目數量
當然,JavaScript 之所以能夠深入到如此眾多行業和領域的開發實踐當中,也離不開 Web瀏覽器在性能上的日益優化和提升。以Chrome瀏覽器的核心JavaScript引擎V8為例,如圖1-2所示,從官方Github倉庫中每日對其修改提交數量統計數據中可以看到,每天都有30~50個提交被合并到 V8項目的主分支。V8的完整版本號由四位數字組成,形式為“MAJOR.MINOR. BUILD.PATCH”。如某一個V8版本的版本號為“6.3.292.33”,其中第三位數字對應的“BUILD”字段在每次V8重新編譯和發布后都會增長。而事實上,該字段對應數字在V8每天的小版本發布中都會有10次以上的增長,可見其版本發布之頻繁,性能優化和特性迭代速度之快。

圖1-2 Github上V8項目的每日修改提交數量統計圖
那么現如今的Web瀏覽器對JavaScript代碼的性能優化到底達到了怎樣的程度呢?我們以下面所給出的代碼為例,來看一下同樣一段耗時的業務邏輯,在不同編程語言的對應實現下,其代碼的運行效率和性能表現如何。這里我們還是以 Chrome 的最新版本桌面瀏覽器作為用來測試該JavaScript應用的容器,采用的Chrome版本是62.0.3202.94 (Official Build)(64-bit)。用于測試的應用其業務邏輯是:初始化兩個浮點數類型的變量,然后讓其中一個變量的值等于該值與另一個變量的累加和,并重復該過程1億次,程序會打印出這1億次累加操作所花費的時間,并重復進行10次。最后再將這10次的1億次累加和打印出來。
首先,我們基于原生C++語言編寫應用,該應用對應的具體源代碼如下:


在C++源代碼編寫完畢之后,需要通過編譯器來編譯這段源代碼,將其轉換成一個二進制的可執行文件。這里將編譯的程序分為兩個版本,第一個為不經過任何編譯器優化處理的基本版本;第二個為經過編譯器對C++源代碼進行浮點數優化和代碼優化后生成的版本。
# 1.未經過編譯器優化的版本,編譯、鏈接與運行
g++ benchmark-cpp.cc-o benchmark-cpp
# 運行所生成的程序
./benchmark-cpp
# 2.編譯器對代碼和浮點數操作優化后的版本
g++-O3–ffast-math benchmark-cpp.cc–o benchmark-cpp
# 運行所生成的程序
./benchmark-cpp
可以看到,這是一個用來計算浮點數累加值的程序。該程序一共循環10次,每一次都會打印出浮點數累加1億次后所花費的時間。程序在最后會打印出循環10次后,即累加10億次后得到的浮點數變量值。這里在進行C++代碼編譯時分別編譯出了兩個不同版本的程序,第一個是未經過任何編譯器優化直接生成的版本;而在第二個版本中,我們加入了GCC編譯器支持的,可以對浮點數運算進行優化的參數“-fast-math”來優化代碼中的浮點數運算。同時還指定了編譯器需要對C++代碼進行優化的等級參數“-O3”。
接下來,我們繼續編寫與該段程序業務邏輯相同的其他語言版本的程序代碼。首先給出JavaScript版本代碼,這段使用JavaScript語言編寫的代碼可以直接在Chrome開發者模式下的Console(控制臺)中運行。

然后繼續編寫Java版本的代碼,這段代碼仍然基于同樣的業務邏輯。

在編譯上述代碼時請確保本地環境已經安裝了 Java 開發工具包(JDK)。可以在命令行下
運行如下命令來編譯該Java程序。
# 編譯代碼
javac benchmark-java.java
# 運行代碼
java benchmark
最后給出的是Python語言對應的代碼。這里采用的Python解釋器版本是2.7.13,將下面的代碼直接存儲在一個以“.py”為后綴的文件中,然后直接使用“python”命令在命令行下執行該文件即可。

通過Python解釋器在命令行下運行Python文件中的代碼。
# 解釋執行
python benchmark-python.py
我們對上述4種不同編程語言分別對應的5段程序的運行結果進行了統計,并計算出了每段程序在10次“大循環”中消耗的平均時間值。最后我們將統計結果繪制成如圖1-3所示的柱狀圖(前10列為5段程序對應的10次“大循環”過程的單次統計結果,最后一列為10次“大循環”的平均統計結果)。
從圖1-3所示的基準測試平均結果中可以看到,運行效率最高的是經過編譯器對代碼進行優化和浮點數操作優化后的 C++程序,單次循環的平均耗時只有3ms。其次便是基于 Java 和JavaScript編寫的程序,兩種程序在處理單次“大循環”時的耗時基本相同,平均時間均為100ms左右,不相上下。而在相同的程序業務邏輯下,未經過任何編譯器優化處理的C++程序的運行效率并不是很高,基準測試結果顯示其運行效率低于在同樣業務邏輯下使用 JavaScript 和 Java語言編寫的程序,平均耗時為300ms左右。排名最后的是使用Python語言編寫的程序,同樣的單次循環平均耗時在6000ms以上(測試數據僅供參考)。

圖1-3 多語言性能基準測試的統計結果
綜合來看,JavaScript 代碼在現代 Web 瀏覽器中的解析和運行效率其實并不低,雖然相比經過編譯器優化的 C/C++代碼來說還有一些差距,但在日常開發工作中經常接觸到的那些編程語言里,JavaScript 代碼的解析和運行效率已經處于比較高的水平。現實總是殘酷的,雖然JavaScript代碼的解析和運行效率正在隨著JavaScript引擎的不斷優化改進而不斷提升,但在日常工作中我們所遇到的前端應用也正變得越來越復雜和多樣化。
1.1.2 Web新時代與不斷挑戰
一般來說,一項新技術是否會隨著時代的推進而被快速迭代和發展,要看這項技術所應用的實際業務場景中是否有相應的技術需求,畢竟沒有任何技術會被憑空創造出來,那么技術的迭代和發展速度也就取決于這些業務需求的變化速度。技術決定了業務需求的多樣性,而業務需求的多樣性又反過來推動技術本身不斷向前發展,兩者相輔相成,最終才能推動行業整體的發展和進步。
自1991年HTTP協議和HTML(超文本標記語言)這兩種核心的Web技術誕生以來,Web技術領域便開始不斷發生翻天覆地的變化。如圖1-4所示為1991—2002年Web技術的總體發展情況,在這十二年里,Web技術的總體發展過程還是比較緩和和穩定的。首先是NetScape、Opera和Internet Explorer(IE)三大瀏覽器開始逐漸走入人們的視野。一些用于構建更豐富Web應用的基礎性技術開始逐漸涌現,比如Flash技術從1996年開始可以被應用在瀏覽器端,這使得我們可以在傳統的Web應用中嵌入包含豐富多媒體信息的Flash應用,這一發展也使得Web應用的交互性和動態性大大增強。Flash技術的出現催生了一批以提供視頻播放、視頻發布和視頻分享服務為主的視頻服務平臺,同時也推動了基于Flash的Web頁游行業的發展。

圖1-4 1991—2002年Web技術的總體發展情況
從2002年開始,Web技術的發展便到了其整個發展歷程的“下半場”。如圖1-5所示為2003—2012年這十年間Web技術的總體發展情況,在這十年的時間里,新型Web技術的出現逐漸呈現出爆炸式的增長。首先是Chrome、Firefox和Safari這三種為推動Web技術的爆炸式發展做出了巨大貢獻的瀏覽器開始出現,各大瀏覽器廠商對其產品的版本更新迭代速度也開始加快。Web技術從2008年開始便進入了一個爆炸式的快速發展階段,各種各樣的新型Web瀏覽器特性,以及新的 Web 標準和 ECMAScript 語言標準都如雨后春筍般開始涌現出來。XMLHTTPRequest2技術為Web應用的數據傳輸提供了更加方便和高效的方式;WebRTC技術為Web應用的實時在線視頻/語音直播提供了底層的基礎技術解決方案;WebGL技術為Web應用提供了一種可以通過JavaScript來使用Web瀏覽器版OpenGL的特性,基于WebGL暴露出的JavaScript接口,我們可以在Web網頁上高效地繪制3D動畫和模型,而這為基于Web網頁運行大型3D網絡游戲提供了可能;IndexedDB技術為前端應用的結構化數據存儲和高性能檢索提供了支持。除此之外,還有很多的Web相關技術正在或已經被實現和標準化,這些技術無疑都大大地拓寬了Web應用所能夠覆蓋到的應用領域和場景。也正是自2008年HTML 5標準和2009年CSS 3標準出現后,Flash多媒體應用技術在Web開發領域逐漸走下坡路,直至最后被其他技術取代。由此可見Web領域的技術迭代與更替速度之快。

圖1-5 2003—2012年Web技術的總體發展情況
JavaScript作為一門可用于開發Web前端應用的編程語言,從1995年發展至今,其所能夠應用的領域已經不再局限于最原始的、基于瀏覽器的Web應用開發。包括Node.js在內的一系列新出現的JavaScript運行時環境已經把JavaScript語言的應用場景從前端應用開發帶到了服務器端的應用開發。
基于Chrome V8引擎構建的Node.js和Fib.js等JavaScript運行時環境,為后端服務器應用的開發提供了非阻塞的異步IO和基于事件模型等新特性,這些新特性可以讓我們以開發傳統前端Web應用的思路來開發服務器端應用。不僅如此,基于Node.js開發出來的各種服務器端應用框架更是極大地提高了我們開發后端應用的效率。這些框架在一些必要的業務流程上已經為我們做了足夠多的封裝和優化,這使得我們可以更多地去關注業務邏輯代碼的實現,而不是一些底層的架構細節。但事情并沒有這么完美,以Node.js為例,由于其本身是基于V8實現的,而V8最重要的一個功能就是對JavaScript代碼進行解析和優化,然后將優化好的中間代碼編譯成機器碼或其他格式后再進行處理。因此,無論Node.js對V8上層的JavaScript代碼進行了何種底層系統調用流程上的優化,如果最后V8在解析和優化JavaScript代碼的過程中消耗了大量時間,那么整個應用的運行效率必然會大打折扣。總的來說,Chrome V8、JavaScriptCore 和SpiderMonkey等JS引擎對JavaScript代碼的解析和優化效率,直接決定了基于JavaScript開發的前端和服務器端應用的運行效率,進而也影響了產品的用戶體驗。
除此之外,變得日益復雜和龐大的Web前端應用也帶來了更多對JavaScript語言性能上的挑戰。比如基于Web瀏覽器的視頻處理應用、大型3D游戲以及在線的機器學習(深度學習)實時訓練平臺等,無一例外都需要消耗大量的瀏覽器計算資源,因此JS引擎對JavaScript代碼的解析執行效率高低也直接決定了這些應用能否被流暢地運行。不僅如此,我們都知道通過JavaScript來移動或修改網頁上的DOM節點所付出的成本是巨大的,隨著傳統Web頁面的交互設計變得越來越復雜,這種成本損耗所帶來的性能問題可能會被逐漸放大,這也是我們在未來將要面對的問題。
1.1.3 無法跨越的“阻礙”
前面我們提到,Chrome V8和JavaScriptCore等JS引擎對JavaScript代碼的解析和執行效率高低,會直接影響到那些基于JavaScript開發的前后端應用的運行效率,那么JS引擎在解析和執行JavaScript代碼時究竟會在哪些地方消耗較多的時間和性能呢?下面讓我們走進這些JS引擎,來看一下它們內部的“世界”。
function add (a, b){
return a + b;
}
上面給出了一段簡單的JavaScript代碼,在這段代碼中我們定義了一個非常簡單的JavaScript 函數。調用該函數時一共需要傳入兩個參數,函數會直接返回這兩個參數經過“+”運算符運算后的結果。那么當我們在代碼中調用該函數時,JS引擎會對這個函數的調用過程進行怎樣的處理呢?如圖1-6所示,這是在ECMA-262最新的8.0版本標準中規定的當JavaScript引擎遇到“+”運算符時需要進行的語法分析規則。

圖1-6 在ECMA-262標準中規定的對“+”運算符的語法分析規則
在這段標準中說明了JavaScript引擎需要對“+”運算符兩邊的操作數進行怎樣的處理和轉換。我們將這段比較抽象的標準“翻譯”成較為容易理解的流程描述,說明如下。
首先,這段標準說明了“+”運算符兩邊可以使用的操作數類型。這個操作數可以來源于AdditiveExpression或MultiplicativeExpression表達式所對應的值。粗粗一看就可以發現,MultiplicativeExpression表達式是指由“*(乘法)”、“/(除法)”、“%(取余)”及“**(求冪)”等運算符組成的一系列表達式;而AdditiveExpression表達式則是指由“+(加法)”和“?(減法)”運算符組成的表達式。但實際上,每一種類型的表達式都是從下到上由一連串的表達式“繼承”鏈組成的。比如對于一個AdditiveExpression表達式,我們自上而下來推導它的繼承鏈結構,可以發現一個獨立的MultiplicativeExpression表達式本身也是一個AdditiveExpression表達式,而一個獨立的 ExponentiationExpression 表達式同時也是一個 MultiplicativeExpression 表達式,依此類推,直到整個繼承鏈的最底層。鏈路底層直接對應的是數字的實體類型,這些類型又會被分為NonZeroDigit及DecimalDigit等各種類型。
整個繼承鏈路如圖1-7所示。這些出現在ECMAScript標準中的各類型表達式之間復雜的繼承關系,也決定了JavaScript引擎在解析JavaScript代碼時應該如何確定各個運算符之間的運算優先級關系。

圖1-7 在ECMA-262標準中部分表達式的繼承鏈關系
通過上述說明我們可以了解到,“+”運算符與其兩邊的子表達式共同組成了一個新的AdditiveExpression 表達式。接下來,我們可以按照標準給出的詳細流程來進一步了解當 JS 引擎在解析 JavaScript 代碼時,如果遇到“+”運算符應該以怎樣的規則來解析這個新生成的AdditiveExpression表達式,并最終求得這個表達式的值。
JavaScript引擎在進行語法/語義分析時,首先需要判斷該“+”運算符兩邊子表達式的值,這個步驟對應于上述ECMAScript 8.0標準中的第1步和第3步,這里在解析AdditiveExpression子表達式值時進行的是一個遞歸的過程。然后通過標準給出的抽象方法 GetValue 來對“+”運算符兩邊已經解析好的操作數進行處理,這里的lval和rval分別代表兩個處理好的結果值。接下來,繼續通過抽象方法ToPrimitive對上一步中得到的lval和rval兩個值進行處理,返回的lprim和rprim分別代表經過這一步處理后得到的兩個中間結果值。在第7步中,我們需要判斷上一步的兩個結果值lprim和rprim中是否至少有一個值的類型為String(字符串)。如果是,則分別對lprim和rprim兩個值調用ToString抽象方法進行處理,然后將這兩個處理后的結果值(分別對應 lstr 和 rstr 代表的字符串)拼接成一個完整的字符串,并將該字符串作為最終結果返回。如果不是,則會對lprim和rprim代表的中間結果值調用ToNumber抽象方法進行處理。這個抽象方法會按照相應的規則將兩個中間結果值分別轉換成對應的數字值,最后再將這兩個數字值通過數學加法進行計算,并將計算結果返回。
這里需要注意的是,為了能夠更加嚴謹地將“ECMA—262”標準直觀地表達出來,我們還需要對上述分析過程中的幾個地方有更深刻的認識。
標準中的“變量”名
需要注意的是,我們在上文中提到的“lval”和“rval”等標記名稱,實際上并不代表任何JavaScript引擎在其源碼中實際使用到的變量名、寄存器名或組件名等,只是標準文檔為了方便在描述解析流程時將各個階段的“中間結果”表示出來而取的標記名稱。而且這些標記名稱也十分有規律,比如lval可以理解為Left Value,即運算符左操作數所代表的值;lstr代表Left String,即左操作數對應的字符串值,其他可以依此類推。用這些標記名稱來表示標準在各個處理階段所生成的值類型顯得十分貼切和形象。
抽象方法
我們在上文中提到的抽象方法,其實在 ECMAScript 8.0標準中對應的名詞是“Abstract Operations”。Abstract Operations本身并不是ECMAScript語言的一部分,只是用來幫助標準本身來更加清晰地描述語法和語義。
這里稱之為“抽象方法”,是由于 Abstract Operations 在標準文檔中的書面引用形式與JavaScript語言中的函數調用過程十分類似。所謂的Abstract Operations其實本身也是一系列對給定值的特定處理流程。如圖1-8所示為抽象方法 ToNumber 的處理流程規范。當 ToNumber抽象方法遇到不同類型的操作數時會根據標準分別進行不同的處理,這里以比較復雜的 Object類型操作數為例。對于一個Object類型的操作數,抽象方法ToNumber首先需要對該操作數調用抽象方法ToPrimitive進行處理,然后將其返回的結果再通過調用抽象方法ToNumber來進行處理并得到最終的處理結果。同樣的,抽象方法ToPrimitive所對應的Abstract Operation,也有其自己的一套規范化、標準化的處理流程。

圖1-8 在ECMA-262標準中對抽象方法ToNumber的處理規范定義
非終結符、終結符與產生式
為了能夠更加深入地理解ECMAScript規范中表達式的作用,以及表達式與JavaScript引擎之間的關系,我們可以將大多數編程語言所對應編譯器的語法分析過程總結成如下通用的簡化版流程。這里假設使用某種編程語言編寫了如下一行代碼。
thisIsAVariable = 1+2
這行代碼表示一個最簡單的賦值語句,將等號“=”右邊的表達式結果值賦值給了一個名為“thisIsAVariable”的變量。我們使用字母“S”來表示整個賦值語句表達式,那么應該怎樣通過符號組合的形式來描述這條賦值語句呢?假設使用字母“v”來表示賦值語句最左邊的變量,字母“e”表示“=”運算符,字母“p”表示“+”加號運算符,字母“d”表示一個整數。經過整理,我們得到了如下所示的字符表達式,最右側的五個字母從左至右依次對應上述語句中出現的每一個有效的語法元素。
S->vedpd
但實際上,由于“=”運算符右邊可以放置任意類型的表達式,因此我們繼續對上述字符表達式進行修改,用字母“E”來表示一個任意類型的表達式。經過整理后的字符表達式如下。
S->veE
在這里,我們將上述字符表達式稱為賦值語句“S”的產生式。其中字母“v”和“e”所對應的 Token 類型已經確定。Token 是詞法分析器在進行詞法分析時產生的最小的且具有明確語義的有效關鍵字。比如對于上述賦值語句,在詞法分析階段,詞法分析器會將這段代碼按照最基本的關鍵字進行分割,分割出的“thisIsAVariable”、“=”、“1”、“+”和“2”五個字符串片段中每一個都獨立地稱作一個 Token,并且其各自分別對應著一種具體的 Token 元素類型。而這些能夠直接與某類型Token相對應的符號,我們稱它們為“終結符”。相反的,符號“E”可以表示一個具有任意組成元素的表達式類型,而其本身并沒有被明確地指定與哪些Token相對應,因此我們稱它為“非終結符”。
為了能夠清楚地描述符號“E”所對應表達式的具體結構,我們需要對“E”進行名為“非終結符展開”的操作。為了簡化該流程,假定在該編程語言中只存在“加法”這一種數學運算,同時也只有“整數”這一種數據類型。那么符號“E”所代表的表達式便可能具有兩種組成方式,其中一種是符號“E”可以僅由一個整數字面量組成,該整數獨立地作為表達式完成整個“S”表達式的計算過程;另一種是符號“E”可以表示任意經由“+”加法運算符組合而形成的子表達式的值。因此,我們可以進一步對賦值語句“S”的產生式進行如下整理。
E->d|Epd
S->ve(d|Epd)
可以看到,符號“E”的產生式以遞歸形式表示出來。這種遞歸形式可以使該表達式的展開式能夠覆蓋到具有任意長度子表達式的所有具體表達式上。比如對于如下這種包含有連加運算的數學表達式,借助上述表達式“S”的遞歸形式展開式,我們可以通過以下步驟對其進行展開。
thisIsAVariable = 1 + 2 + 3;
# 展開過程
1.S-> veE
2.S-> veEpd (E->Epd)
3.S-> veEpdpd (E->Epd)
4.E-> vedpdpd (E->d)
通常來說,在編程語言所對應的整個編譯器鏈路中,詞法分析器(Lexer)負責將源代碼中的各類短語進行過濾并解析成具有特定語義的Token字符串,而這些字符串將會在接下來的語法分析階段,被語法分析器(Parser)通過相應的算法進行“表達式非終結符”展開的處理。比如在 JavaScript 語言中,我們可以通過“+”運算符來定位一個 AdditiveExpression 類型的表達式。剛才我們也提到過,每一個表達式其實都是一種“非終結符”類型,這些非終結符都需要在被編譯成機器碼之前展開成特定的終結符形式。因此相對而言,如果語法分析器無法將一段代碼內的某個表達式展開成標準中提到的任意一種終結符展開式形式,那么在該表達式中便一定存在語法格式錯誤。
同樣的,如果代碼中不存在語法錯誤,也就代表著該段代碼中所有的非終結符結構(包括表達式、條件控制結構及函數定義在內的各類非終結符形式)都被成功地展開成了標準中某種特定的終結符形式。語法分析器在處理完代碼后會向編譯器鏈路的下一個階段輸出一種名為“抽象語法樹(AST)”的數據結構,它以結構化的表示形式表達了整段代碼的語法結構。至此,也表明語法分析器真正“理解”了源代碼中各個代碼段所表達的具體語義。
通過上面的分析,我們大致了解了JavaScript引擎在處理“+”運算符時所需要經過的一系列解析流程。實際上,JS引擎在實際實現規范中所描述的各類流程時,需要處理的細節問題遠比我們所描述的要復雜得多。在規范中出現的每一個流程內的每一個抽象方法都有著其各自不同的處理流程,而在這些流程內部同樣又有多個更加底層的抽象方法。通過將這些抽象方法一步一步組合形成各種各樣的上層流程,而流程與流程之間又相互調用形成網狀結構,這些網狀結構的調用流程最后便組成了整個ECMAScript語法和語義層的標準。
回過頭來看,JavaScript引擎之所以要在處理“+”運算符時經過多道“工序”,其主要原因是 JavaScript本身是一種弱類型(Weak Typed)的編程語言。所謂弱類型,在語法形式上最直觀的體現便是在使用該編程語言初始化變量時,無須顯式地指出變量的具體類型,整個變量的類型完全由代碼解釋器在代碼的運行過程中進行推斷。而相對于弱類型編程語言的則是強類型(Strongly Typed)編程語言。同樣的,所謂強類型,最直觀的體現便是在使用該編程語言聲明變量時,必須要顯式地指明變量需要存儲的數據類型。這樣做的好處是,我們無須花費額外的精力在代碼運行時去推斷變量的數據類型,而這從某種程度上便可以大大提高代碼的運行效率。由于代碼中的所有變量類型都不再需要通過運行時環境去推斷,因此便可以提前將程序的源代碼進行靜態編譯(AOT)和優化,最后直接生成相應的經過優化的二進制機器碼供CPU執行。C 語言便是這樣一種常用的強類型編程語言。強類型編程語言所共有的一個優勢就是無須進行變量的運行時類型推斷,進而使得代碼的運行效率更高。
1.1.4 Chrome V8引擎鏈路
從上文中我們已經了解到,由于JavaScript引擎無法在代碼運行前便得知變量所存儲的具體數據類型,因此對于很多運算符操作,JS引擎需要在運行時環境下通過一系列的判斷“決策”才能推斷出變量所存儲的具體數據類型。而實施這些“決策”的過程則需要消耗一定的系統資源。接下來讓我們以老版本(Chrome 58以下)的Chrome V8引擎為例,來進一步剖析V8引擎在處理JavaScript源代碼時的整個流程以及所對應的編譯器鏈路,如圖1-9所示。

圖1-9 老版本Chrome V8引擎的編譯器鏈路
V8引擎的飛躍式進化也是從這里開始的。整個代碼的解析、編譯和執行流程按照JavaScript代碼所經過的不同編譯器順序及類型,可以被分為“Full-codegen”基線JIT編譯器對應的Baseline編譯階段,以及“TurboFan”和“Crankshaft”兩個優化JIT編譯器所對應的Optimized編譯階段共兩個部分。
首先,每一組編譯器都會有一個前置的語法分析器,這個語法分析器會對 JavaScript 源代碼進行詞法和語法分析,然后生成對應的抽象語法樹結構。一般來說,一個完整的編譯器鏈路在處理源代碼時通常會分為以下幾個步驟。
詞法分析
詞法分析,顧名思義,該階段主要是識別和提取源代碼中的關鍵字,同時判斷出每一種關鍵字的類型。這些關鍵字可能是一個用來聲明變量的“let”,也可能只是一個簡單的用于結束語句的分號“;”。所有這些組成源代碼的關鍵字都會在這一階段被識別和提取出來,最后結合這些關鍵字的具體類型和一些基本的語素信息,我們就得到了詞法分析階段的最小單位“Token”,而每一個Token就代表了一種不同的關鍵字類型。
語法分析
語法分析,該階段會通過結合編程語言的具體語法規則和從詞法分析階段得到的Token信息,將JavaScript源代碼轉換成抽象語法樹(AST)的形式。抽象語法樹以樹狀的形式表示源代碼的語法結構。
// 聲明一個函數
function add(a, b){
return a + b;
}
// 聲明一個變量并調用函數,最后將函數值賦值給該變量
let num = add(1, 2);
比如我們在上面的JavaScript源代碼中聲明了一個函數,這個函數接收兩個參數,然后返回這兩個參數經過“+”運算符運算后的結果。接下來調用該函數,并將數字1和2作為參數傳入該函數,最后再將函數返回的結果賦值給一個新的變量“num”。我們將這段JavaScript源代碼經過語法分析處理后生成的AST以JavaScript對象的形式表示出來,如下所示。這里我們采用了Esprima來分析JavaScript源代碼并生成對應的AST結構。




從上面的AST結構中可以看到,抽象語法樹上的每一個節點都有其各自的類型,這些類型可能是ECMAScript標準中的一個終結符Token,比如標識符(Identifier)類型;或者是一個非終結符,比如一個需要進一步展開的表達式類型。除此之外,還有函數定義、變量定義等各種類型的節點。在語法分析階段,語法分析器會根據文法來分析那些在詞法分析階段產生的Token,并且按照ECMAScript的語法規則將這些Token進行整理和組合,通過識別關鍵字Token來提取出語法中的函數定義、變量定義和表達式等語法結構。最后再將這些包含有各種元素的層級結構整理成一個樹狀的語法表示結構。AST從最內層(樹的子節點)開始統一由終結符類型的Token組成,逐層向外擴展。比如對于CallExpression表達式類型,其展開過程可以有多種形式,每種形式又由不同的 Token 組成,而具體的展開形式只有根據實際的源代碼才能確定。同時CallExpression也可以被放到VariableDeclaration這種語法結構中。AST便是這樣逐層地將各種類型的語法元素按照標準中規定的語法結構表示出來的,樹形結構的表示方式也正好對應了源代碼在語法上的邏輯嵌套和組成關系。
語義分析
在語法分析階段,編譯器只是對源代碼進行靜態分析,以判斷源代碼在語法表達上是否存在錯誤。在語義分析階段,編譯器會進一步分析在語法分析階段產生的AST結構,進而判斷源代碼是否存在運行時錯誤。在這一階段中,編譯器會分析源代碼中的函數調用過程,以及傳入函數的參數個數是否正確,優化那些已經聲明并被初始化,但卻在程序中沒有被明確調用到的變量等。對于強類型的編程語言,編譯器還會檢查變量類型的聲明與使用是否保持一致。
生成目標代碼
經過上面幾個步驟之后,我們便可以將最終經過分析和優化后的代碼直接“翻譯”成對應的目標代碼并在對應的目標平臺上執行。比如 JavaScript 代碼對應的目標執行平臺就是各類瀏覽器。在這一階段中,編譯器會將從上一步語義分析階段得到的中間代碼直接編譯成對應平臺的機器碼,并在瀏覽器中解析和執行。
我們再把目光移回到V8引擎上。為了提高對JavaScript源代碼的解析和執行效率,V8引擎會對當前即將執行的JavaScript代碼段進行分析。如前面的圖1-9所示,V8引擎會首先將所有的JavaScript源代碼通過一個前置的語法分析器(Parser)來進行詞法和語法的分析,并同時生成對應的AST數據結構。在這一階段中,Parser會檢查整段JavaScript代碼并將它們分成如下兩種不同的類型。
Top-Level代碼
這一類型的代碼主要是指那些當JavaScript源代碼初次加載時需要被首先運行到的“頂層”代碼。這部分代碼主要包括變量聲明、函數定義以及函數調用等類型的代碼。而那些用于定義函數的函數體內部的代碼,并不屬于Top-Level代碼。
非Top-Level代碼
這一類型的代碼則與 Top-Level 代碼正好相反,主要是指那些用于定義函數的函數體內部的JavaScript代碼。
在如下的一段代碼中,我們將其中的Top-Level代碼和非Top-Level代碼通過注釋做出了區分。其中注釋為TL的代碼為Top-Level代碼,而注釋為NTL的代碼為非Top-Level代碼。


在V8引擎中,位于各個編譯器的前置Parser被分為Pre-Parser與Full-Parser兩種類型。首先,Pre-Parser主要負責對整個JavaScript源代碼段進行必要的前期檢查。Pre-Parser并不區分具體的代碼類型,即無論這些代碼是否屬于Top-Level類型,Pre-Parser都會對它們進行檢查。通過檢查,V8會判斷JavaScript代碼中是否存在語法錯誤,如果存在,則V8需要在對代碼進行下一步處理前及時拋出語法錯誤信息(Early Syntax Error)并提示用戶,同時中斷代碼的后續解析和執行。在Pre-Parser對代碼進行分析和處理的階段,Parser并不會生成對應于源代碼語法的AST結構,同時也不會生成變量可用的上下文作用域。
接下來,Full-Parser會開始分析那些屬于Top-Level類型的JavaScript源代碼,并生成這部分JavaScript代碼所對應的AST信息。同時在該階段中,Full-Parser還會對代碼中的變量進行作用域分析,以便追蹤那些具有特殊作用域的變量(例如閉包中的變量),并為它們在外層作用域分配相應的資源,同時生成該變量可用的上下文作用域。當 Full-Parser 將所有屬于頂層Top-Level類型的JavaScript源代碼都轉換成對應的AST信息后,這些AST隨后便會被送往V8引擎的第一個支持運行時編譯(JIT)的編譯器——“Full-codegen”基線編譯器來進行處理。在這里,Full-codegen會快速地根據輸入的AST信息來編譯并生成對應未經優化的機器碼,這些機器碼最后便可以被瀏覽器快速地解析和執行。
瀏覽器在解析和執行這些 Top-Level 代碼的過程中,會遇到一些諸如函數調用的操作。由于在初次的Full-Parsing過程中,Parser只對Top-Level代碼進行了處理,而在這部分Top-Level代碼中并不包含這些被調用函數的函數體定義。因此在這種情況下,V8引擎會根據 Top-Level代碼在執行過程中遇到的函數調用,來對 JavaScript 源代碼中對應函數的函數體再進行一次Full-Parsing 的處理,并生成這個函數體所對應的 AST 信息。這些 AST 信息隨后也同樣會被Full-codegen處理并生成對應的機器碼,最后再由瀏覽器解析和執行。V8引擎的這種“不一次性完全生成和處理所有JavaScript源代碼對應的AST信息,而只在用到時才進行AST生成和編譯”的特性,我們稱之為“Lzay Parsing”。總的來說,在V8引擎中,Pre-Parsing階段主要用來檢查整個JavaScript源代碼中是否含有需要提前拋出的語法錯誤,而在隨后的Full-Parsing階段才會真正生成AST信息并交由編譯器來處理。
隨著從Full-codegen基線編譯器輸出的未經優化的機器碼被瀏覽器解析和執行,V8引擎會發現在當前這些正在運行的代碼邏輯中,有一些比較耗時的代碼流程可以被進一步優化。比如在JavaScript代碼中出現的“大次數循環代碼塊”或ECMAScript 6標準中的某些新特性。這時,V8引擎會選擇把這部分JavaScript代碼直接轉交給另外的優化編譯器進行優化處理。V8引擎有兩個JavaScript優化編譯器,分別為Crankshaft和TurboFan。其中Crankshaft主要用于對JavaScript源代碼進行一些比較基礎的優化;而TurboFan主要用于對那些使用了ECMAScript 6及以上標準的新特性代碼進行優化,同時它也負責對ASM.js代碼進行處理。
首先,Full-codegen基線編譯器在根據AST來生成未經優化機器碼的過程中,會假設某些情況是成立的。也只有當在確保這些假設是真實成立的情況下,優化編譯器所進行的深度優化才會有實際的意義。比如在優化一段包含有大量循環邏輯的JavaScript代碼時,如果Full-codegen基線編譯器發現在循環的前幾次中都執行了相同邏輯的代碼(相同的作用域環境與變量結構),便會假設在后面的所有循環中,迭代的代碼形式都是保持不變的,而對于這樣的循環結構,基線編譯器會把編譯流程交給優化編譯器來進行優化處理。但實際上,由于 JavaScript 語言本身所具有的高度動態性,所以并不能完全保證循環邏輯中每次迭代所執行的代碼結構都一定是完全相同的。因此,這些經過優化編譯器生成的機器碼在被瀏覽器解析和執行前,V8引擎會再次檢驗之前基線編譯器所做出的假設是否成立。如果假設確實成立,瀏覽器便會直接解析和執行這些經過優化生成的機器碼;如果假設不成立,優化編譯器便會開始執行一個名為“去優化(Deoptimize)”的過程。
“去優化”過程是指V8引擎發現之前基線編譯器所做出的假設并不成立,而此時則需要把代碼的編譯流程從優化編譯器重新“交回”到基線編譯器的手上。基線編譯器會再次重新編譯這些 JavaScript 代碼,同時生成未優化的機器碼,最后讓瀏覽器來解析和執行。而之前優化編譯器生成的那部分錯誤的優化機器碼便會被直接丟棄。
以上便是老版本Chrome V8引擎在處理JavaScript代碼時,從編譯器角度來看所經過的一系列流程。隨著Web應用的規模越來越大,V8引擎現有的這種用于處理JavaScript代碼的編譯器架構模式所存在的問題便逐漸凸顯出來。Full-codegen基線編譯器在處理Top-Level代碼時所生成的機器碼會大量占用 V8的堆內存。不僅如此,V8的編譯器鏈路在解析和執行 JavaScript代碼的整個時間線(Startup Time)上,有近三分之一的時間被Parsing和Compiling占據。其中,對同一段代碼的多次 Parsing 更是大大降低了 V8引擎對 JavaScript 源代碼的處理效率(Pre-Parsing、基線編譯器的Full-Parsing和優化編譯器的Full-Parsing)。比如對于如下所示的這段JavaScript源代碼,我們嘗試通過Node.js來追蹤V8引擎在處理該段代碼時的Pre-Parsing和Full-Parsing過程。(注:Node.js基于V8構建,因此也使用V8來解析JavaScript代碼。)

在終端命令行中執行如下Node.js命令。
node-trace_parse app.js
通過為node命令指定“-trace_parse”參數,我們可以讓Node.js輸出V8引擎在對JavaScript源代碼進行Pre-Parsing和Full-Parsing兩個過程時的相關信息,結果如圖1-10所示。可以看到,在解析這段JavaScript源代碼時,V8引擎會首先對這段源代碼中的所有函數定義及調用過程進行Pre-Parsing操作,在Pre-Parsing過程中V8引擎會檢查源代碼中是否存在需要提前拋出的語法錯誤信息。在對所有源代碼完成 Pre-Parsing 階段的處理后,V8引擎開始處理在第一次運行時將會被調用到的函數代碼,即屬于Top-Level類型的代碼。

圖1-10 通過Node.js來追蹤上述JavaScript代碼在V8下的Parsing過程
隨著代碼的運行,一些在Top-Level代碼中被調用的函數其函數體會被V8引擎繼續處理。這里由于我們在Top-Level代碼中調用了sayHi函數,因此V8引擎會對sayHi函數的函數體再進行一次Full-Parsing處理,Full-Parsing操作會分析函數體代碼并生成所對應的AST數據結構,同時初始化函數內的變量和上下文環境。
但從整體上看,V8引擎對sayHi函數的Pre-Parsing處理過程其實是完全沒有必要的。由于所有位于Top-Level層級的函數調用都會在JavaScript被加載時就執行,對這些函數的函數體的語法檢查完全可以放在緊接著 Pre-Parsing 之后的 Full-Parsing 階段來進行,如果可以將這些重復的操作完全省略掉,那么V8引擎在處理JavaScript代碼時的效率又會得到進一步的提升。
為此,V8引擎也提供了一些比較“Hack”的方式來避免多余的 Pre-Parsing 過程。我們可以通過一種常見的名為“IIFE(立即執行函數表達式)”形式的 JavaScript 代碼,來強制讓 V8引擎省略掉對IIFE內部代碼的Pre-Parsing過程。在這里,我們通過IIFE來優化之前的那段代碼,優化后的JavaScript源代碼如下。

在終端命令行中運行如下Node.js命令。
node-trace_parse app.js
接下來,我們還是使用Node.js來追蹤V8引擎對上述經過IIFE處理后的JavaScript代碼進行Pre-Parsing和Full-Parsing的處理過程,命令執行結果如圖1-11所示。可以看到,V8引擎在處理這些包含在IIFE結構內的代碼時,完全省略了對這部分代碼的Pre-Parsing處理過程,這在某種程度上提升了V8引擎解析和執行JavaScript代碼的效率。

圖1-11 經過IIFE修改后的JavaScript代碼執行結果
鑒于在老版本V8引擎中存在各種問題,Google自Chrome 58版本開始,開始對V8引擎的編譯器鏈路進行了改進與優化。V8團隊希望能夠通過如圖1-12所示的全新編譯器鏈路架構模式來優化現階段的V8引擎。

圖1-12 全新的Chrome V8編譯器鏈路
在這個全新的 V8引擎編譯器鏈路中,新加入了一個名為“Ignition”的解釋器,同時去掉了Full-codegen基線編譯器和Crankshaft優化編譯器。Ignition解釋器會根據從Parser傳遞過來的基于JavaScript源代碼生成的AST信息直接生成對應的“比特碼(Bytecode)”數據結構。比特碼本身是一種對機器碼形式的抽象,它的信息密度更高,因此相比于基線編譯器生成的未經優化的機器碼,Ignition解釋器生成比特碼的速度更快,同時這些比特碼的體積更小,占用的堆內存也更少。這些比特碼一部分會被 Ignition 自身直接高效地解釋和執行,另一部分則會被送往TurboFan的“圖”生成器中等待進一步的優化。
與之前類似,如果優化編譯器TurboFan生成的優化機器碼不可用,即V8引擎所做出的優化假設是不成立的,那么這些機器碼便會被直接丟棄,同時整個編譯流程會直接再次返回到Ignition解釋器,由Ignition來處理和執行這些JavaScript源代碼。這種新的編譯器鏈路使得V8引擎的整體架構復雜度大幅下降,僅有一次的Parsing過程也使得JavaScript代碼可以被更高效地運行。同時也解決了在老版本鏈路中基線編譯器會耗費大量堆內存的問題。
但實際上,從老版本V8鏈路到全新鏈路架構的升級并不是一蹴而就的。因此,在Chrome 53版本中采用的V8編譯器鏈路仍舊如圖1-13所示。由于TurboFan優化編譯器本身的處理性能并不足以獨立支撐整個V8鏈路對JavaScript代碼的優化,因此我們不得不把Crankshaft優化編譯器重新加回到鏈路中。而另一方面,由于Crankshaft本身沒有可用于處理比特碼的編譯器前端,因此從 Crankshaft 進行去優化過程時仍需要把這部分代碼重新交回到 Full-codegen 基線編譯器的手上,所以Full-codegen也被重新加回到了V8引擎鏈路中。

圖1-13 Chrome 53版本中實際使用的Chrome V8編譯器鏈路
可以看到,從老版本V8鏈路到全新鏈路架構的升級過程并不是能夠快速完成的。隨著Web應用的規模不斷增大,V8引擎需要不斷地進行升級來提升自己處理JavaScript代碼的性能。但是,每一次升級所花費的時間和Web應用逐漸復雜化的時間周期并不成正比。并且V8引擎所存在的這些問題并不是其獨有的,包括 SpiderMonkey和JavaScriptCore等在內的這些常見的JavaScript引擎均存在類似的問題。
- 剪映短視頻剪輯零基礎一本通
- Photoshop CC實戰從入門到精通
- Flash CS6標準教程(全視頻微課版)
- Unity 2D與3D手機游戲開發實戰
- 微信小程序開發入門與實踐
- NHibernate 2 Beginner's Guide
- Final Cut Pro短視頻剪輯入門教程
- Cinema 4D完全實戰技術手冊
- AI短視頻生成與剪輯實戰108招:ChatGPT+剪映
- After Effects影視特效立體化教程:After Effects 2021(微課版)
- 中文版3ds Max 2021完全自學教程
- Illustrator CC平面設計標準教程(微課版)
- Word-Excel-PowerPoint 2010三合一辦公應用實戰從入門到精通(超值版)
- 神奇的中文版Photoshop CC 2017入門書
- UG NX 12.0中文版從入門到精通