JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

Fundebug發表於2019-01-22

摘要: JS的"編譯原理"。

Fundebug經授權轉載,版權歸原作者所有。

這是專門探索 JavaScript 及其所構建的元件的系列文章的第 14 篇。

如果你錯過了前面的章節,可以在這裡找到它們:

概述

我們都知道執行一大段 JavaScript 程式碼效能會變得很糟糕。這段程式碼不僅需要通過網路傳輸,而且還需要解析、編譯成位元組碼,最後執行。在之前的文章中,我們討論了 JS 引擎、執行時和呼叫堆疊等,以及主要由谷歌 Chrome 和 NodeJS 使用的V8引擎。它們在整個 JavaScript 執行過程中都發揮著至關重要的作用。這篇說的抽象語法樹同樣重要:在這我們將瞭解大多數 JavaScript 引擎如何將文字解析為對機器有意義的內容,轉換之後發生的事情以及做為 Web 開發者如何利用這一知識。

程式語言原理

那麼,首先讓我們回顧一下程式語言原理。不管你使用什麼程式語言,你需要一些軟體來處理原始碼以便讓計算機能夠理解。該軟體可以是直譯器,也可以是編譯器。無論你使用的是解釋型語言(JavaScript、Python、Ruby)還是編譯型語言(c#、Java、Rust),都有一個共同的部分:將原始碼作為純文字解析為 抽象語法樹(abstract syntax tree, AST) 的資料結構。

AST 不僅以結構化的方式顯示原始碼,而且在語義分析中扮演著重要角色。在語義分析中,編譯器驗證程式和語言元素的語法使用是否正確。之後,使用 AST 來生成實際的位元組碼或者機器碼。

抽象語法樹(abstract syntax tree 或者縮寫為 AST),或者語法樹(syntax tree),是原始碼的抽象語法結構的樹狀表現形式,這裡特指程式語言的原始碼。和抽象語法樹相對的是具體語法樹(concrete syntaxtree),通常稱作分析樹(parse tree)。一般的,在原始碼的翻譯和編譯過程中,語法分析器建立出分析樹。一旦 AST 被建立出來,在後續的處理過程中,比如語義分析階段,會新增一些資訊。

AST 程式

AST 不僅僅是用於語言直譯器和編譯器,在計算機世界中,它們還有多種應用。使用它們最常見的方法之一是進行靜態程式碼分析。靜態分析器不執行輸入的程式碼,但是,他們仍然需要理解程式碼的結構。

例如,你可能想要實現一個工具,該工具可以找到公共程式碼結構,以便你可以重構它們以減少重複。你可能會通過使用字串比較來實現這一點,但這個會相當簡單且有侷限性。

當然,如果你對實現這樣的工具感興趣,你不需要編寫自己的解析器。有許多與 Ecmascript規範完全相容的開源專案。EsprimaAcorn 即是黃金搭檔,還有許多工具可以幫助解析器生成輸出,即 ASTs ,ASTs 被廣泛應用於程式碼轉換。

例如,你可能希望實現一個將 Python 程式碼轉換為J avaScript 的轉換器。基本思想是使用Python 轉換器生成 AST,然後使用 AST 生成JavaScript程式碼。

你可能會覺得難以置信,事實是 ASTs 只是部分語言的不同表示法。在解析之前,它被表示為遵循一些規則的文字,這些規則構成了一種語言。在解析之後,它被表示為一個樹結構,其中包含與輸入文字完全相同的資訊。因此,也可以進行反向解析然後回到文字。

程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具Fundebug

JavaScript 解析

讓我們看看 AST 是如何構建的。我們用一個簡單的 JavaScript 函式作為例子:

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

    return x + 10;
}
複製程式碼

解析器會產生如下的 AST:

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

注意,為了觀看方便,這裡是解析器將生成的結果的簡化版本。實際的 AST 要複雜得多。然而,這裡的目的是為了執行原始碼之前的第一個步驟前。如果人想檢視實際的 AST 是什麼樣子,可以訪問 AST Explorer。它是一個線上工具,你以在其中輸入一些 JavaScript 並輸出對應的 AST。

你可能會問,為什麼需要知道 JavaScript解析器工作原理,畢竟這是瀏覽器工作,你想法是部分正確。下圖展示了 JavaScript 執行過程中不同階段的耗時。仔細瞅瞅,你或許會發現一些有趣的東西。

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

發現沒? 通常情況下,瀏覽器解析 JavaScript 大約需佔總執行時間的 15%20%。我沒有具體統計過這些數值。這些是來自真實應用程式和以某種方式使用 JavaScript 的網站的統計資料。也許 15% 看起來不是很多,但相信我,這是很多。

一個典型的單頁程式載入 0.4 mb 左右的 JavaScript,瀏覽器需要大約 370ms 來解析它。也許你會又說,這也不是很多嘛,本身花費的時間並不多。但請記住,這只是將 JavaScript 程式碼解析為 AST 所需要的時間。這並不包括執行本身的時間,也不包括在頁面載入 ,如 CSS 和 HTML 渲染過程的耗時。這些還只涉及桌面,移動瀏覽器的情況會更加複雜,在手機上花在解析上的時間通常是桌面瀏覽器的 2 到 5 倍。

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

上圖顯示了 1MB JavaScript 包在不同類的移動和桌面瀏覽器解析時間。

更重要的是,為了獲得更多類原生的使用者體驗而把越來越多的業務邏輯堆積在前端,Web 應用程式正變得越來越複雜。你可以輕易地想到網路應用受到的效能影響。只需開啟瀏覽器開發工具,然後使用該工具來解析、編譯和瀏覽器中發生的所有其他事情上所消耗的時間。

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

不幸的是,移動瀏覽器上沒有開發者工具。不過不用擔心,這並不意味著你對此無能為力。因為有 DeviceTiming 工具,它可以用來幫助檢測受控環境中指令碼的解析和執行時間。它通過插入程式碼來封裝原生程式碼,這樣每次從不同的裝置訪問頁面時,就可以在本地測量解析和執行時間。

好事就是 JavaScript 引擎做了很多工作來避免冗餘的工作,並得到了更好的優化,以下為主流瀏覽器使用的技術。

例如,V8 實現指令碼流(script streaming)和程式碼快取技術。指令碼流即指令碼一旦開始下載,asyncdeferred的 指令碼就會在單獨的執行緒上解析。這意味著在下載指令碼完成後幾乎立即完成解析,這會提升 10% 的頁面載入速度。

每次訪問頁面時,JavaScript 程式碼通常編譯為位元組碼。 然而,一旦使用者訪問另一頁面,該位元組碼就被丟棄。 發生這種情況是因為編譯後的程式碼很大程度上依賴於編譯時機器的狀態和上下文。 這是 Chrome 42 引入位元組碼快取的原因。 該技術會本地快取編譯過的程式碼,這樣當使用者返回同一頁面時,諸如下載,解析和編譯等所有步驟都會被跳過。 這使得 Chrome 可以節省大約 40% 的解析和編譯時間。 此外,這還可以節省移動裝置的電量。

在 Opera 中,Carakan 引擎可以重用另一個程式最近編譯過的輸出。沒有要求程式碼必須來自相同的頁面甚至同個域下。這種快取技術實際上非常高效,還可以完全跳過編譯步驟。它依賴於典型的使用者行為和瀏覽場景:每當使用者在應用程式/網站中遵循某個使用者的特定瀏覽習慣,都會載入相同的 JavaScript 程式碼。不過,Carakan 引擎早已被谷歌的 V8 所取代。

Opera 新的 JavaScript 引擎 “Carakan”,目前速度是其他已存在 JavaScript 引擎(基於 SunSpider)的2.5倍。其在轉化為本地機器程式碼時專門針對正規表示式做了優化。

Firefox 使用的 SpiderMonkey 引擎不會快取所有內容。它可以過渡到監視階段,在這個階段中,它計算執行給定指令碼的次數。基於此計算,它推匯出頻繁使用而可以被優化的程式碼部分。

SpiderMonkey 是 Mozilla 專案的一部分,是一個用 C 語言實現的 JavaScript 指令碼引擎,另外還有一個叫做Rhino 的 Java 版本。

顯然,有些人決定什麼都不做。Safari 的首席開發人員 Maciej Stachowiak 表示,Safari 不會對編譯後的位元組碼進行任何快取。快取技術他們是有考慮過的問題,但是他們還沒有實現,因為生成程式碼的耗時小於總執行時間的 2%。

這些優化不會直接影響 JavaScript 原始碼的解析,但是會盡可能完全避免。畢竟做總比沒做好點?

我們可以做很多事情來改善應用程式的初始載入時間。最小化載入的 JavaScript 數量:程式碼越小、解析所需要時間就越少,執行時間也就越小。要做到這一點,我們只能在當前的路由上載入所需的程式碼,而不是載入一大陀的程式碼。例如,PRPL模式即表示該種程式碼傳輸型別。或者,可以檢查程式碼的依賴關係,看看是否有什麼冗餘的依賴導致程式碼庫膨脹,然而,這些東西需要很大的篇幅來進行討論。

本文的主要的目的討論作為 Web 開發人員可以做些什麼來幫助 JavaScript 解析器更快地完成它的工作。還有,現代JavaScript 解析器使用 啟發法(heuristics) 來決定是否立即執行指定的程式碼片段或者推遲在未來的某個時候執行。基於這些啟發法,解析器將進行即時或懶解析。

啟發法是針對模型求解方法而言的,是一種逐次逼近最優解的方法。這種方法對所求得的解進行反覆判斷實踐修正直至滿意為止。啟發法的特點是模型簡單,需要進行方案組合的個數少,因此便於找出最終答案。此方法雖不能保證得到最優解,但只要處理得當,可獲得決策者滿意的近似最優解。一般步驟包括:定義一個計算總費用的方法;報定判別準則;規定方案改選的途徑;建立相應的模型;送代求解。

立即解析會執行需要立即編譯的函式。它主要做三件事:構建 AST,構建作用域層級和查詢所有語法錯誤。另一方面, 懶解析只執行未編譯的函式。它不構建AST,也不查詢所有語法錯誤,它只構建作用域層級,與立即解析相比節省了大約一半的時間。

顯然,這不是一個新概念。即使像 IE 9 這樣的瀏覽器也支援這種型別的優化,儘管與現在的解析器的工作方式相比,這種優化方式還很初級。

來看一個例子,假設有以下程式碼片段:

function foo() {
    function bar(x) {
        return x + 10;
    }

    function baz(x, y) {
        return x + y;
    }

    console.log(baz(100, 200));
}

foo()
複製程式碼

就像前面的例子一樣,程式碼被輸入到語法分析器中,語法分析器進行語法分析並輸出AST,如下:

  • 宣告函式 foo
  • 呼叫函式 foo
  • foo 裡宣告函式 bar 接收引數 x, 並返回 x 和 10 相加的結果
  • foo 裡宣告函式 baz 接收引數 xy, 並返回 xy 相加的結果
  • 呼叫 baz 函式傳入 100 和 2。
  • 呼叫 console.log 引數為之前函式呼叫的返回值。

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

那麼期間發生了什麼? 解析器看到 bar 函式的宣告、baz 函式的宣告、bar函式的呼叫和 console.log 的呼叫。但是,解析器做了一些完全無關的額外工作即解析 bar 函式。為什麼這無關緊要? 因為函式 bar 從來沒有被呼叫過(或者至少在那個時候沒有)。這是一個簡單的示例,看起來可能有些不同尋常,但在許多實際應用程式中,許多宣告的函式從未被呼叫。

這裡不解析bar函式,該函式宣告瞭卻沒有呼叫它。只在需要的時候在函式執行前進行真正的解析。懶解析仍然需要找到函式的整個主體併為其宣告,但僅此而已。它不需要語法樹,因為它還沒有被處理。另外,它不會從堆中分配記憶體,而堆通常會佔用相當多的系統資源,簡而言之,跳過這些步驟會帶來很大的效能改進。

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

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

注意,這裡只確認 bar 函式宣告,沒有進入 bar 函式體。在這種情況下,函式體只是一個返回語句。但是,與大多數實際應用程式一樣,它可以更大,包含多個返回語句、條件語句、迴圈、變數宣告,甚至巢狀函式宣告。這完全是在浪費時間和系統資源,因為這個函式永遠不會被呼叫。

這是一個相當簡單的概念,但實際上,它的實現是非常難的,不侷限於以上示例。整個方法還可以適用於函式、迴圈、條件、物件等。基本上,所有需要解析的東西。

例如,下面是一個非常常見的 JavaScript 模式。

var myModule = (function() {
     // 整個模組的邏輯
     // 返回模組物件
})();
複製程式碼

大多數現代 JavaScript 解析器都能識別這種模式,此模式表示程式碼需要立即解析。

那麼為什麼解析器不都使用懶解析呢? 如果懶解析某些程式碼,這些程式碼需要立即執行,這實際上會使程式碼執行速度變慢。需要執行一次懶解析之後進行另一個立即解析,這和立即解析相比,執行速度會慢 50%。

現在對解析器底層原理有了大致的瞭解,是時候考慮如何提高解析器的解析速度。可以用這種方式編寫程式碼,以便在正確的時間解析函式。大多數解析器都能識別一種模式:使用括號封裝函式。對於解析器來說,這幾乎總是一個積極的訊號,即函式需要立即執行。如果解析器看到一個左括號,緊接著是一個函式宣告,它將立即解析這個函式。可以通過顯式地宣告立即執行的函式來幫助解析器加快解析速度。

假設有一個名為 foo 的函式。

function foo(x) {
    return x * 10;
}
複製程式碼

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

首先,將函式儲存在一個變數中:

var foo = function foo(x) {
    return x * 10;
};
複製程式碼

注意,這裡有使用函式的名稱 foo,這不是必需的,但是建議這樣做,因為在丟擲異常的情況下,stacktrace 會保留實際函式名稱,而不僅僅是 <anonymous>

以上事例解析器執行懶解析,可以用括號封裝起來,讓解析器進行立即解析:

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 團隊正在努力解決重複解析問題,這樣預編譯有可能實際並沒有多大的用處。

提升編譯速度一些建議

  • 檢查依賴,減少不必要的依賴
  • 分割程式碼為更小的塊而不是一整陀的
  • 儘可能推遲載入 JavaScript,按需要載入或者動態載入。
  • 使用開發者工具和 DeviceTiming 來檢測效能瓶頸
  • 用像 Optimize.js 的工具來幫助解析器選擇立即解析或者懶解析以加快解析速度

原文:How JavaScript works: Parsing, Abstract Syntax Trees (ASTs) + 5 tips on how to minimize parse time

關於Fundebug

Fundebug專注於JavaScript、微信小程式、微信小遊戲、支付寶小程式、React Native、Node.js和Java線上應用實時BUG監控。 自從2016年雙十一正式上線,Fundebug累計處理了9億+錯誤事件,付費客戶有Google、360、金山軟體、百姓網等眾多品牌企業。歡迎大家免費試用

JavaScript的工作原理:解析、抽象語法樹(AST)+ 提升編譯速度5個技巧

相關文章