javascript
JavaScript 是如何工作的:解析、抽象语法树(AST)+ 提升编译速度5个技巧
這是專門探索 JavaScript 及其所構(gòu)建的組件的系列文章的第 14 篇。
如果你錯過了前面的章節(jié),可以在這里找到它們:
概述
我們都知道運行一大段 JavaScript 代碼性能會變得很糟糕。這段代碼不僅需要通過網(wǎng)絡傳輸,而且還需要解析、編譯成字節(jié)碼,最后執(zhí)行。在之前的文章中,我們討論了 JS 引擎、運行時和調(diào)用堆棧等,以及主要由谷歌 Chrome 和 NodeJS 使用的V8引擎。它們在整個 JavaScript 執(zhí)行過程中都發(fā)揮著至關重要的作用。這篇說的抽象語法樹同樣重要:在這我們將了解大多數(shù) JavaScript 引擎如何將文本解析為對機器有意義的內(nèi)容,轉(zhuǎn)換之后發(fā)生的事情以及做為 Web 開發(fā)者如何利用這一知識。
編程語言原理
那么,首先讓我們回顧一下編程語言原理。不管你使用什么編程語言,你需要一些軟件來處理源代碼以便讓計算機能夠理解。該軟件可以是解釋器,也可以是編譯器。無論你使用的是解釋型語言(JavaScript、Python、Ruby)還是編譯型語言(c#、Java、Rust),都有一個共同的部分:將源代碼作為純文本解析為 抽象語法樹(abstract syntax tree, AST) 的數(shù)據(jù)結(jié)構(gòu)。
AST 不僅以結(jié)構(gòu)化的方式顯示源代碼,而且在語義分析中扮演著重要角色。在語義分析中,編譯器驗證程序和語言元素的語法使用是否正確。之后,使用 AST 來生成實際的字節(jié)碼或者機器碼。
抽象語法樹(abstract syntax tree 或者縮寫為 AST),或者語法樹(syntax tree),是源代碼的抽象語法結(jié)構(gòu)的樹狀表現(xiàn)形式,這里特指編程語言的源代碼。和抽象語法樹相對的是具體語法樹(concrete syntaxtree),通常稱作分析樹(parse tree)。一般的,在源代碼的翻譯和編譯過程中,語法分析器創(chuàng)建出分析樹。一旦 AST 被創(chuàng)建出來,在后續(xù)的處理過程中,比如語義分析階段,會添加一些信息。AST 程序
AST 不僅僅是用于語言解釋器和編譯器,在計算機世界中,它們還有多種應用。使用它們最常見的方法之一是進行靜態(tài)代碼分析。靜態(tài)分析器不執(zhí)行輸入的代碼,但是,他們?nèi)匀恍枰斫獯a的結(jié)構(gòu)。
例如,你可能想要實現(xiàn)一個工具,該工具可以找到公共代碼結(jié)構(gòu),以便你可以重構(gòu)它們以減少重復。你可能會通過使用字符串比較來實現(xiàn)這一點,但這個會相當簡單且有局限性。
當然,如果你對實現(xiàn)這樣的工具感興趣,你不需要編寫自己的解析器。有許多與 Ecmascript規(guī)范完全兼容的開源項目。Esprima 和 Acorn 即是黃金搭檔,還有許多工具可以幫助解析器生成輸出,即 ASTs ,ASTs 被廣泛應用于代碼轉(zhuǎn)換。
例如,你可能希望實現(xiàn)一個將 Python 代碼轉(zhuǎn)換為J avaScript 的轉(zhuǎn)換器。基本思想是使用Python 轉(zhuǎn)換器生成 AST,然后使用 AST 生成JavaScript代碼。
你可能會覺得難以置信,事實是 ASTs 只是部分語言的不同表示法。在解析之前,它被表示為遵循一些規(guī)則的文本,這些規(guī)則構(gòu)成了一種語言。在解析之后,它被表示為一個樹結(jié)構(gòu),其中包含與輸入文本完全相同的信息。因此,也可以進行反向解析然后回到文本。
JavaScript 解析
讓我們看看 AST 是如何構(gòu)建的。我們用一個簡單的 JavaScript 函數(shù)作為例子:
function foo(x) {if (x > 10) {var a = 2;return a * x;}return x + 10; }解析器會產(chǎn)生如下的 AST:
注意,為了觀看方便,這里是解析器將生成的結(jié)果的簡化版本。實際的 AST 要復雜得多。然而,這里的目的是為了運行源碼之前的第一個步驟前。如果人想查看實際的 AST 是什么樣子,可以訪問 AST Explorer。它是一個在線工具,你以在其中輸入一些 JavaScript 并輸出對應的 AST。
你可能會問,為什么需要知道 JavaScript解析器工作原理,畢竟這是瀏覽器工作,你想法是部分正確。下圖展示了 JavaScript 執(zhí)行過程中不同階段的耗時。仔細瞅瞅,你或許會發(fā)現(xiàn)一些有趣的東西。
發(fā)現(xiàn)沒? 通常情況下,瀏覽器解析 JavaScript 大約需占總執(zhí)行時間的 15% 到 20%。我沒有具體統(tǒng)計過這些數(shù)值。這些是來自真實應用程序和以某種方式使用 JavaScript 的網(wǎng)站的統(tǒng)計數(shù)據(jù)。也許 15% 看起來不是很多,但相信我,這是很多。
一個典型的單頁程序加載 0.4 mb 左右的 JavaScript,瀏覽器需要大約 370ms 來解析它。也許你會又說,這也不是很多嘛,本身花費的時間并不多。但請記住,這只是將 JavaScript 代碼解析為 AST 所需要的時間。這并不包括運行本身的時間,也不包括在頁面加載 ,如 CSS 和 HTML 渲染過程的耗時。這些還只涉及桌面,移動瀏覽器的情況會更加復雜,在手機上花在解析上的時間通常是桌面瀏覽器的 2 到 5 倍。
上圖顯示了 1MB JavaScript 包在不同類的移動和桌面瀏覽器解析時間。
更重要的是,為了獲得更多類原生的用戶體驗而把越來越多的業(yè)務邏輯堆積在前端,Web 應用程序正變得越來越復雜。你可以輕易地想到網(wǎng)絡應用受到的性能影響。只需打開瀏覽器開發(fā)工具,然后使用該工具來解析、編譯和瀏覽器中發(fā)生的所有其他事情上所消耗的時間。
不幸的是,移動瀏覽器上沒有開發(fā)者工具。不過不用擔心,這并不意味著你對此無能為力。因為有 DeviceTiming 工具,它可以用來幫助檢測受控環(huán)境中腳本的解析和運行時間。它通過插入代碼來封裝本地代碼,這樣每次從不同的設備訪問頁面時,就可以在本地測量解析和運行時間。
好事就是 JavaScript 引擎做了很多工作來避免冗余的工作,并得到了更好的優(yōu)化,以下為主流瀏覽器使用的技術。
例如,V8 實現(xiàn)腳本流(script streaming)和代碼緩存技術。腳本流即腳本一旦開始下載,async 和 deferred的 腳本就會在單獨的線程上解析。這意味著在下載腳本完成后幾乎立即完成解析,這會提升 10% 的頁面加載速度。
每次訪問頁面時,JavaScript 代碼通常編譯為字節(jié)碼。 然而,一旦用戶訪問另一頁面,該字節(jié)碼就被丟棄。 發(fā)生這種情況是因為編譯后的代碼很大程度上依賴于編譯時機器的狀態(tài)和上下文。 這是 Chrome 42 引入字節(jié)碼緩存的原因。 該技術會本地緩存編譯過的代碼,這樣當用戶返回同一頁面時,諸如下載,解析和編譯等所有步驟都會被跳過。 這使得 Chrome 可以節(jié)省大約 40% 的解析和編譯時間。 此外,這還可以節(jié)省移動設備的電量。
在 Opera 中,Carakan 引擎可以重用另一個程序最近編譯過的輸出。沒有要求代碼必須來自相同的頁面甚至同個域下。這種緩存技術實際上非常高效,還可以完全跳過編譯步驟。它依賴于典型的用戶行為和瀏覽場景:每當用戶在應用程序/網(wǎng)站中遵循某個用戶的特定瀏覽習慣,都會加載相同的 JavaScript 代碼。不過,Carakan 引擎早已被谷歌的 V8 所取代。
Opera 新的 JavaScript 引擎 “Carakan”,目前速度是其他已存在 JavaScript 引擎(基于 SunSpider)的2.5倍。其在轉(zhuǎn)化為本地機器代碼時專門針對正則表達式做了優(yōu)化。Firefox 使用的 SpiderMonkey 引擎不會緩存所有內(nèi)容。它可以過渡到監(jiān)視階段,在這個階段中,它計算執(zhí)行給定腳本的次數(shù)。基于此計算,它推導出頻繁使用而可以被優(yōu)化的代碼部分。
SpiderMonkey 是 Mozilla 項目的一部分,是一個用 C 語言實現(xiàn)的 JavaScript 腳本引擎,另外還有一個叫做Rhino 的 Java 版本。顯然,有些人決定什么都不做。Safari 的首席開發(fā)人員 Maciej Stachowiak 表示,Safari 不會對編譯后的字節(jié)碼進行任何緩存。緩存技術他們是有考慮過的問題,但是他們還沒有實現(xiàn),因為生成代碼的耗時小于總運行時間的 2%。
這些優(yōu)化不會直接影響 JavaScript 源代碼的解析,但是會盡可能完全避免。畢竟做總比沒做好點?
我們可以做很多事情來改善應用程序的初始加載時間。最小化加載的 JavaScript 數(shù)量:代碼越小、解析所需要時間就越少,運行時間也就越小。要做到這一點,我們只能在當前的路由上加載所需的代碼,而不是加載一大陀的代碼。例如,PRPL模式即表示該種代碼傳輸類型。或者,可以檢查代碼的依賴關系,看看是否有什么冗余的依賴導致代碼庫膨脹,然而,這些東西需要很大的篇幅來進行討論。
本文的主要的目的討論作為 Web 開發(fā)人員可以做些什么來幫助 JavaScript 解析器更快地完成它的工作。還有,現(xiàn)代JavaScript 解析器使用 啟發(fā)法(heuristics) 來決定是否立即運行指定的代碼片段或者推遲在未來的某個時候運行。基于這些啟發(fā)法,解析器將進行即時或懶解析。
啟發(fā)法是針對模型求解方法而言的,是一種逐次逼近最優(yōu)解的方法。這種方法對所求得的解進行反復判斷實踐修正直至滿意為止。啟發(fā)法的特點是模型簡單,需要進行方案組合的個數(shù)少,因此便于找出最終答案。此方法雖不能保證得到最優(yōu)解,但只要處理得當,可獲得決策者滿意的近似最優(yōu)解。一般步驟包括:定義一個計算總費用的方法;報定判別準則;規(guī)定方案改選的途徑;建立相應的模型;送代求解。立即解析會運行需要立即編譯的函數(shù)。它主要做三件事:構(gòu)建 AST,構(gòu)建作用域?qū)蛹壓筒檎宜姓Z法錯誤。另一方面, 懶解析只運行未編譯的函數(shù)。它不構(gòu)建AST,也不查找所有語法錯誤,它只構(gòu)建作用域?qū)蛹?#xff0c;與立即解析相比節(jié)省了大約一半的時間。
顯然,這不是一個新概念。即使像 IE 9 這樣的瀏覽器也支持這種類型的優(yōu)化,盡管與現(xiàn)在的解析器的工作方式相比,這種優(yōu)化方式還很初級。
來看一個例子,假設有以下代碼片段:
function foo() {function bar(x) {return x + 10;}function baz(x, y) {return x + y;}console.log(baz(100, 200)); }foo()就像前面的例子一樣,代碼被輸入到語法分析器中,語法分析器進行語法分析并輸出AST,如下:
- 聲明函數(shù) foo
- 調(diào)用函數(shù) foo
- 在 foo 里聲明函數(shù) bar 接收參數(shù) x, 并返回 x 和 10 相加的結(jié)果
- 在 foo 里聲明函數(shù) baz 接收參數(shù) x和 y, 并返回 x 和 y 相加的結(jié)果
- 調(diào)用 baz 函數(shù)傳入 100 和 2。
- 調(diào)用 console.log 參數(shù)為之前函數(shù)調(diào)用的返回值。
那么期間發(fā)生了什么? 解析器看到 bar 函數(shù)的聲明、baz 函數(shù)的聲明、bar函數(shù)的調(diào)用和 console.log 的調(diào)用。但是,解析器做了一些完全無關的額外工作即解析 bar 函數(shù)。為什么這無關緊要? 因為函數(shù) bar 從來沒有被調(diào)用過(或者至少在那個時候沒有)。這是一個簡單的示例,看起來可能有些不同尋常,但在許多實際應用程序中,許多聲明的函數(shù)從未被調(diào)用。
這里不解析bar函數(shù),該函數(shù)聲明了卻沒有調(diào)用它。只在需要的時候在函數(shù)運行前進行真正的解析。懶解析仍然需要找到函數(shù)的整個主體并為其聲明,但僅此而已。它不需要語法樹,因為它還沒有被處理。另外,它不會從堆中分配內(nèi)存,而堆通常會占用相當多的系統(tǒng)資源,簡而言之,跳過這些步驟會帶來很大的性能改進。
所以之前的例子,解析器實際上會像如下這樣解析:
注意,這里只確認 bar 函數(shù)聲明,沒有進入 bar 函數(shù)體。在這種情況下,函數(shù)體只是一個返回語句。但是,與大多數(shù)實際應用程序一樣,它可以更大,包含多個返回語句、條件語句、循環(huán)、變量聲明,甚至嵌套函數(shù)聲明。這完全是在浪費時間和系統(tǒng)資源,因為這個函數(shù)永遠不會被調(diào)用。
這是一個相當簡單的概念,但實際上,它的實現(xiàn)是非常難的,不局限于以上示例。整個方法還可以適用于函數(shù)、循環(huán)、條件、對象等。基本上,所有需要解析的東西。
例如,下面是一個非常常見的 JavaScript 模式。
var myModule = (function() {// 整個模塊的邏輯// 返回模塊對象 })();大多數(shù)現(xiàn)代 JavaScript 解析器都能識別這種模式,此模式表示代碼需要立即解析。
那么為什么解析器不都使用懶解析呢? 如果懶解析某些代碼,這些代碼需要立即執(zhí)行,這實際上會使代碼運行速度變慢。需要運行一次懶解析之后進行另一個立即解析,這和立即解析相比,運行速度會慢 50%。
現(xiàn)在對解析器底層原理有了大致的了解,是時候考慮如何提高解析器的解析速度。可以用這種方式編寫代碼,以便在正確的時間解析函數(shù)。大多數(shù)解析器都能識別一種模式:使用括號封裝函數(shù)。對于解析器來說,這幾乎總是一個積極的信號,即函數(shù)需要立即執(zhí)行。如果解析器看到一個左括號,緊接著是一個函數(shù)聲明,它將立即解析這個函數(shù)。可以通過顯式地聲明立即執(zhí)行的函數(shù)來幫助解析器加快解析速度。
假設有一個名為 foo 的函數(shù)。
function foo(x) {return x * 10; }因為沒有明顯地標識表明需要立即運行該函數(shù)所以瀏覽器會進行懶解析。然而,我們確定這是不對的,那么可以運行兩個步驟。
首先,將函數(shù)存儲在一個變量中:
var foo = function foo(x) {return x * 10; };注意,這里有使用函數(shù)的名稱 foo,這不是必需的,但是建議這樣做,因為在拋出異常的情況下,stacktrace 會保留實際函數(shù)名稱,而不僅僅是 <anonymous>。
以上事例解析器執(zhí)行懶解析,可以用括號封裝起來,讓解析器進行立即解析:
var foo = (function foo(x) {return x * 10; });現(xiàn)在,解析器看見 function 關鍵字前的左括號便會立即進行解析。
因為需要知道解析器在哪些情況下執(zhí)行懶解析或者立即解析,所以很難手動管理。此外,還需要花時間考慮是否立即調(diào)用某個函數(shù),肯定沒人想這么做的。
最后,這種地讓代碼更難閱讀和理解。可以使用 Optimize.js 可以幫我們做這類事情,該工具只是用來優(yōu)化 JavaScript 源代碼的初始加載時間,它們對代碼進行靜態(tài)分析,然后通過使用括號封裝需要立即運行的函數(shù)以便瀏覽器立即解析并準備運行它們。
像往常一樣編碼,然后有一段代碼看起來像這樣的:
(function() {console.log('Hello, World!'); })();一切看起來都很好,如預期的那樣工作,而且速度很快,因為在函數(shù)聲明之前添加左括號。當然,在進入生產(chǎn)環(huán)境之前需要進行代碼壓縮,以下為壓縮工具的輸出:
!function(){console.log('Hello, World!')}();好像沒問題,代碼像以前一樣工作。但是好像少了什么,壓縮工具刪除包裹函數(shù)的括號,而是在函數(shù)前放置了一個感嘆號,這意味著解析器將跳過此并將執(zhí)行惰解析。
最重要的是,為了能夠執(zhí)行該函數(shù),它將在懶解析之后立即進行立即解析。 這會使代碼運行得更慢,幸運的是,可以利用 Optimize.js 來解決此類問題,傳給 Optimize.js 壓縮過的代碼會輸出如下代碼:
!(function(){console.log('Hello, World!')})();這還差不多,現(xiàn)在擁有兩全其美方案:壓縮代碼且解析器正確地識別懶解析和立即解析的函數(shù)。
預編譯
但為什么不能在服務器端完成所有這些工作呢? 畢竟,最好這樣做一次并將結(jié)果提供給客戶端,而不強制各個客戶端重復做該項事情。那么,目前正在討論引擎是否應該提供一種執(zhí)行預編譯腳本的方法,這樣就可以節(jié)省瀏覽器運行時間。
從本質(zhì)上講,該思路是擁有可以生成字節(jié)碼的務器端工具,這樣只需要傳輸字節(jié)碼并在客戶端運行,之后會看到啟動時間的一些主要差異。 這可能聽起來很誘人,但事情并非那么簡單,還可能會產(chǎn)生相反的效果,因為它會更大,并且很可能需要簽署代碼并出于安全原因?qū)ζ溥M行處理。 例如,V8 團隊正在努力解決重復解析問題,這樣預編譯有可能實際并沒有多大的用處。
提升編譯速度一些建議
- 檢查依賴,減少不必要的依賴
- 分割代碼為更小的塊而不是一整陀的
- 盡可能推遲加載 JavaScript,按需要加載或者動態(tài)加載。
- 使用開發(fā)者工具和 DeviceTiming 來檢測性能瓶頸
- 用像 Optimize.js 的工具來幫助解析器選擇立即解析或者懶解析以加快解析速度
原文:
https://blog.sessionstack.com...
代碼部署后可能存在的BUG沒法實時知道,事后為了解決這些BUG,花了大量的時間進行l(wèi)og 調(diào)試,這邊順便給大家推薦一個好用的BUG監(jiān)控工具 Fundebug。
你的點贊是我持續(xù)分享好東西的動力,歡迎點贊!
一個笨笨的碼農(nóng),我的世界只能終身學習!
更多內(nèi)容請關注公眾號《大遷世界》!
總結(jié)
以上是生活随笔為你收集整理的JavaScript 是如何工作的:解析、抽象语法树(AST)+ 提升编译速度5个技巧的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: “数读”上海营商环境:对追求高水平稳态发
- 下一篇: vue打包关闭console.log