1. 引言
本周精讀的文章是 V8 引擎 Lazy Parsing,看看 V8 引擎為了優化性能,做了怎樣的嘗試吧!
這篇文章介紹的優化技術叫 preparser,是通過跳過不必要函數編譯的方式優化性能。
2. 概述 & 精讀
解析 Js 發生在網頁運行的關鍵路徑上,因此加速對 JS 的解析,就可以加速網頁運行效率。
然而并不是所有 Js 都需要在初始化時就被執行,因此也不需要在初始化時就解析所有的 Js!因為編譯 Js 會帶來三個成本問題:
- 編譯不必要的代碼會占用 CPU 資源。
- 在 GC 前會占用不必要的內存空間。
- 編譯后的代碼會緩存在磁盤,占用磁盤空間。
因此所有主流瀏覽器都實現了 Lazy Parsing(延遲解析),它會將不必要的函數進行預解析,也就是只解析出外部函數需要的內容,而全量解析在調用這個函數時才發生。
預解析的挑戰
本來預解析也不難,因為只要判斷一個函數是否會立即執行就可以了,只有立即執行的函數才需要被完全解析。
使得預解析變復雜的是變量分配問題。原文通過了堆棧調用的例子說明原因:
Js 代碼的執行在堆棧上完成,比如下面這個函數:
function f(a, b) {const c = a + b;return c;
}function g() {return f(1, 2);// The return instruction pointer of `f` now points here// (because when `f` `return`s, it returns here).
}
這段函數的調用堆棧如下:
首先是全局 This globalThis
,然后執行到函數 f
,再對 a
b
進行賦值。在執行 f
函數時,通過 <rip g>
(return instruction pointer) 保存 g 堆棧狀態,再保存堆棧跳出后返回位置的指針 <save fp>
(frame pointer),最后對變量 c
賦值。
這看上去沒有問題,只要將值存在堆棧就搞定了。但是將變量定義到函數內部就不一樣了:
function make_f(d) {// ← declaration of `d`return function inner(a, b) {const c = a + b + d; // ← reference to `d`return c;};
}const f = make_f(10);function g() {return f(1, 2);
}
將變量 d
申明在函數 make_f
中,且在返回函數 inner
中用到了 d
。那么函數的調用棧就變成了這樣:
需要創建一個 context
存儲函數 f
中變量 d
的值。
也就是說,如果一個在函數內部定義的變量被子 Scope 使用時,Js 引擎需要識別這種情況,并將這個變量值存儲在 context
中。
所以對于函數定義的每一個入參,我們需要知道其是否會被子函數引用。也就是說,在 preparser
階段,我們只要少能分析出哪些變量被內部函數引用了。
難以分辨的引用
預處理器中跟蹤變量的申明與引用很復雜,因為 Js 的語法導致了無法從部分表達式推斷含義,比如下面的函數:
function f(d) {function g() {const a = ({ d }
我們不清楚第三行的 d
到底是不是指代第一行的 d
。它可能是:
function f(d) {function g() {const a = ({ d } = { d: 42 });return a;}return g;
}
也可能只是一個自定義函數參數,與上面的 d
無關:
function f(d) {function g() {const a = ({ d }) => d;return a;}return [d, g];
}
惰性 parse
在執行函數時,只會將最外層執行的函數完全編譯并生成 AST,而對內部模塊只進行 preparser
。
// This is the top-level scope.
function outer() {// preparsedfunction inner() {// preparsed}
}outer(); // Fully parses and compiles `outer`, but not `inner`.
為了允許惰性編譯函數,上下文指針指向了 ScopeInfo 的對象(從代碼中可以看到,ScopeInfo 包含上下文信息,比如當前上下文是否有函數名,是否在一個函數內等等),當編譯內部函數時,可以利用 ScopeInfo 繼續編譯子函數。
但是為了判斷惰性編譯函數自身是否需要一個上下文,我們需要再次解析內部的函數:比如我們需要知道某個子函數是否對外層函數定義的變量有所引用。
這樣就會產生遞歸遍歷:
由于代碼總會包含一些嵌套,而編譯工具更會產生 IIFE(立即調用函數) 這種多層嵌套的表達式,使得遞歸性能比較差。
而下面有一種辦法可以將時間復雜度簡化為線性:將變量分配的位置序列化為一個密集的數組,當惰性解析函數時,變量會按照原先的順序重新創建,這樣就不需要因為子函數可能引用外層定義變量的原因,對所有子函數進行遞歸惰性解析了。
按照這種方式優化后的時間復雜度是線性的:
針對模塊化打包的優化
由于現代代碼幾乎都是模塊化編寫的,構建起在打包時會將模塊化代碼封裝在 IIFE(立即調用的閉包)中,以保證模擬模塊化環境運行。比如 (function(){....})()
。
這些代碼看似在函數中應該惰性編譯,但其實這些模塊化代碼從一開始就要被編譯,否則反而會影響性能,因此 V8 有兩種機制識別這些可能被立即調用的函數:
- 如果函數是帶括號的,比如
(function(){...})
,就假設它會被立即調用。 - 從 V8 v5.7 / Chrome 57 開始,還會識別 uglifyJS 的
!function(){...}(), function(){...}(), function(){...}()
這種模式。
然而在瀏覽器引擎解析環境比較復雜,很難對函數進行完整字符串匹配,因此只能對函數頭進行簡單判斷。所以對于下面這種匿名函數的行為,瀏覽器是不識別的:
// pre-parser
function run(func) {func()
}run(function(){}) // 在這執行它,進行 full parser
上面的代碼看上去沒毛病,但由于瀏覽器只檢測被括號括住的函數,因此這個函數不被認為是立即執行函數,因此在后續執行時會被重復 full-parse。
也有一些代碼輔助轉換工具幫助 V8 正確識別,比如 optimize-js,會將代碼做如下轉換。
轉換前:
!function (){}()
function runIt(fun){ fun() }
runIt(function (){})
轉換后:
!(function (){})()
function runIt(fun){ fun() }
runIt((function (){}))
然而在 V8 v7.5+ 已經很大程度解決了這個問題,因此現在其實不需要使用 optimize-js 這種庫了~
4. 總結
JS 解析引擎在性能優化做了不少工作,但同時也要應對代碼編譯器產生的特殊 IIFE 閉包,防止對這種立即執行閉包進行重復 parser。
最后,不要試圖總是將函數用括號括起來,因為這樣會導致惰性編譯的特性無法啟用。
討論地址是:精讀《V8 引擎 Lazy Parsing》 · Issue #148 · dt-fe/weekly
如果你想參與討論,請 點擊這里,每周都有新的主題,周末或周一發布。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
special Sponsors
- DevOps 全流程平臺
版權聲明:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)