極速解析,第2部分:延遲解析
這是我們系列文章的第二部分,解釋了V8如何以最快速度解析JavaScript。第一部分解釋了我們如何讓V8的掃描器更快。
解析是把源代碼轉換為可供編譯器使用的中間表示的步驟(在V8中,是字節碼編譯器Ignition)。解析和編譯發生在網頁啟動的關鍵路徑上,而並不是所有的函數都在啟動期間立即需要。即使開發者可以用異步和延遲腳本來推遲這些代碼的執行,但這並非總是可行。此外,許多網頁會傳輸一些僅被某些功能使用的代碼,而這些功能可能在單個頁面加載過程中完全未被用戶訪問。
無必要地急切編譯代碼會產生實在的資源成本:
- 使用CPU周期創建代碼,延遲實際啟動需要的代碼的可用性。
- 代碼對象會佔用記憶體,至少到字節碼清除判定這些代碼暫時不需要並允許它們被垃圾回收為止。
- 在頂層腳本完成執行之前編譯的代碼會被緩存到磁盤上,佔用了磁盤空間。
基於這些原因,所有主要瀏覽器都實現了_延遲解析_。解析器可以選擇“預解析”它遇到的函數來取代全面解析它們,而不是為每個函數生成抽象語法樹(AST)並將其編譯為字節碼。它通過切換到預解析器來實現這一點,預解析器是解析器的一個副本,僅執行能略過該函數所需的最低操作。預解析器驗證它略過的函數語法有效性,並生成外部函數正確編譯所需的所有信息。當預解析的函數後來被調用時,將會按需全面解析和編譯。
變數分配
使預解析變得複雜的主要因素是變數分配。
出於性能原因,函數的激活工作由機器堆棧管理。例如,如果函數g
調用函數f
並傳遞參數1
和2
:
function f(a, b) {
const c = a + b;
return c;
}
function g() {
return f(1, 2);
// `f`的返回指令指針現在指向這裡
// (因為當`f`返回時,它返回到這裡)。
}
首先,接收器(即f
的this
值,因為它是個非嚴格函數調用,此值為globalThis
)被推入堆棧,接著是被調用的函數f
。然後參數1
和2
被推入堆棧。此時函數f
被調用。為執行此調用,我們首先將函數g
的狀態保存在堆棧上:函數f
的“返回指令指針”(rip
; 我們需返回的代碼)以及“幀指針”(fp
; 函數返回時堆棧的預期樣子)。接著我們進入f
,它為局部變量c
分配空間,並可能分配任何需要的臨時空間。這確保了任何由函數使用的數據在函數退出作用域後都被清除:它們從堆棧被彈出即可。
這種設置的問題在於函數可以引用外部函數中聲明的變量。內部函數可以在創建它們的激活上下文之外生存:
function make_f(d) { // ← `d`的聲明
return function inner(a, b) {
const c = a + b + d; // ← 對`d`的引用
return c;
};
}
const f = make_f(10);
function g() {
return f(1, 2);
}
在上述示例中,從inner
對於在make_f
中聲明的局部變量d
的引用是在make_f
返回之後才生效的。為實現這一點,支持詞法閉包的語言的虛擬機將內部函數中被引用的變量分配到堆上的一個結構中,稱為“上下文”。
這意味著對於在函式中宣告的每個變數,我們需要知道內部函式是否引用了該變數,以便決定是將該變數分配到堆疊上還是分配到堆分配的上下文中。當我們評估函式文字時,我們分配了一個閉包,該閉包即指向函式的程式碼,也指向當前上下文:包含變數值對象的那些值可能需要被訪問。
簡而言之,我們確實需要在預解析器中至少跟蹤變數引用。
不過,如果僅僅跟蹤引用,我們將會高估哪些變數被引用。一個在外部函式中宣告的變數可能會被內部函式中的重新宣告遮蔽,使得內部函式的引用目標是內部宣告,而非外部宣告。如果我們無條件地分配外部變數到上下文中,性能將會受到影響。因此,為了使變數分配在預解析過程中正常工作,我們需要確保預解析的函式正確保持對變數引用和宣告的跟蹤。
頂層代碼是此規則的一個例外。腳本的頂層總是分配到堆中,因為變數是跨腳本可見的。一個接近完美架構的簡單方法是僅運行預解析器而不進行變數跟蹤以快速解析頂層函式;對內部函式使用完整解析器,但略過編譯它們。這比預解析更昂貴,因為我們不必要地構建了一個完整的抽象語法樹(AST),但它使我們可以快速啟動。這正是V8在V8 v6.3 / Chrome 63之前所做的。
教授預解析器有關變數的知識
在預解析器中跟蹤變數宣告和引用是複雜的,因為在JavaScript中,從一開始並不總是清楚部分表達式的含義。例如,假設我們有一個函式 f
,其參數為 d
,並且內部有一個函式 g
,其中一個表達式看起來可能引用了 d
。
function f(d) {
function g() {
const a = ({ d }
它可能確實最終引用了 d
,因為我們看到的標記是解構賦值表達式的一部分。
function f(d) {
function g() {
const a = ({ d } = { d: 42 });
return a;
}
return g;
}
它也可能最終變成一個帶有解構參數 d
的箭頭函式,在這種情況下,f
中的 d
並未被 g
引用。
function f(d) {
function g() {
const a = ({ d }) => d;
return a;
}
return [d, g];
}
最初,我們的預解析器被實現為解析器的獨立副本,且沒有太多共享,導致兩個解析器隨時間分化。通過重寫解析器和預解析器,使其基於實現好奇的遞迴模板模式的ParserBase
,我們設法最大化了共享,同時保持了分離副本的性能優勢。這極大地簡化了向預解析器添加完整變數跟蹤的過程,因為實現的大部分可以在解析器和預解析器之間共享。
事實上,即使對於頂層函式,忽略變數宣告和引用也是不正確的。ECMAScript規範要求在首次解析腳本時檢測各種類型的變數衝突。例如,如果同一作用域中同一變數被兩次作為詞法變數宣告,則被認為是一個早期 SyntaxError
。由於我們的預解析器簡單地略過了變數宣告,故在預解析期間會錯誤地允許代碼的執行。當時我們認為性能提升值得這種規範違背。然而,現在預解析器正確地跟蹤變數,我們在不顯著減少性能的情況下消除了與變數解析相關的整個類別的規範違背。
跳過內部函式
正如之前提到的,當預解析的函式首次被調用時,我們會完全解析它並將生成的AST編譯為字節碼。
// 這是頂層作用域。
function outer() {
// 預解析的
function inner() {
// 預解析的
}
}
outer(); // 完全解析並編譯 `outer`,但不解析 `inner`。
該函式直接指向外部上下文,該上下文包含需向內部函式提供的變數宣告值。為了允許函式的懶編譯(以及支持調試器),上下文指向一個名為ScopeInfo
的元數據對象。ScopeInfo
對象描述了上下文中列出的變數。這意味著在編譯內部函式時,我們可以計算變數在上下文鏈中的位置。
為了計算懶編譯函數本身是否需要上下文,我們需要再次執行作用域解析:我們需要知道懶編譯函數中的嵌套函數是否引用了該懶函數聲明的變數。我們可以通過重新預解析這些函數來弄清楚。V8 在 V8 v6.3 / Chrome 63 之前的版本就是這麼做的。然而,這在性能上並不理想,因為這使得源代碼大小與解析成本之間的關係變得非線性:我們會多次對嵌套函數進行預解析。除了動態程序的自然嵌套外,JavaScript 打包器通常會將代碼包裹在“立即調用的函數表達式”(IIFEs)中,這使得大多數 JavaScript 程序擁有多層嵌套。
為了避免非線性的性能開銷,我們甚至在預解析期間就執行完整的作用域解析。我們存儲足夠的元數據,以便稍後可以簡單地 跳過 內部函數,而不是必須重新預解析它們。一種方式是存儲內部函數引用的變量名稱。這樣存儲成本較高,並且仍然需要我們重複工作:我們已經在預解析期間執行了變量解析。
相反,我們將變量的位置作為每個變量的稠密標誌數組進行序列化。當我們懶解析一個函數時,變量將按照預解析器看到的順序重新創建,我們可以簡單地將元數據應用到這些變量。一旦函數被編譯,變量分配的元數據就不再需要,可以被垃圾回收。由於我們僅對實際包含內部函數的函數需要此元數據,因此絕大部分函數甚至不需要這些元數據,從而顯著減少了內存開銷。
跳過內部函數的性能影響與重新預解析內部函數的開銷一樣,都是非線性的。有些網站將所有函數提升到頂層範圍。由於它們的嵌套層次始終為 0,因此開銷始終為 0。然而,許多現代網站實際上深度嵌套了函數。在這些網站上,我們在 V8 v6.3 / Chrome 63 推出該功能後看到了顯著的改進。主要優勢在於,現在無論代碼嵌套得多深,任何函數最多只需要預解析一次,然後完整解析一次1。
可能調用的函數表達式
如前所述,打包器通常將多個模塊結合到單一文件中,通過將模塊代碼包裝在一個立即調用的閉包中來實現。這為模塊提供了隔離性,使它們能夠像是腳本中唯一的代碼一樣運行。這些函數本質上是嵌套的腳本;腳本執行時這些函數會被立即調用。打包器通常將 立即調用的函數表達式(IIFEs;發音為“iffies”)作為帶括號的函數發送:(function(){…})()
。
由於這些函數在腳本執行過程中會立即需要,因此預解析此類函數並不理想。在腳本的頂層執行期間,我們立即需要編譯該函數,因此我們需要對其進行完整解析和編譯。這意味著我們之前為加快啟動速度而進行的快速解析,必然成為了一項不必要的額外成本。
你可能會問,為什麼不簡單地編譯被調用的函數呢?對於開發者而言,通常很容易注意到一個函數是否被調用,但對於解析器而言情況就不是這樣了。解析器需要在開始解析函數之前決定是要急切編譯函數還是延遲編譯。語法中的歧義使得簡單地快速掃描到函數結尾變得困難,並且其成本很快趨近於常規預解析的成本。
基於此原因,V8 針對兩個簡單的模式進行識別,將其視為 可能調用的函數表達式(PIFEs;發音為“piffies”),並對其進行急切的解析和編譯:
- 如果函數是一個括號包裹的函數表達式,即
(function(){…})
,我們假設它將被調用。我們在看到此模式開始時(即(function
)就作出此假設。 - 自 V8 v5.7 / Chrome 57 開始,我們還檢測到由 UglifyJS 生成的模式
!function(){…}(),function(){…}(),function(){…}()
。當我們看到!function
或與另一個 PIFE 緊連的,function
時,該檢測就會啟動。
由於 V8 對 PIFEs 進行急切編譯,因此它們可以作為 基於配置的反饋2,向瀏覽器告知哪些函數是啟動所需的。
當 V8 仍會重新解析內部函數時,一些開發者注意到 JS 解析對於啟動的影響非常大。套件 optimize-js
根據靜態啟發式將函數轉換為 PIFE(優先執行函數表達式)。在創建該套件時,這對 V8 的載入性能產生了巨大影響。我們通過在 V8 v6.1 上運行 optimize-js
提供的基準測試,並僅查看壓縮過的腳本,重現了這些結果。
然而,現在我們已不再重新解析內部函數,並且解析器已變得更快,通過 optimize-js
獲得的性能提升已大大減少。事實上,v7.5 的默認配置已經比在 v6.1 上運行的優化版本快得多。即使是在 v7.5 上,對於啟動時需要的代碼,少量使用 PIFE 仍然有意義:因為我們早期知道該函數會被需要,可以避免預解析。
optimize-js
的基準測試結果並不完全反映真實世界。腳本是同步加載的,並且整個解析 + 編譯時間計入加載時間。在實際情況中,您可能會使用 <script>
標籤加載腳本。這使得 Chrome 的預加載器能在腳本被評估之前發現該腳本,並在不阻塞主線程的情況下下載、解析和編譯該腳本。我們決定要優先編譯的所有內容都會自動在非主線程上編譯,並應該只對啟動有最小的影響。使用非主線程腳本編譯放大了使用 PIFE 的影響。
不過,它仍然有成本,尤其是內存成本,因此不建議對所有內容都進行優先編譯:
雖然在啟動時為需要的函數添加括號是個好主意(例如基於啟動性能分析),但使用像 optimize-js
這樣僅應用簡單靜態啟發式的套件並不是一個好主意。例如,該套件假設如果函數作為函數調用的參數,就會在啟動期間被調用。然而,如果這樣的函數實現了一個只在後期才需要的整個模組,那麼最終會編譯過多的內容。過於積極的編譯對性能不利:V8 在沒有延遲編譯的情況下顯著降低了加載時間。此外,optimize-js
的一些好處源於 UglifyJS 和其它壓縮工具的問題,這些工具從不是 IIFE 的 PIFE 裡移除了括號,去除了可能應用於例如 通用模組定義-風格模組的有用提示。這可能是壓縮工具需要修正的問題,以在優先編譯 PIFE 的瀏覽器上獲得最大性能。
結論
延遲解析提高了啟動性能並減少了那些提供超過實際需要的代碼的應用的內存開銷。能夠在預解析器中正確追蹤變量聲明和引用是能夠正確(符合規範)且快速地進行預解析的必要條件。在預解析器中分配變量還可以讓我們序列化變量分配信息以便解析器稍後使用,從而避免必須重新預解析內部函數,避免深層嵌套函數的非線性解析行為。
能被解析器識別的 PIFE 避免了啟動期間立即需要的代碼的初始預解析開銷。謹慎地基於剖面使用 PIFE,或由壓縮工具使用 PIFE,可以提供有益的冷啟動速度提升。然而,需要避免為了觸發此啟發而不必要地將函數包裹在括號中,因為這會導致更多代碼被優先編譯,從而產生更差的啟動性能和增大的內存使用。