JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

tristan發表於2019-03-04

原文請查閱這裡,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland

本系列持續更新中,Github 地址請查閱這裡

這是 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。

JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

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

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

JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

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

JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

以上圖表展示了不同移動和桌面瀏覽器解析 1MB JavaScript 程式碼所消耗的時間。

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

JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

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

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

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

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

Opera 中,Carakan 引擎可以複用另一個程式最近編譯過的輸出。不要求程式碼在同一頁面或是相同域名下。該快取技術非常高效且可以完全跳過編譯步驟。它依賴於典型的使用者行為和瀏覽場景:每當使用者在程式/網站上遵循特定的使用者瀏覽習慣,則會載入相同的 JavaScript 程式碼。然而,Carakan 早就被谷歌 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 引數為之前函式呼叫的返回值。

JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

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

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

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

JavaScript 工作原理之十四-解析,語法抽象樹及最小化解析時間的 5 條小技巧

注意到這裡僅僅只是確認函式 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) {
    // 進行相應的邏輯處理
  }
};
複製程式碼

招賢納士

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章