How Javascript works (Javascript工作原理) (十四) 解析,文法抽象樹及最小化解析時間的 5 條小技巧

來源:互聯網
上載者:User

標籤:插入   模組   推薦   原始碼   概述   之間   google   rom   font   

個人總結:讀完這篇文章需要15分鐘,文章介紹了抽象文法樹與js引擎解析這些文法樹的過程,提到了懶解析——即轉換為AST的過程中不直接進入函數體解析,當這個函數體需要執行的時候才進行相應轉換。(因為有的函數體只是聲明了,並沒有實際被調用) 解析,文法抽象樹及最小化解析時間的 5 條小技巧

這是 JavaScript 工作原理的第十四章。

概述

我們都知道運行一大段 JavaScript 代碼效能會變得很糟糕。代碼不僅僅需要在網路中傳輸而且還需要解析,編譯為位元組碼,最後運行。之前的文章討論了諸如 JS 引擎,運行時及調用棧,還有為 Google Chrome 和 NodeJS 廣泛使用的 V8 引擎的話題。它們都在整個 JavaScript 的運行過程中扮演著重要的角色。

今天所講的主題也非常重要:瞭解到大多數的 JavaScript 引擎是如何把文本解析為機器能夠理解的代碼,轉換之後發生的事情以及開發人員如何利用這一知識。

程式設計語言原理

那麼,首先讓我們回顧一下程式設計語言原理。無論使用何種程式設計語言,你經常需要一些軟體來處理源碼以便讓電腦能夠理解。該軟體可以是解譯器或編譯器。不管是使用解釋型語言(JavaScript, Python, Ruby) 或者編譯型語言(C#, Java, Rust),它們都有一個共同點:把源碼作為純文字解析為文法抽象樹(AST)的資料結構。AST 不僅要以結構化地方式展示源碼,而且在語義分析中扮演了重要的角色,編譯器檢查驗證程式和語言元素的文法使用是否正確。之後, 使用 AST 來產生實際的位元組碼或者機器碼。

 

AST 程式

AST 不止應用於語言解譯器和編譯器,在電腦世界中,還有其它用途。最為常見的用途之一即靜態程式碼分析。靜態程式碼分析並不會運行輸入的代碼。但是,它們仍然需要理解代碼的結構。比如,實現一個工具來找出常見的代碼結構以便用來代碼重構減少重複代碼。或許你可以使用字串比較來實現,但是工具會相當簡單且有局限性。當然了,如果你有興趣實現這樣的工具,你不必自己動手去編寫解析器,有許多完美相容於 Ecmascript 規範的開源項目。Esprima 和 Acorn 即是黃金搭檔。還有其它工具可以用來協助解析器輸出代碼,即 ASTs.ASTs 被廣泛應用於代碼轉換。舉個栗子,你可能想實現一個轉換器用來轉換 Python 代碼為 JavaScript.大致的思路即使用 Python 代碼轉換器來產生 AST,然後使用該 AST 來產生 JavaScript 代碼。你可能會覺得難以置信。事實是 ASTs 只是部分語言的不同標記法。在解析之前,它表現為文本,該文本遵守著構成語言的一些文法規則。解析之後,它表現為一種樹狀結構,該結構所包含的資訊和輸入文本幾乎一樣。因此,也可以進行反向解析然後回到文本。

 

JavaScript 解析

讓我們看一下 AST 的構造。以如下一個簡單 JavaScript 函數為例子:

function foo(x) {    if (x > 10) {        var a = 2;        return a * x;    }    return x + 10;}

解析器會產生如下的 AST。

請注意,這裡為了展示用只是解析器輸出的簡化版本。實際的 AST 要更加複雜。然而,這裡的意思即瞭解一下運行源碼之前的第一個步驟。可以訪問 AST Explorer 來查看實際的 AST 樹。這是一個線上工具,你可以在上面寫 JavaScript 代碼,然後網站會輸出目標代碼的 AST。

也許你會問為什麼我得學習 JavaScript 解析器的工作原理。反正,瀏覽器會負責運行 JavaScript 代碼。你有那麼一丁點是正確的。以表展示了 JavaScript 運行過程中不同階段的耗時。瞪大眼睛瞅瞅,也許你可以發現點有趣的東西。

發現沒?通常情況下,瀏覽器大概消耗了 15% 到 20% 的總已耗用時間來解析 JavaScript.我沒有具體統計過這些數值。這些統計資料來自於現實世界中程式和網站的各種 JavaScript 使用姿勢。 現在也許 15% 看起來不是很多,但相信我,很多的。一個典型的單頁程式會載入大約 0.4M 的 JavaScript 代碼,然後消耗掉瀏覽器大概 370ms 的時間來進行解析。也許你會又說,這也不是很多嘛。本身花費的時間並不多。但記住了,這隻是把 JavaScript 代碼轉化為 ASTs 所消耗的時間。其中不包含運行本身的時間或者頁面載入期間其它諸如 CSS 和 HTML 渲染的過程的耗時。這僅僅只是案頭瀏覽器所面臨的問題。行動瀏覽器的情況會更加複雜。一般情況下,手機行動瀏覽器解析代碼的時間是案頭瀏覽器的 2-5 倍。

以表展示了不同移動和案頭瀏覽器解析 1MB JavaScript 代碼所消耗的時間。

另外,為了獲得更多類原生的使用者體驗而把越來越多的商務邏輯堆積在前端,網頁程式變得越來越複雜。網頁程式越來越胖,都快走不動了。你可以輕易地想到網路應用受到的效能影響。只需開啟瀏覽器開發人員工具,然後使用該工具來檢測解析,編譯及其它發生於瀏覽器中直到頁面完全載入所消耗的時間。

 

不幸的是,行動瀏覽器沒有開發人員工具來進行效能檢測。不用擔心。因為有 DeviceTiming 工具。它可以用來協助檢測受控環境中指令碼的解析和已耗用時間。它通過插入代碼來封裝本地代碼,這樣每當從不同裝置訪問的時候,可以本地測量解析和已耗用時間。

好事即 JavaScript 引擎做了大量的工作來避免冗餘工作及更加高效。以下為主流瀏覽器使用的技術。

例如,V8 實現了 script 流和代碼緩衝技術。Script 流即當指令碼開始下載的時候,async 和 deferred 的指令碼在單獨的線程中進行解析。這意味著解析會在指令碼下載完成時立即完成。這會提升 10% 的頁面載入速度。

每當訪問頁面的時候,JavaScript 代碼通常會被編譯為位元組碼。但是,當使用者訪問另一個頁面的時候,該位元組碼會作廢。這是因為編譯的代碼嚴重依賴於編譯階段機器的狀態和上下文。從 Chrome 42 開始帶來了位元組程式碼快取。該技術會本機快取編譯過的代碼,這樣當使用者返回到同一頁面的時候,諸如下載,解析和編譯等所有步驟都會被跳過。這樣就會為 Chrome 節約大概 40% 的代碼解析和編譯時間。另外,這同樣會節省手機電量。

Opera 中,Carakan 引擎可以複用另一個程式最近編譯過的輸出。不要求代碼在同一頁面或是相同網域名稱下。該緩衝技術非常高效且可以完全跳過編譯步驟。它依賴於典型的使用者行為和瀏覽情境:每當使用者在程式/網站上遵循特定的使用者瀏覽習慣,則會載入相同的 JavaScript 代碼。然而,Carakan 早就被Google V8 引擎所取代。

Firefox 使用的 SpiderMonkey 引擎沒有使用任何的緩衝技術。它可以過渡到監視階段,在那裡記錄指令碼運行次數。基於此計算,它推匯出頻繁使用而可以被最佳化的代碼部分。

很明顯地,一些人選擇不做任何處理。Safari 首席開發人員 Maciej Stachowiak 指出 Safari 不緩衝編譯的位元組碼。他們可能已經想到了緩衝技術但並沒付諸實施,因為產生代碼的耗時小於總已耗用時間的 2%。

這些最佳化措施沒有直接影響 JavaScript 源碼的解析時間,但是會儘可能完全避免。畢竟聊勝於無。

有許多方法可以用來減少程式的初始化載入時間。最小化載入的 JavaScript 數量:代碼越少,解析耗時越少,已耗用時間越少。為了達到此目的,可以用特殊的方法傳輸必需的代碼而不是一股勞地載入一大坨代碼。比如,PRPL 模式即表示該種代碼傳輸類型。或者,可以檢查依賴然後查看是否有無用、冗餘的依賴導致程式碼程式庫的膨脹。然而,這些東西需要很大的篇幅來進行討論。

本文的目標即開發人員如何協助加快 JavaScript 解析器的解析速度。現代 JavaScript 解析器使用 heuristics(啟發法) 來決定是否立即運行指定的程式碼片段或者延遲在未來的某個時候運行。基於這些 heuristics,解析器會進行立即或者懶解析。立即解析會運行需要立即編譯的函數。其主要做三件事:構建 AST,構建範圍層級,然後檢查所有的語法錯誤。而懶解析只運行未編譯的函數,它不構建 AST和檢查任何語法錯誤。只構建範圍層級,這樣相對於立即解析會節省大約一半的時間。

顯然,這並不是一個新概念。甚至像 IE9 這樣老掉牙的瀏覽器也支援該最佳化技術,雖然和現代解析器的工作方式相比是以一種簡陋的方式實現的。

舉個栗子吧。假設有如下程式碼片段:

function foo() {    function bar(x) {        return x + 10;    }    function baz(x, y) {        return x + y;    }    console.log(baz(100, 200));}

和之前代碼類似,把代碼輸入解析器進行文法分析然後輸出 AST。這樣表述如下:

聲明 bar 函數接收 x 參數。有一個返回語句。函數返回 x 和 10 相加的結果。

聲明 baz 函數接收兩個參數(x 和 y)。有一個返回語句。函數函數 x 和 y 相加結果。

調用 baz 函數傳入 100 和 2。

調用 console.log 參數為之前函數調用的傳回值。

那麼期間發生了什麼呢?解析器發現了 bar 函式宣告, baz 函式宣告,調用 bar 函數及調用 console.log 函數。然而,解析器做了完全不相關的額外無用功即解析 bar 函數。為何不相關?因為函數 bar 從未被調用(或者至少不是在對應時間點上)。這隻是一個簡單樣本及可能有些不同尋常,但是在現實生活的許多程式中,許多函式宣告從未被調用過。

這裡不解析 bar 函數,該函式宣告了卻沒有指出其用途。只在需要的時候在函數運行前進行真正的解析。懶解析仍然只需要找出整個函數體然後為其聲明。它不需要文法樹因其將不會被處理。另外,它不從記憶體堆中分配記憶體,而這會消耗相當一部分系統資源。簡而言之,跳過這些步驟可以有巨大的效能提升。

所以之前的例子,解析器實際上會像如下這樣解析:

注意到這裡僅僅只是確認函數 bar 聲明。沒有進入 bar 函數體。當前情況下,函數體只有一句簡單的返回語句。然而,正如現代世界中的大多數程式那樣,函數體可能會更加龐大,包含多個返回語句,條件陳述式,迴圈,變數聲明甚至嵌套函式宣告。由於函數從未被調用,這完全是在浪費時間和系統資源。

實際上這是一個相當簡單的概念,然而其實現是非常難的。不局限於以上樣本。整個方法還可以應用於函數,迴圈,條件陳述式,對象等等。一般情況下,所有代碼都需要解析。

例如,以下是一個實現 JavaScript 模組的相當常見的模式。

var myModule = (function() {  // 整個模組的邏輯  // 返回模組對象})();

該模式可以被大多數現代 JavaScript 解析器識別且標識裡面的代碼需要立即解析。

那麼為何解析器不都使用懶解析呢?如果懶解析一些代碼,而該代碼必須立即運行,這樣就會降低代碼運行速度。需要運行一次懶解析之後進行另一個立即解析。和立即解析相比,運行速度會降低 50%。

現在,對解析器底層原理有了大致的理解,是時候考慮如何協助提高解析器的解析速度了。可以以這樣的方式編寫代碼,這樣就可以在正確的時間解析函數。這裡有一個為大多數解析器所識別的模式:使用括弧封裝函數。這樣會告訴解析器需要立即函數。如果解析器看到一個左括弧且之後為函式宣告,它會立即解析該函數。可以通過顯式聲明立即運行函數來協助解析器加快解析速度。

假設有一個 foo 函數

function foo(x) {    return x * 10;}

因為沒有明顯地標識表明需要立即運行該函數所以瀏覽器會進行懶解析。然而,我們確定這是不對的,那麼可以運行兩個步驟。

首先,把函數儲存為一變數。

var foo = function foo(x) {    return x * 10;};

注意,在 function 關鍵字和函數參數的左括弧之間的函數名。這並不是必要的,但推薦這樣做,因為當拋出異常錯誤的時候,堆棧追蹤會包含實際的函數名而不是 。

解析器仍然會做懶解析。可以做一個微小的改動來解決這一問題:用括弧封裝函數。

var foo = (function foo(x) {    return x * 10;});

現在,解析器看見 function 關鍵字前的左括弧便會立即進行解析。

因需要知道解析器在何種情況下懶解析或者立即解析代碼,所以可操作性會很差。同樣地,開發人員需要花時間考慮指定的函數是否需要立即解析。肯定沒人想費力地這麼做。最後,這肯定會讓代碼難以閱讀和理解。可以使用 Optimize.js 來處理此類情況。該工具只是用來最佳化 JavaScript 原始碼的初始載入時間。他們對代碼運行靜態分析,然後通過使用括弧封裝需要立即啟動並執行函數以便瀏覽器立即解析並準備運行它們。

那麼,可以如平常雜編碼然後一小段代碼如下:

(function() {    console.log(‘Hello, World!‘);})();

一切看起來很美好,因為在函式宣告前添加了左括弧。當然,在進入生產環境之前需要進行代碼壓縮。以下為壓縮公用程式的輸出:

!function(){console.log(‘Hello, World!‘)}();

看起來一切正常。代碼如期運行。然而好像少了什麼。壓縮公用程式移除了封裝函數的括弧代之以一個驚嘆號。這意味著解析器會跳過該代碼且將會運行懶解析。總之,為了運行該函數解析器會在懶解析之後進行立即解析。這會導致代碼運行變慢。幸運的是,可以利用 Optimize.js 來解決此類問題。傳給 Optimize.js 壓縮過的代碼會輸出如下代碼:

!(function(){console.log(‘Hello, World!‘)})();

現在,充分利用了各自的優勢:壓縮代碼且解析器正確地識別懶解析和立即解析的函數。

先行編譯

但是為何不在服務端進行這些工作呢?總之,比強制各個用戶端重複做該項事情更好的做法是只運行一次並在用戶端輸出結果。那麼,有一個進行中的討論即引擎是否需要提供一個運行先行編譯代碼的功能以節省瀏覽器的已耗用時間。本質上,該思路即使用服務端工具來產生位元組碼,這樣就只需要傳輸位元組碼並在用戶端運行。之後,將會看到啟動時間上的一些主要差異。這聽起來很有誘惑性但實現起來會很難。可能會有反效果,因為它將會很龐大且由於安全原因很有可能需要進行簽名和處理。例如,V8 團隊已經在內部解決重複解析問題,這樣先行編譯有可能實際上沒啥鳥用。

一些提升網路應用速度的建議
  • 檢查依賴。減少不必要的依賴。
  • 分割代碼為更小的塊而不是一整塊。如 webpack 的 code-spliting 功能。
  • 儘可能消極式載入 JavaScript 代碼。可以只載入當前路由所要求的程式碼片段。比如只在點擊某個元素的時候引入 某段代碼模組。
  • 使用開發人員工具和 DeviceTiming 來檢測效能瓶頸。
  • 使用像 Optimize.js 的工具來協助解析器選擇立即解析或者懶解析以加快解析速度。
拓展

有時候,特別是手機端瀏覽器,比如當你點擊前進/後退按鈕的時候,瀏覽器會進行緩衝。但是在有些情境下,你可能不需要瀏覽器的這種功能。有如下解決辦法:

window.addEventListener(‘pageshow‘, (event) => {  // 檢查前進/後退緩衝,是否從緩衝載入頁面  if (event.persisted || window.performance &&     window.performance.navigation.type === 2) {    // 進行相應的邏輯處理  }
};

How Javascript works (Javascript工作原理) (十四) 解析,文法抽象樹及最小化解析時間的 5 條小技巧

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.