本文作者:Vice
前言
對於我們前端開發來說,無時無刻不在接觸著閉包。比如在 React Hooks
中利用了閉包來捕獲元件的狀態,並在元件的生命週期中保持狀態的一致性。在 Vue
中利用閉包來定義計算屬性和監聽器,以及在元件之間共享資料。在 Angular
中利用閉包可以用於建立服務和依賴注入。
所以理解閉包產生的原因和原理對我們的日常開發非常重要。
熱個身
其實 JavaScript 本身的特性決定了一定要實現閉包:
- JavaScript 允許在函式內部定義新的函式。
- 因為
詞法作用域
,可以在內部函式中訪問父函式中定義的變數。 - 函式作為一等公民,函式可以作為返回值。
利用上面三點列舉一個貫穿全文的 JavaScript 經典閉包程式碼:
function multi() {
var a = 10;
return function inner() {
return a * 10;
}
}
const p = multi();
此段程式碼宣告瞭 multi 函式,在函式內部定義了變數 a,並且返回了 inner 函式,inner 函式中訪問 multi 函式中宣告的 a,最後執行了 multi 函式並且將返回值返回給 p。這個時候閉包就建立完成啦,閉包讓開發者可以從內部函式訪問外部函式的作用域,p 函式始終能訪問到 multi 函式中的 a。
但是大家都知道,multi 函式執行完之後,理應內部宣告的變數都會被銷燬,但是因為閉包的原因,這個 a 變數突破了這種限制。
為了實現閉包,我們來看看 V8 都是怎麼做的吧。
V8 是如何執行一段 JavaScript 程式碼的
我們都知道,我們寫的 JavaScript 程式碼,是需要經過編譯的步驟,讓 CPU 獲取到一串二進位制的指令去執行的。完成這一步的通常有兩種方法:
- 解釋執行,將原始碼透過解析器生成中間程式碼,然後用直譯器解釋執行,它的優勢在於快速啟動執行,但執行速度相對較慢。
- 編譯執行,也是先生成中間程式碼,然後透過編譯器將中間程式碼直接轉換成二進位制程式碼,執行的時候直接執行二進位制檔案即可,它的優勢在於執行時直接操作二進位制檔案,執行速度更快,並且編譯過程只進行了一次,所以在多次執行相同程式碼時,編譯執行的效能更高,但是相對的啟動速度就會比較慢。
V8 採取的策略是混合編譯執行和解釋執行,也就是我們經常聽到的 JIT,是一種對上述兩種策略的一種權衡。流程如下:
- 初始化執行環境,比如堆疊空間、事件迴圈系統等。
- 解析器解析程式碼生成 AST 和作用域。
- 根據 AST 和作用域生成中間程式碼,也就是位元組碼。
- 直譯器解釋執行中間程式碼輸出結果。
- 監控直譯器執行,發現頻繁執行的熱點程式碼會生成二進位制程式碼以提高執行速度。
- 熱點程式碼改變或者執行頻率下降,編譯器會執行反最佳化重新讓這段程式碼生成位元組碼。
V8 遇到函式是如何編譯的?
上面說到執行 JavaScript 程式碼需要經過編譯到中間程式碼的步驟,但是實際上 V8 並不會把所有程式碼全部進行解析,是因為如果一次性編譯所有 JavaScript 程式碼,編譯時間會很長,需要全部編譯完才能執行程式碼,對使用者來說會感到嚴重的延遲特別是大型專案。並且編譯產生的大量中間程式碼會非常佔用記憶體資源,特別是移動裝置,記憶體的消耗是需要謹慎考慮的。
所以包括 V8,所有主流瀏覽器都實現了延遲解析(lazy parsing)
。顧名思義,V8 會推遲對程式碼的解析,直到程式碼被實際執行時才進行解析。具體就是在解析器遇到函式宣告時,只會解析函式的宣告部分,而不會解析函式內部的程式碼。在執行函式的時候 V8 會對函式進行各種最佳化,例如內聯最佳化、型別推斷等。延遲解析也可以使 V8 有更多的執行上下文和執行時資訊,從而更好地進行最佳化,提高程式碼的執行效率。
我們來使用 D8 工具具體看個例子:
var top = 1;
function multi(a) {
return a * 10;
}
透過 d8 --print-ast
命令列印出 AST 資訊:
V8 首先會接收到我們書寫的原始碼,為了理解這段原始碼,它需要結構化這段字串來生成原始碼中的語法結構和關係,便於後續 V8 的理解。比如語言轉換器 Babel、語法檢查工具 ESLint 等,底層都使用了 AST 去實現。
--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (0x7fa6a5810050) (mode = VAR, assigned = true) "top"
. . FUNCTION "multi" = function multi
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 10
. . . INIT at 10
. . . . VAR PROXY unallocated (0x7fa6a5810050) (mode = VAR, assigned = true) "top"
. . . . LITERAL 1
簡單解釋下這段被解析器解析生成的 AST,著重看 DECLS
和 EXPRESSION STATEMENT
。
DECLS
代表一組宣告,此處宣告瞭一個名為 top 的變數,並且該變數被賦值(assigned = true)。還宣告瞭一個名為 multi 的函式。
EXPRESSION STATEMENT
表示一個表示式語句節點,這裡就是 var top = 1;
,下面的內容代表這段表示式的結構化表述,將變數 top 的 proxy(指向了實際 top 的值,可以看到 0x7fbc75010c50
地址相同)並且初始化為字面量 1。
所以自始至終解析器並沒有解析函式體內部的程式碼,僅僅只解析了函式的宣告部分。
我們也可以透過 d8 --print-scopes
列印此時 multi 函式的作用域:
Global scope:
global { // (0x7ff32601e030) (0, 53)
// will be compiled
// NormalFunction
// 1 stack slots
// temporary vars:
TEMPORARY .result; // (0x7ff32601e530) local[0]
// local vars:
VAR top; // (0x7ff32601e250)
VAR multi; // (0x7ff32601e4a0)
function multi () { // (0x7ff32601e2e0) (27, 53)
// lazily parsed
// NormalFunction
// 2 heap slots
}
}
我們可以看到它沒有為 multi 函式生成作用域,而是進行 lazily parsed
。
那我們執行一下這個 multi 函式,看看 AST 會是什麼樣子:
var top = 1;
function multi(a) {
return a * 10;
}
multi(3);
[generating bytecode for function: multi]
--- AST ---
FUNC at 27
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "multi"
. PARAMS
. . VAR (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. DECLS
. . VARIABLE (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. RETURN at 37
. . MUL at 46
. . . VAR PROXY parameter[0] (0x7fe75782f670) (mode = VAR, assigned = false) "a"
. . . LITERAL 10
執行 multi 函式時,從 multi 函式物件中取出函式程式碼,和頂層程式碼一樣編譯為 AST 和位元組碼,然後再解釋執行,這裡我們簡單看看生成的 AST 吧:
PARAMS
代表函式引數部分,表示函式有一個引數 a,且該引數未被賦值(在執行階段才會指向堆和棧中相應的資料)。DECLS
中宣告瞭 a 變數,地址與引數 a 相同。RETURN at
代表函式返回語句位於原始碼的位置。MUL at
代表返回值是一個乘法表示式。下面一行代表乘法表示式的第一個運算元是引數 a。LITERAL 10
代表乘法表示式的第二個運算元是字面量 10。
延遲解析 & 閉包
當延遲解析遇到了閉包,那麼情況就又複雜了,我們來稍微改造一下上面的 multi 函式。
function multi() {
var a = 10;
return function inner() {
return a * 10;
}
}
const p = multi();
這是一段閉包程式碼,我們簡單分析下上述程式碼的執行流程:
- 執行 multi 函式時,multi 函式會將它的內部函式 inner 返回給全域性變數 p。
- 然後 multi 函式執行結束,執行上下文被 V8 銷燬。
V8 用執行上下文來維護執行當前程式碼所需要的變數宣告、this 指向等,比如這裡的 a 變數。
- 雖然 multi 函式的執行上下文被銷燬了,但是被全域性 p 引用的 inner 函式引用了 multi 函式作用域中的變數 a。
為什麼 inner 函式中的 a 引用的是 multi 中的 a,這是因為 JavaScript 是基於詞法作用域,是靜態的作用域,和函式如何呼叫如何執行沒有關係,是程式碼編譯階段就決定好的,查詢順序都是照當前函式作用域向上冒泡,最後到全域性作用域。所以這裡的變數查詢規則為 inner 函式作用域 -> multi 函式作用域 -> 全域性作用域。
所以這裡就會帶來兩個問題?
- 當 multi 函式執行完成時,因為閉包的存在,此時 multi 的執行上下文被銷燬,但是 a 變數又被引用了,肯定不能被銷燬,那麼 V8 會採取什麼策略。
- 因為 V8 採用的延遲解析,在 inner 函式未執行的時候,是不會解析 inner 內部的程式碼的,所以 V8 並不知道是否引用了外部作用域中的變數。
預解析器(preparser)
V8 為了解決這兩個問題的,引入了 預解析器(preparser)
模組來解決,主要是做了兩件事:
- 當解析到頂層函式時,預解析器並不會直接跳過該函式,而是對該函式做一次快速的預解析,是為了判斷當前函式是不是存在一些語法上的錯誤。
在過去的版本中,預解析器在解析指令碼時會忽略變數宣告,例如在同一作用域中兩次宣告同名的變數應該被視為語法錯誤,但預解析器會允許這樣的程式碼透過預解析階段。當時是為了追求效能的提升,預解析器忽略了變數宣告的處理。現在修復後的預解析器能夠正確處理變數宣告和引用,符合ECMAScript規範,並且也沒有明顯的效能損失。
- 當執行函式時,只會將當前函式生成 AST 以及位元組碼,對內部宣告的其他函式進行預解析,是為了檢查函式內部是否引用了外部變數。如果函式內部引用了外部變數,預解析器會將這些變數從棧中複製(值型別複製值,引用型別複製地址)到堆中。這樣,在下次執行該函式時,函式可以直接使用堆中的引用,從而解決了閉包所帶來的問題。
我們來具體透過執行 multi 函式的位元組碼來理解下,透過 d8 --print-bytecode
來列印:
其實早期的 V8 為了提升程式碼的執行速度,是直接將 JavaScript 原始碼編譯成了沒有最佳化的二進位制的機器程式碼,但是隨著移動裝置的普及,V8 團隊逐漸發現將 JavaScript 原始碼直接編譯成二進位制程式碼存在兩個致命的問題。第一是編譯時間過久,影響程式碼啟動速度;第二是快取編譯後的二進位制程式碼佔用更多的記憶體。所以便引入位元組碼來解決上述啟動問題和空間問題。
[generated bytecode for function: multi (0x06d300259e19 <SharedFunctionInfo multi>)]
Bytecode length: 14
Parameter count 1
Register count 1
Frame size 8
Bytecode age: 0
0x6d30025a092 @ 0 : 83 00 01 CreateFunctionContext [0], [1]
0x6d30025a095 @ 3 : 1a fa PushContext r0
0x6d30025a097 @ 5 : 0d 0a LdaSmi [10]
0x6d30025a099 @ 7 : 25 02 StaCurrentContextSlot [2]
0x6d30025a09b @ 9 : 80 01 00 02 CreateClosure [1], [0], #2
0x6d30025a09f @ 13 : a9 Return
Constant pool (size = 2)
0x6d30025a061: [FixedArray] in OldSpace
- map: 0x06d300002231 <Map(FIXED_ARRAY_TYPE)>
- length: 2
0: 0x06d300259ff9 <ScopeInfo FUNCTION_SCOPE>
1: 0x06d30025a029 <SharedFunctionInfo inner>
Handler Table (size = 0)
Source Position Table (size = 0)
我們看到 Bytecode age: 0
(代表位元組碼的執行狀態,數字增加代表函式的熱度,也就是上面說的熱點程式碼,V8 就會對這串程式碼進行針對性最佳化)下的一條條指令就是位元組碼啦,這六條指令直譯器執行完就代表 multi 函式執行完成了,上面列印出來的位元組碼只是全部的冰山一角,若有同學有興趣的話,可以到V8原始碼檢視更多。
這裡的位元組碼最終透過直譯器解釋執行,在執行的過程中,需要透過某些手段去儲存引數、中間計算結果等,V8 的直譯器(Ignition)採用的是基於暫存器的架構,他透過暫存器來儲存所需要的資料。有興趣的同學可以詳細檢視Ignition 設計文件中的 register 相關內容。
下面我來簡單逐行解釋下列印出來的程式碼。
Bytecode length
表示函式 multi 的位元組碼長度。Parameter count 1
表示函式 multi 接收一個引數,這裡是隱式地傳入了 this
。Register count
表示使用的暫存器數量。Frame size
代表棧幀大小(因為 V8 是透過棧結構來管理函式呼叫,棧幀是一個用於儲存引數、被呼叫者的返回值、區域性變數和暫存器的空間)。
CreateFunctionContext
是用來建立函式上下文的,會把 multi 函式上下文和作用域資訊存到暫存器中,當然 inner 函式也會存進去。PushContext
用於將暫存器中的上下文推入執行上下文棧。LdaSmi
和 StaCurrentContextSlot
代表將值 10 載入到暫存器中並且儲存到當前上下文中。CreateClosure
就是透過傳入上下文的一些資訊,若發現內部有引用外層作用域鏈上的變數,則輸出帶有閉包資訊的新的 inner 函式存進暫存器中最後返回。
我們重點看下下面的位元組碼,Constant pool
代表常量池,當程式碼中使用了多個相同的常量值時,V8 引擎會將這些常量值儲存在 Constant pool
中,並在需要使用時直接引用它們,而不是重複建立多個相同的常量值。繼續往下看 [FixedArray] in OldSpace
代表下面的常量存到了老生代中,老生代中的物件更穩定,不容易被回收,通常用於用於儲存生命週期較長的物件,例如函式、閉包、大型物件。下面的 ScopeInfo FUNCTION_SCOPE
表示函式作用域資訊的資料結構,它記錄了函式內部的變數和作用域鏈等資訊。SharedFunctionInfo inner
表示用於儲存 inner 函式的位元組碼等。這兩個常量同時存在表示內部函式 inner 與外部函式的作用域存在關聯,透過 ScopeInfo
中的作用域鏈查詢到內部函式訪問了外部函式的變數。最後在 SharedFunctionInfo
中會儲存內部函式引用的外部函式的變數作用域範圍的資訊,這裡就是儲存了閉包變數 a 的作用域範圍,儲存到了堆中供後續 inner 函式執行訪問。
所以 V8 透過預解析器使得 JavaScript 的閉包特性得以實現。
總結
本文我們介紹了在 V8 中是如何實現閉包這一特性的,V8 在處理函式的時候採用的延遲解析來提高啟動速度,但是延遲解析和閉包存在天然的矛盾,所以當一個函式中存在閉包並且執行時,V8 會透過引入預解析器去掃描內部函式使用到的外部變數,並且複製到堆中,下次執行內部函式的時候就是直接訪問堆中的引用。
最後我們要注意閉包可能導致的記憶體洩露問題,我們書寫閉包程式碼時如果引用了一些後續用不到的變數,比如說引用了一個大物件,但是我們只用這個物件中的一個屬性值,那麼就會導致這個大物件不會被銷燬,導致記憶體洩漏,解決方式們就是要將需要的屬性值提取出來成為一個新變數,在函式中引用此新變數就可以。還有一些引用 dom 節點產生的洩露等問題。
參考
Blazingly fast parsing, part 2: lazy parsing
最後
更多崗位,可進入網易招聘官網檢視 https://hr.163.com/