摘要:事實是只是部分語言的不同表示法?;谶@些,解析器會進(jìn)行立即或者懶解析。然而,解析器做了完全不相關(guān)的額外無用功即解析函數(shù)。這里不解析函數(shù),該函數(shù)聲明了卻沒有指出其用途。所以之前的例子,解析器實際上
原文請查閱這里,本文采用知識共享署名 4.0 國際許可協(xié)議共享,BY Troland。
本系列持續(xù)更新中,Github 地址請查閱這里。
這是 JavaScript 工作原理的第十四章。
概述我們都知道運行一大段 JavaScript 代碼性能會變得很糟糕。代碼不僅僅需要在網(wǎng)絡(luò)中傳輸而且還需要解析,編譯為字節(jié)碼,最后運行。之前的文章討論了諸如 JS 引擎,運行時及調(diào)用棧,還有為 Google Chrome 和 NodeJS 廣泛使用的 V8 引擎的話題。它們都在整個 JavaScript 的運行過程中扮演著重要的角色。
今天所講的主題也非常重要:了解到大多數(shù)的 JavaScript 引擎是如何把文本解析為機(jī)器能夠理解的代碼,轉(zhuǎn)換之后發(fā)生的事情以及開發(fā)者如何利用這一知識。
編程語言原理那么,首先讓我們回顧一下編程語言原理。無論使用何種編程語言,你經(jīng)常需要一些軟件來處理源碼以便讓計算機(jī)能夠理解。該軟件可以是解釋器或編譯器。不管是使用解釋型語言(JavaScript, Python, Ruby) 或者編譯型語言(C#, Java, Rust),它們都有一個共同點:把源碼作為純文本解析為語法抽象樹(AST)的數(shù)據(jù)結(jié)構(gòu)。AST 不僅要以結(jié)構(gòu)化地方式展示源碼,而且在語義分析中扮演了重要的角色,編譯器檢查驗證程序和語言元素的語法使用是否正確。之后, 使用 AST 來生成實際的字節(jié)碼或者機(jī)器碼。
AST 程序AST 不止應(yīng)用于語言解釋器和編譯器,在計算機(jī)世界中,還有其它用途。最為常見的用途之一即靜態(tài)代碼分析。靜態(tài)代碼分析并不會運行輸入的代碼。但是,它們?nèi)匀恍枰斫獯a的結(jié)構(gòu)。比如,實現(xiàn)一個工具來找出常見的代碼結(jié)構(gòu)以便用來代碼重構(gòu)減少重復(fù)代碼?;蛟S你可以使用字符串比較來實現(xiàn),但是工具會相當(dāng)簡單且有局限性。當(dāng)然了,如果你有興趣實現(xiàn)這樣的工具,你不必自己動手去編寫解析器,有許多完美兼容于 Ecmascript 規(guī)范的開源項目。Esprima 和 Acorn 即是黃金搭檔。還有其它工具可以用來幫助解析器輸出代碼,即 ASTs.ASTs 被廣泛應(yīng)用于代碼轉(zhuǎn)換。舉個栗子,你可能想實現(xiàn)一個轉(zhuǎn)換器用來轉(zhuǎn)換 Python 代碼為 JavaScript.大致的思路即使用 Python 代碼轉(zhuǎn)換器來生成 AST,然后使用該 AST 來生成 JavaScript 代碼。你可能會覺得難以置信。事實是 ASTs 只是部分語言的不同表示法。在解析之前,它表現(xiàn)為文本,該文本遵守著構(gòu)成語言的一些語法規(guī)則。解析之后,它表現(xiàn)為一種樹狀結(jié)構(gòu),該結(jié)構(gòu)所包含的信息和輸入文本幾乎一樣。因此,也可以進(jìn)行反向解析然后回到文本。
JavaScript 解析讓我們看一下 AST 的構(gòu)造。以如下一個簡單 JavaScript 函數(shù)為例子:
function foo(x) { if (x > 10) { var a = 2; return a * x; } return x + 10; }
解析器會產(chǎn)生如下的 AST。
請注意,這里為了展示用只是解析器輸出的簡化版本。實際的 AST 要更加復(fù)雜。然而,這里的意思即了解一下運行源碼之前的第一個步驟??梢栽L問 AST Explorer 來查看實際的 AST 樹。這是一個在線工具,你可以在上面寫 JavaScript 代碼,然后網(wǎng)站會輸出目標(biāo)代碼的 AST。
也許你會問為什么我得學(xué)習(xí) JavaScript 解析器的工作原理。反正,瀏覽器會負(fù)責(zé)運行 JavaScript 代碼。你有那么一丁點是正確的。以下圖表展示了 JavaScript 運行過程中不同階段的耗時。瞪大眼睛瞅瞅,也許你可以發(fā)現(xiàn)點有趣的東西。
發(fā)現(xiàn)沒?通常情況下,瀏覽器大概消耗了 15% 到 20% 的總運行時間來解析 JavaScript.我沒有具體統(tǒng)計過這些數(shù)值。這些統(tǒng)計數(shù)據(jù)來自于現(xiàn)實世界中程序和網(wǎng)站的各種 JavaScript 使用姿勢。 現(xiàn)在也許 15% 看起來不是很多,但相信我,很多的。一個典型的單頁程序會加載大約 0.4M 的 JavaScript 代碼,然后消耗掉瀏覽器大概 370ms 的時間來進(jìn)行解析。也許你會又說,這也不是很多嘛。本身花費的時間并不多。但記住了,這只是把 JavaScript 代碼轉(zhuǎn)化為 ASTs 所消耗的時間。其中不包含運行本身的時間或者頁面加載期間其它諸如 CSS 和 HTML 渲染的過程的耗時。這僅僅只是桌面瀏覽器所面臨的問題。移動瀏覽器的情況會更加復(fù)雜。一般情況下,手機(jī)移動瀏覽器解析代碼的時間是桌面瀏覽器的 2-5 倍。
以上圖表展示了不同移動和桌面瀏覽器解析 1MB JavaScript 代碼所消耗的時間。
另外,為了獲得更多類原生的用戶體驗而把越來越多的業(yè)務(wù)邏輯堆積在前端,網(wǎng)頁程序變得越來越復(fù)雜。網(wǎng)頁程序越來越胖,都快走不動了。你可以輕易地想到網(wǎng)絡(luò)應(yīng)用受到的性能影響。只需打開瀏覽器開發(fā)者工具,然后使用該工具來檢測解析,編譯及其它發(fā)生于瀏覽器中直到頁面完全加載所消耗的時間。
不幸的是,移動瀏覽器沒有開發(fā)者工具來進(jìn)行性能檢測。不用擔(dān)心。因為有 DeviceTiming 工具。它可以用來幫助檢測受控環(huán)境中腳本的解析和運行時間。它通過插入代碼來封裝本地代碼,這樣每當(dāng)從不同設(shè)備訪問的時候,可以本地測量解析和運行時間。
好事即 JavaScript 引擎做了大量的工作來避免冗余工作及更加高效。以下為主流瀏覽器使用的技術(shù)。
例如,V8 實現(xiàn)了 script 流和代碼緩存技術(shù)。Script 流即當(dāng)腳本開始下載的時候,async 和 deferred 的腳本在多帶帶的線程中進(jìn)行解析。這意味著解析會在腳本下載完成時立即完成。這會提升 10% 的頁面加載速度。
每當(dāng)訪問頁面的時候,JavaScript 代碼通常會被編譯為字節(jié)碼。但是,當(dāng)用戶訪問另一個頁面的時候,該字節(jié)碼會作廢。這是因為編譯的代碼嚴(yán)重依賴于編譯階段機(jī)器的狀態(tài)和上下文。從 Chrome 42 開始帶來了字節(jié)碼緩存。該技術(shù)會本地緩存編譯過的代碼,這樣當(dāng)用戶返回到同一頁面的時候,諸如下載,解析和編譯等所有步驟都會被跳過。這樣就會為 Chrome 節(jié)約大概 40% 的代碼解析和編譯時間。另外,這同樣會節(jié)省手機(jī)電量。
Opera 中,Carakan 引擎可以復(fù)用另一個程序最近編譯過的輸出。不要求代碼在同一頁面或是相同域名下。該緩存技術(shù)非常高效且可以完全跳過編譯步驟。它依賴于典型的用戶行為和瀏覽場景:每當(dāng)用戶在程序/網(wǎng)站上遵循特定的用戶瀏覽習(xí)慣,則會加載相同的 JavaScript 代碼。然而,Carakan 早就被谷歌 V8 引擎所取代。
Firefox 使用的 SpiderMonkey 引擎沒有使用任何的緩存技術(shù)。它可以過渡到監(jiān)視階段,在那里記錄腳本運行次數(shù)。基于此計算,它推導(dǎo)出頻繁使用而可以被優(yōu)化的代碼部分。
很明顯地,一些人選擇不做任何處理。Safari 首席開發(fā)者 Maciej Stachowiak 指出 Safari 不緩存編譯的字節(jié)碼。他們可能已經(jīng)想到了緩存技術(shù)但并沒付諸實施,因為生成代碼的耗時小于總運行時間的 2%。
這些優(yōu)化措施沒有直接影響 JavaScript 源碼的解析時間,但是會盡可能完全避免。畢竟聊勝于無。
有許多方法可以用來減少程序的初始化加載時間。最小化加載的 JavaScript 數(shù)量:代碼越少,解析耗時越少,運行時間越少。為了達(dá)到此目的,可以用特殊的方法傳輸必需的代碼而不是一股勞地加載一大坨代碼。比如,PRPL 模式即表示該種代碼傳輸類型。或者,可以檢查依賴然后查看是否有無用、冗余的依賴導(dǎo)致代碼庫的膨脹。然而,這些東西需要很大的篇幅來進(jìn)行討論。
本文的目標(biāo)即開發(fā)者如何幫助加快 JavaScript 解析器的解析速度。現(xiàn)代 JavaScript 解析器使用 heuristics(啟發(fā)法) 來決定是否立即運行指定的代碼片段或者推遲在未來的某個時候運行?;谶@些 heuristics,解析器會進(jìn)行立即或者懶解析。立即解析會運行需要立即編譯的函數(shù)。其主要做三件事:構(gòu)建 AST,構(gòu)建作用域?qū)蛹?,然后檢查所有的語法錯誤。而懶解析只運行未編譯的函數(shù),它不構(gòu)建 AST和檢查任何語法錯誤。只構(gòu)建作用域?qū)蛹墸@樣相對于立即解析會節(jié)省大約一半的時間。
顯然,這并不是一個新概念。甚至像 IE9 這樣老掉牙的瀏覽器也支持該優(yōu)化技術(shù),雖然和現(xiàn)代解析器的工作方式相比是以一種簡陋的方式實現(xiàn)的。
舉個栗子吧。假設(shè)有如下代碼片段:
function foo() { function bar(x) { return x + 10; } function baz(x, y) { return x + y; } console.log(baz(100, 200)); }
和之前代碼類似,把代碼輸入解析器進(jìn)行語法分析然后輸出 AST。這樣表述如下:
聲明 bar 函數(shù)接收 x 參數(shù)。有一個返回語句。函數(shù)返回 x 和 10 相加的結(jié)果。
聲明 baz 函數(shù)接收兩個參數(shù)(x 和 y)。有一個返回語句。函數(shù)函數(shù) x 和 y 相加結(jié)果。
調(diào)用 baz 函數(shù)傳入 100 和 2。
調(diào)用 console.log 參數(shù)為之前函數(shù)調(diào)用的返回值。
那么期間發(fā)生了什么呢?解析器發(fā)現(xiàn)了 bar 函數(shù)聲明, baz 函數(shù)聲明,調(diào)用 bar 函數(shù)及調(diào)用 console.log 函數(shù)。然而,解析器做了完全不相關(guān)的額外無用功即解析 bar 函數(shù)。為何不相關(guān)?因為函數(shù) bar 從未被調(diào)用(或者至少不是在對應(yīng)時間點上)。這只是一個簡單示例及可能有些不同尋常,但是在現(xiàn)實生活的許多程序中,許多函數(shù)聲明從未被調(diào)用過。
這里不解析 bar 函數(shù),該函數(shù)聲明了卻沒有指出其用途。只在需要的時候在函數(shù)運行前進(jìn)行真正的解析。懶解析仍然只需要找出整個函數(shù)體然后為其聲明。它不需要語法樹因其將不會被處理。另外,它不從內(nèi)存堆中分配內(nèi)存,而這會消耗相當(dāng)一部分系統(tǒng)資源。簡而言之,跳過這些步驟可以有巨大的性能提升。
所以之前的例子,解析器實際上會像如下這樣解析:
注意到這里僅僅只是確認(rèn)函數(shù) bar 聲明。沒有進(jìn)入 bar 函數(shù)體。當(dāng)前情況下,函數(shù)體只有一句簡單的返回語句。然而,正如現(xiàn)代世界中的大多數(shù)程序那樣,函數(shù)體可能會更加龐大,包含多個返回語句,條件語句,循環(huán),變量聲明甚至嵌套函數(shù)聲明。由于函數(shù)從未被調(diào)用,這完全是在浪費時間和系統(tǒng)資源。
實際上這是一個相當(dāng)簡單的概念,然而其實現(xiàn)是非常難的。不局限于以上示例。整個方法還可以應(yīng)用于函數(shù),循環(huán),條件語句,對象等等。一般情況下,所有代碼都需要解析。
例如,以下是一個實現(xiàn) JavaScript 模塊的相當(dāng)常見的模式。
var myModule = (function() { // 整個模塊的邏輯 // 返回模塊對象 })();
該模式可以被大多數(shù)現(xiàn)代 JavaScript 解析器識別且標(biāo)識里面的代碼需要立即解析。
那么為何解析器不都使用懶解析呢?如果懶解析一些代碼,而該代碼必須立即運行,這樣就會降低代碼運行速度。需要運行一次懶解析之后進(jìn)行另一個立即解析。和立即解析相比,運行速度會降低 50%。
現(xiàn)在,對解析器底層原理有了大致的理解,是時候考慮如何幫助提高解析器的解析速度了。可以以這樣的方式編寫代碼,這樣就可以在正確的時間解析函數(shù)。這里有一個為大多數(shù)解析器所識別的模式:使用括號封裝函數(shù)。這樣會告訴解析器需要立即函數(shù)。如果解析器看到一個左括號且之后為函數(shù)聲明,它會立即解析該函數(shù)??梢酝ㄟ^顯式聲明立即運行函數(shù)來幫助解析器加快解析速度。
假設(shè)有一個 foo 函數(shù)
function foo(x) { return x * 10; }
因為沒有明顯地標(biāo)識表明需要立即運行該函數(shù)所以瀏覽器會進(jìn)行懶解析。然而,我們確定這是不對的,那么可以運行兩個步驟。
首先,把函數(shù)存儲為一變量。
var foo = function foo(x) { return x * 10; };
注意,在 function 關(guān)鍵字和函數(shù)參數(shù)的左括號之間的函數(shù)名。這并不是必要的,但推薦這樣做,因為當(dāng)拋出異常錯誤的時候,堆棧追蹤會包含實際的函數(shù)名而不是
解析器仍然會做懶解析??梢宰鲆粋€微小的改動來解決這一問題:用括號封裝函數(shù)。
var foo = (function foo(x) { return x * 10; });
現(xiàn)在,解析器看見 function 關(guān)鍵字前的左括號便會立即進(jìn)行解析。
因需要知道解析器在何種情況下懶解析或者立即解析代碼,所以可操作性會很差。同樣地,開發(fā)者需要花時間考慮指定的函數(shù)是否需要立即解析??隙]人想費力地這么做。最后,這肯定會讓代碼難以閱讀和理解。可以使用 Optimize.js 來處理此類情況。該工具只是用來優(yōu)化 JavaScript 源代碼的初始加載時間。他們對代碼運行靜態(tài)分析,然后通過使用括號封裝需要立即運行的函數(shù)以便瀏覽器立即解析并準(zhǔn)備運行它們。
那么,可以如平常雜編碼然后一小段代碼如下:
(function() { console.log("Hello, World!"); })();
一切看起來很美好,因為在函數(shù)聲明前添加了左括號。當(dāng)然,在進(jìn)入生產(chǎn)環(huán)境之前需要進(jìn)行代碼壓縮。以下為壓縮工具的輸出:
!function(){console.log("Hello, World!")}();
看起來一切正常。代碼如期運行。然而好像少了什么。壓縮工具移除了封裝函數(shù)的括號代之以一個感嘆號。這意味著解析器會跳過該代碼且將會運行懶解析??傊?,為了運行該函數(shù)解析器會在懶解析之后進(jìn)行立即解析。這會導(dǎo)致代碼運行變慢。幸運的是,可以利用 Optimize.js 來解決此類問題。傳給 Optimize.js 壓縮過的代碼會輸出如下代碼:
!(function(){console.log("Hello, World!")})();
現(xiàn)在,充分利用了各自的優(yōu)勢:壓縮代碼且解析器正確地識別懶解析和立即解析的函數(shù)。
預(yù)編譯但是為何不在服務(wù)端進(jìn)行這些工作呢?總之,比強(qiáng)制各個客戶端重復(fù)做該項事情更好的做法是只運行一次并在客戶端輸出結(jié)果。那么,有一個正在進(jìn)行的討論即引擎是否需要提供一個運行預(yù)編譯代碼的功能以節(jié)省瀏覽器的運行時間。本質(zhì)上,該思路即使用服務(wù)端工具來生成字節(jié)碼,這樣就只需要傳輸字節(jié)碼并在客戶端運行。之后,將會看到啟動時間上的一些主要差異。這聽起來很有誘惑性但實現(xiàn)起來會很難??赡軙蟹葱Ч驗樗鼘荦嫶笄矣捎诎踩蚝苡锌赡苄枰M(jìn)行簽名和處理。例如,V8 團(tuán)隊已經(jīng)在內(nèi)部解決重復(fù)解析問題,這樣預(yù)編譯有可能實際上沒啥鳥用。
一些提升網(wǎng)絡(luò)應(yīng)用速度的建議檢查依賴。減少不必要的依賴。
分割代碼為更小的塊而不是一整塊。如 webpack 的 code-spliting 功能。
盡可能延遲加載 JavaScript 代碼??梢灾患虞d當(dāng)前路由所要求的代碼片段。比如只在點擊某個元素的時候引入 某段代碼模塊。
使用開發(fā)者工具和 DeviceTiming 來檢測性能瓶頸。
使用像 Optimize.js 的工具來幫助解析器選擇立即解析或者懶解析以加快解析速度。
拓展有時候,特別是手機(jī)端瀏覽器,比如當(dāng)你點擊前進(jìn)/后退按鈕的時候,瀏覽器會進(jìn)行緩存。但是在有些場景下,你可能不需要瀏覽器的這種功能。有如下解決辦法:
window.addEventListener("pageshow", (event) => { // 檢查前進(jìn)/后退緩存,是否從緩存加載頁面 if (event.persisted || window.performance && window.performance.navigation.type === 2) { // 進(jìn)行相應(yīng)的邏輯處理 } };
本系列持續(xù)更新中,Github 地址請查閱這里。
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/96127.html
摘要:事實是只是部分語言的不同表示法。基于這些,解析器會進(jìn)行立即或者懶解析。然而,解析器做了完全不相關(guān)的額外無用功即解析函數(shù)。這里不解析函數(shù),該函數(shù)聲明了卻沒有指出其用途。所以之前的例子,解析器實際上 原文請查閱這里,本文采用知識共享署名 4.0 國際許可協(xié)議共享,BY Troland。 本系列持續(xù)更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第十四章。 概...
摘要:事實是只是部分語言的不同表示法。基于這些,解析器會進(jìn)行立即或者懶解析。然而,解析器做了完全不相關(guān)的額外無用功即解析函數(shù)。這里不解析函數(shù),該函數(shù)聲明了卻沒有指出其用途。所以之前的例子,解析器實際上 原文請查閱這里,本文采用知識共享署名 4.0 國際許可協(xié)議共享,BY Troland。 本系列持續(xù)更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第十四章。 概...
摘要:使用新的易用的類定義,歸根結(jié)底也是要創(chuàng)建構(gòu)造函數(shù)和修改原型。首先,它把構(gòu)造函數(shù)當(dāng)成單獨的函數(shù)且包含類屬性集。該節(jié)點還儲存了指向父類的指針引用,該父類也并儲存了構(gòu)造函數(shù),屬性集和及父類引用,依次類推。 原文請查閱這里,略有刪減,本文采用知識共享署名 4.0 國際許可協(xié)議共享,BY Troland。 本系列持續(xù)更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第...
摘要:使用新的易用的類定義,歸根結(jié)底也是要創(chuàng)建構(gòu)造函數(shù)和修改原型。首先,它把構(gòu)造函數(shù)當(dāng)成單獨的函數(shù)且包含類屬性集。該節(jié)點還儲存了指向父類的指針引用,該父類也并儲存了構(gòu)造函數(shù),屬性集和及父類引用,依次類推。 原文請查閱這里,略有刪減,本文采用知識共享署名 4.0 國際許可協(xié)議共享,BY Troland。 本系列持續(xù)更新中,Github 地址請查閱這里。 這是 JavaScript 工作原理的第...
閱讀 3752·2021-11-24 10:46
閱讀 1718·2021-11-15 11:38
閱讀 3772·2021-11-15 11:37
閱讀 3496·2021-10-27 14:19
閱讀 1955·2021-09-03 10:36
閱讀 2002·2021-08-16 11:02
閱讀 3009·2019-08-30 15:55
閱讀 2262·2019-08-30 15:44