解讀閉包,這次從ECMAScript詞法環境,執行上下文說起

幾個我發表於2020-08-11

對於x年經驗的前端仔來說,專案也做了好些個了,各個場景也接觸過一些。但是假設真的要跟面試官敞開來撕原理,還是有點慌的。看到很多大神都在手撕各種框架原理還是有點羨慕他們的技術實力,羨慕不如行動,先踏踏實實啃基礎。嗯...今天來聊聊閉包!

講閉包的文章可能大家都看了幾十篇了吧,而且也能發現,一些文章(我沒說全部)行文都是一個套路,基本上都在關注兩個點,什麼是閉包,閉包舉例,很有搬運工的嫌疑。我看了這些文章之後,一個很大的感受是:如果讓我給別人講解閉包這個知識點,我能說得清楚嗎?我的依據是什麼?可信度有多大?我覺得我是懷疑我自己的,否定三連估計是妥了。

好像懂了嗎

不同的階段做不同的事,當有一些基礎後,我們還是可以適當地研究下原理,不要浮在問題表面!那麼技術水平一般的我們,應該怎麼辦,怎麼從這些雜亂的文章中突圍?我覺得一個辦法是從一些比較權威的文件上去找線索,比如ES規範,MDN,維基百科等。

關於閉包(closure),總是有著不同的解釋。

第一種說法是,閉包是由函式以及宣告該函式的詞法環境組合而成的。這個說法來源於MDN-閉包

另外一種說法是,閉包是指有權訪問另外一個函式作用域中的變數的函式。

從我的理解來看,我認為第一個說法是正確的,閉包不是一個函式,而是函式和詞法環境組成的。那麼第二種說法對不對呢?我覺得它說對了一半,在閉包場景下,確實存在一個函式有權訪問另外一個函式作用域中的變數,但閉包不是函式。

這就完了嗎?顯然不是!解讀閉包,這次我們刨根究底(吹下牛逼)!

本文會直接從ECMAScript5規範入手解讀JS引擎的部分內部實現邏輯,基於這些認知再來重新審視閉包

回到主題,上文提到的詞法環境(Lexical Environment)到底是什麼?

詞法環境

我們可以看看ES5規範第十章(可執行程式碼和執行上下文)中的第二節詞法環境是怎麼說的。

A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code.

詞法環境是一種規範型別(specification type),它定義了識別符號和ECMAScript程式碼中的特定變數及函式之間的聯絡。

問題來了,規範型別(specification type)又是什麼?specification type是Type的一種。從ES5規範中可以看到Type分為language typesspecification types兩大類。

型別示意圖

language types是語言型別,我們熟知的型別,也就是使用ECMAScript的程式設計師們可以操作的資料型別,包括Undefined, Null, Number, String, BooleanObject

而規範型別(specification type)是一種更抽象的元值(meta-values),用於在演算法中描述ECMAScript的語言結構和語言型別的具體語義。

A specification type corresponds to meta-values that are used within algorithms to describe the semantics of ECMAScript language constructs and ECMAScript language types.

至於元值是什麼,我覺得可以理解為後設資料,而後設資料是什麼意思,可以簡單看看這篇知乎什麼是後設資料?為何需要後設資料?

總的來說,後設資料是用來描述資料的資料。這一點就可以類比於,高階語言總要用一個更底層的語言和資料結構來描述和表達。這也就是JS引擎乾的事情。

大致理解了規範型別是什麼後,我們不免要問下:規範型別(specification type)包含什麼?

The specification types are Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, and Environment Record.

看到這裡我好似明白了些什麼,原來詞法環境(Lexical Environment)和環境記錄(Environment Record)都是一種規範型別(specification type),果然是更底層的概念。

先拋開List, Completion, Property Descriptor, Property Identifier等規範型別不說,我們接著看詞法環境(Lexical Environment)這種規範型別。

下面這句解釋了詞法環境到底包含了什麼內容:

A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment.

詞法環境包含了一個環境記錄(Environment Record)和一個指向外部詞法環境的引用,而這個引用的值可能為null。

一個詞法環境的結構如下:

Lexical Environment
  + Outer Reference
  + Environment Record

Outer Reference指向外部詞法環境,這也說明了詞法環境是一個連結串列結構。簡單畫個結構圖幫助理解下!

詞法環境連結串列示意圖

Usually a Lexical Environment is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a WithStatement, or a Catch clause of a TryStatement and a new Lexical Environment is created each time such code is evaluated.

通常,詞法環境與ECMAScript程式碼的某些特定語法結構(如FunctionDeclarationWithStatementTryStatementCatch子句)相關聯,並且每次評估此類程式碼時都會建立一個新的詞法環境。

PS:evaluated是evaluate的過去分詞,從字面上解釋就是評估,而評估程式碼我覺得不是很好理解。我個人的理解是,評估程式碼代表著JS引擎在解釋執行javascript程式碼

我們知道,執行函式會建立新的詞法環境。

我們也認同,with語句會“延長”作用域(實際上是呼叫了NewObjectEnvironment,建立了一個新的詞法環境,詞法環境的環境記錄是一個物件環境記錄)。

以上這些是我們比較好理解的。那麼catch子句對詞法環境做了什麼?雖然try-catch平時用得還比較多,但是關於詞法環境的細節很多人都不會注意到,包括我!

我們知道,catch子句會有一個錯誤物件e

function test(value) {
  var a = value;
  try {
    console.log(b);
    // 直接引用一個不存在的變數,會報ReferenceError
  } catch(e) {
    console.log(e, arguments, this)
  }
}
test(1);

catch子句中列印arguments,只是為了證明catch子句不是一個函式。因為如果catch是一個函式,顯然這裡列印的arguments就不應該是test函式的arguments。既然catch不是一個函式,那麼憑什麼可以有一個僅限在catch子句中被訪問的錯誤物件e

答案就是catch子句使用NewDeclarativeEnvironment建立了一個新的詞法環境(catch子句中詞法環境的外部詞法環境引用指向函式test的詞法環境),然後通過CreateMutableBinding和SetMutableBinding將識別符號e與新的詞法環境的環境記錄關聯上。

有人會說,for迴圈中的initialization部分也可以通過var定義變數,和catch子句有什麼本質區別嗎?要注意的是,在ES6之前是沒有塊級作用域的。在for迴圈中通過var定義的變數原則上歸屬於所在函式的詞法環境。如果for語句不是用在函式中,那麼其中通過var定義的變數就是屬於全域性環境(The Global Environment)。

with語句和catch子句中建立了新的詞法環境這一結論,證據來源於上文中一句話“a new Lexical Environment is created each time such code is evaluated.”具體細節也可以看看12.10 The with Statement12.14 The try Statement

Environment Record

瞭解了詞法環境(Lexical Environment),接下來就說說詞法環境中的環境記錄(Environment Record)吧。環境記錄與我們使用的變數,函式息息相關,可以說環境記錄是它們的底層實現。

規範描述環境記錄的內容太長,這兒就不全部複製了,請直接開啟ES5規範第10.2.1節閱讀。

There are two kinds of Environment Record values used in this specification: declarative environment records and object environment records. // 省略一大段

從規範中我們可以看到環境記錄(Environment Record)分為兩種:

  • declarative environment records 宣告式環境記錄
  • object environment records 物件環境記錄

ECMAScript規範約束了宣告式環境記錄和物件環境記錄都必須實現環境記錄類的一些公共的抽象方法,即便他們在具體實現演算法上可能不同。

這些公共的抽象方法有:

  • HasBinding(N)
  • CreateMutableBinding(N, D)
  • SetMutableBinding(N,V, S)
  • GetBindingValue(N,S)
  • DeleteBinding(N)
  • ImplicitThisValue()

宣告式環境記錄還應該實現兩個特有的方法:

  • CreateImmutableBinding(N)
  • InitializeImmutableBinding(N,V)

關於不可變繫結(ImmutableBinding),在規範中有這麼一段比較細緻的場景描述:

If strict is true, then Call env’s CreateImmutableBinding concrete method passing the String "arguments" as the argument.

Call env’s InitializeImmutableBinding concrete method passing "arguments" and argsObj as arguments.

Else,Call env’s CreateMutableBinding concrete method passing the String "arguments" as the argument.

Call env’s SetMutableBinding concrete method passing "arguments", argsObj, and false as arguments.

也就是說,只有嚴格模式下,才會對函式的arguments物件使用不可變繫結。應用了不可變繫結(ImmutableBinding)的變數意味著不能再被重新賦值,舉個例子:

非嚴格模式下可以改變arguments的指向:

function test(a, b) {
  arguments = [3, 4];
  console.log(arguments, a, b)
}
test(1, 2)
// [3, 4] 1 2

而在嚴格模式下,改變arguments的指向會直接報錯:

"use strict";
function test(a, b) {
  arguments = [3, 4];
  console.log(arguments, a, b)
}
test(1, 2)
// Uncaught SyntaxError: Unexpected eval or arguments in strict mode

要注意,我這裡說的是改變arguments的指向,而不是修改argumentsarguments[2] = 3這種操作在嚴格模式下是不會報錯的。

所以不可變繫結(ImmutableBinding)約束的是引用不可變,而不是約束引用指向的物件不可變。

declarative environment records

在我們使用變數宣告函式宣告catch子句時,就會在JS引擎中建立對應的宣告式環境記錄,它們直接將identifier bindings與ECMAScript的language values關聯到一起。

object environment records

物件環境記錄(object environment records),包含Program, WithStatement,以及後面說到的全域性環境的環境記錄。它們將identifier bindings與某些物件的屬性關聯到一起。

看到這裡,我自己就想問下:identifier bindings是啥?

看了ES5規範中提到的環境記錄(Environment Record)的抽象方法後,我有了一個大致的答案。

先簡單看一下javascript變數取值和賦值的過程:

var a = 1;
console.log(a);

我們在給變數a初始化並賦值1的這樣一個步驟,其實體現在JS引擎中,是執行了CreateMutableBinding(建立可變繫結)和SetMutableBinding(設定可變繫結的值)。

而在對變數a取值時,體現在JS引擎中,是執行了GetBindingValue(獲取繫結的值),這些執行過程中會有一些斷言和判斷,也會牽涉到嚴格模式的判斷,具體見10.2.1.1 Declarative Environment Records

這裡也省略了一些步驟,比如說GetIdentifierReference, GetValue(V), PutValue(V) 等。

按我的理解,identifier bindings就是JS引擎中維護的一組繫結關係,可以與javascript中的識別符號關聯起來。

The Global Environment

全域性環境(The Global Environment)是一個特殊的詞法環境,在ECMAScript程式碼執行之前就被建立。全域性環境中的環境記錄(Environment Record)是一個物件環境記錄(object environment record),它被繫結到一個全域性物件(Global Object)上,體現在瀏覽器環境中,與Global Object關聯的就是window物件

全域性環境是一個頂層的詞法環境,因此全域性環境不再有外部詞法環境,或者說它的外部詞法環境的引用是null。

15.1 The Global Object一節也解釋了Global Object的一些細節,比如為什麼不能new Window(),為什麼在不同的宿主環境中全域性物件會有很大區別......

執行上下文

看了這些我們還是沒有一個全盤的把握去解讀閉包,不如接著看看執行上下文。在我之前的理解中,上下文應該是一個環境,包含了程式碼可訪問的變數。當然,這顯然還不夠全面。那麼上下文到底是什麼?

When control is transferred to ECMAScript executable code, control is entering an execution context. Active execution contexts logically form a stack. The top execution context on this logical stack is the running execution context.

當程式控制轉移到ECMAScript可執行程式碼(executable code)時,就進入了一個執行上下文(execution context),執行上下文是一個邏輯上的堆疊結構(Stack)。堆疊中最頂層的執行上下文就是正在執行的執行上下文。

很多人對可執行程式碼可能又有疑惑了,javascript不都是可執行程式碼嗎?不是的,比如註釋(Comment),空白符(White Space)就不是可執行程式碼。

An execution context contains whatever state is necessary to track the execution progress of its associated code.

執行上下文包含了一些狀態(state),這些狀態用於跟蹤與之關聯的程式碼的執行程式。每個執行上下文都有這些狀態元件(Execution Context State Components)。

  • LexicalEnvironment:詞法環境
  • VariableEnvironment:變數環境
  • ThisBinding:與執行上下文直接關聯的this關鍵字

執行上下文的建立

我們知道,解釋執行global code或使用eval function,呼叫函式都會建立一個新的執行上下文,執行上下文是堆疊結構。

When control enters an execution context, the execution context’s ThisBinding is set, its VariableEnvironment and initial LexicalEnvironment are defined, and declaration binding instantiation (10.5) is performed. The exact manner in which these actions occur depend on the type of code being entered.

當控制程式進入執行上下文時,會發生下面這3個動作:

  1. this關鍵字的值被設定。
  2. 同時VariableEnvironment(不變的)和initial LexicalEnvironment(可能會變,所以這裡說的是initial)被定義。
  3. 然後執行宣告式繫結初始化操作。

以上這些動作的執行細節取決於程式碼型別(分為global code, eval code, function code三類)。

PS:通常情況下,VariableEnvironment和LexicalEnvironment在初始化時是一致的,VariableEnvironment不會再發生變化,而LexicalEnvironment在程式碼執行的過程中可能會變化。

那麼進入global code,eval code,function code時,執行上下文會發生什麼不同的變化呢?感興趣的可以仔細閱讀下10.4 Establishing an Execution Context

詞法環境的連結串列結構

回顧一下上文,上文中提到,詞法環境是一個連結串列結構。

詞法環境連結串列示意圖

眾所周知,在理解閉包的時候,很多人都會提到作用域鏈(Scope Chain)這麼一個概念,同時會引出VO(變數物件)和AO(活動物件)這些概念。然而我在閱讀ECMAScript規範時,通篇沒有找到這些關鍵詞。我就在想,詞法環境的連結串列結構是不是他們說的作用域鏈?VO,AO是不是已經過時的概念?但是這些概念又好像成了“權威”,一搜相關的文章,都在說VO, AO,我真的也要這樣去理解嗎?

在ECMAScript中,找到8.6.2 Object Internal Properties and Methods一節中的Table 9 Internal Properties Only Defined for Some Objects,的確存在[[Scope]]這麼一個內部屬性,按照Scope單詞的意思,[[Scope]]不就是函式作用域嘛!

在這個Table中,我們可以明確看到[[Scope]]的Value Type Domain一列的值是Lexical Environment,這說明[[Scope]]就是一種詞法環境。我們接著看看Description:

A lexical environment that defines the environment in which a Function object is executed. Of the standard built-in ECMAScript objects, only Function objects implement [[Scope]].

仔細看下,[[Scope]]是函式物件被執行時所在的環境,而且只有函式實現了[[Scope]]屬性,這意味著[[Scope]]是函式特有的屬性。

所以,我是不是可以理解為:作用域鏈(Scope Chain)就是函式執行時能訪問的詞法環境鏈。而廣義上的詞法環境連結串列不僅包含了作用域鏈,還包括WithStatement和Catch子句中的詞法環境,甚至包含ES6的Block-Level詞法環境。這麼看來,ECMAScript是非常嚴謹的!

而VO,AO這兩個相對陳舊的概念,由於沒有官方的解釋,所以基本上是“一千個讀者,一千個哈姆雷特”了,我覺得可能這樣理解也行:

  • VO是詞法分析(Lexical Parsing)階段的產物
  • AO是程式碼執行(Execution)階段的產物

ES5及ES6規範中是沒有這樣的字眼的,所以乾脆忘掉VO, AO吧!

閉包

什麼是閉包?

文章最開始提到了閉包是由函式和詞法環境組成。這裡再引用一段維基百科的閉包解釋佐證下。

在電腦科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是在支援頭等函式的程式語言中實現詞法繫結的一種技術。閉包在實現上是一個結構體,它儲存了一個函式(通常是其入口地址)和一個關聯的環境(相當於一個符號查詢表)。環境裡是若干對符號和值的對應關係,它既要包括約束變數(該函式內部繫結的符號),也要包括自由變數(在函式外部定義但在函式內被引用),有些函式也可能沒有自由變數。閉包跟函式最大的不同在於,當捕捉閉包的時候,它的自由變數會在捕捉時被確定,這樣即便脫離了捕捉時的上下文,它也能照常執行。

這是站在電腦科學的角度解釋什麼是閉包,當然這同樣適用於javascript!

裡面提到了一個詞“自由變數”,也就是閉包詞法環境中我們重點關注的變數。

Chrome如何定義閉包?

Chrome瀏覽器似乎已經成為了前端的標準,那麼在Chrome瀏覽器中,是如何判定閉包的呢?不妨來探索下!

function test() {
  var a = 1;
  function increase() {
    debugger;
    var b = 2;
    a++;
    return a;
  };
  increase();
}
test();

閉包1

我把debugger置於內部函式increase中,除錯時我們直接看右側的高亮部分,可以發現,Scope中存在一個Closure(閉包),Closure的名稱是外部函式test的函式名,閉包中的變數a是在函式test中定義的,而變數b是作為本地變數處於Local中。

PS: 關於本地變數,可以參見localEnv

假設我在外部函式test中再定義一個變數c,但是在內部函式increase中不引用它,會怎麼樣呢?

function test() {
  var a = 1;
  var c = 3; // c不在閉包中
  function increase() {
    debugger;
    var b = 2;
    a++;
    return a;
  };
  increase();
}
test();

經驗證,內部函式increase執行時,變數c沒有在閉包中。

我們還可以驗證,如果內部函式increase不引用任何外部函式test中的變數,就不會產生閉包。

所以到這裡,我們可以下這樣一個結論,閉包產生的必要條件是:

  1. 存在函式巢狀;
  2. 巢狀的內部函式必須引用在外部函式中定義的變數;
  3. 巢狀的內部函式必須被執行。

面試官最喜歡問的閉包

在面試過程中,我們通常被問到的閉包場景是:內部函式引用了外部函式的變數,並且作為外部函式的返回值。這是一種特殊的閉包,舉個例子看下:

function test() {
  var a = 1;
  function increase() {
    a++;
  };
  function getValue() {
    return a;
  }
  return {
    increase,
    getValue
  }
}
var adder = test();
adder.increase(); // 自增1
adder.getValue(); // 2
adder.increase();
adder.getValue(); // 3

在這個例子中,我們發現,每呼叫一次adder.increase()方法後,a的值會就會比上一次增加1,也就是說,變數a被保持在記憶體中沒有被釋放。

那麼這種現象背後到底是怎麼回事呢?

閉包分析

既然閉包涉及到記憶體問題,那麼不得不提一嘴V8的GC(垃圾回收)機制。

我們從書本上了解最多的GC策略就是引用計數,但是現代主流VM(包括V8, JVM等)都不採用引用計數的回收策略,而是採用可達性演算法。

引用計數讓人比較容易理解,所以常見於教材中,但是可能存在物件相互引用而無法釋放其記憶體的問題。而可達性演算法是從GC Roots物件(比如全域性物件window)開始進行搜尋存活(可達)物件,不可達物件會被回收,存活物件會經歷一系列的處理。

關於V8 GC的一些演算法細節,有一篇文章講得特別好,作者是洗影,非常建議去看看,已附在文末的參考資料中。

而在我們關注的這種特殊閉包場景下,之所以閉包變數會保持在記憶體中,是因為閉包的詞法環境沒有被釋放。我們先來分析下執行過程。

function test() {
  var a = 1;
  function increase() {
    a++;
  };
  function getValue() {
    return a;
  }
  return {
    increase,
    getValue
  }
}
var adder = test();
adder.increase();
adder.getValue();
  1. 初始執行global code,建立全域性執行上下文,隨之設定this關鍵詞的值為window物件,建立全域性環境(Global Environment)。全域性物件下有adder, test等變數和函式宣告。

  1. 開始執行test函式,進入test函式執行上下文。在test函式執行過程中,宣告瞭變數a,函式increasegetValue。最終返回一個物件,該物件的兩個屬性分別引用了函式increasegetValue

  1. 退出test函式執行上下文,test函式的執行結果賦值給變數adder,當前執行上下文恢復成全域性執行上下文。

  1. 呼叫adderincrease方法,進入increase函式的執行上下文,執行程式碼使變數a自增1

  1. 退出increase函式的執行上下文。
  2. 呼叫addergetValue方法,其過程與呼叫increase方法的過程類似。

對整個執行過程有了一定認識後,我們似乎也很難解釋為什麼閉包中的變數a不會被GC回收。只有一個事實是很清楚的,那就是每次執行increasegetValue方法時,都依賴函式test中定義的變數a,但僅憑這個事實作為理由顯然也是不具有說服力。

這裡不妨丟擲一個問題,程式碼是如何解析a這個識別符號的呢?

通過閱讀規範,我們可以知道,解析識別符號是通過GetIdentifierReference(lex, name, strict),其中lex是詞法環境,name是識別符號名稱,strict是嚴格模式的布林型標誌。

那麼在執行函式increase時,是怎麼解析識別符號a的呢?我們來分析下!

  1. 首先,讓lex的值為函式increaselocalEnv(函式的本地環境),通過GetIdentifierReference(lex, name, strict)localEnv中解析識別符號a
  2. 根據GetIdentifierReference的執行邏輯,在localEnv並不能解析到識別符號a(因為a不是在函式increase中宣告的,這很明顯),所以會轉到localEnv的外部詞法環境繼續查詢,而這個外部詞法環境其實就是increase函式的內部屬性[[Scope]](這一點我是從仔細看了多遍規範定義得出的),也就是test函式的localEnv的“閹割版”。
  3. 回到執行函式test那一步,執行完函式test後,函式testlocalEnv中的其他變數的binding都能在後續GC的過程中被釋放,唯獨a的binding不能被釋放,因為還有其他詞法環境(increase函式的內部屬性[[Scope]])會引用a
  4. 閉包的詞法環境和函式test執行時的localEnv是不一樣的。函式test執行時,其localEnv會完完整整地重新初始化一遍,而退出函式test的執行上下文後,閉包詞法環境只保留了其環境記錄中的一部分bindings,這部分bindings會被其他詞法環境引用,所以我稱之為“閹割版”。

這裡可能會有朋友提出一個疑問(我也這樣問過我自己),為什麼adder.increase()是在全域性執行上下文中被呼叫,它執行時的外部詞法環境仍然是test函式的localEnv的“閹割版”?

這就要回到外部詞法環境引用的定義了,外部詞法環境引用指向的是邏輯上包圍內部詞法環境的詞法環境

The outer reference of a (inner) Lexical Environment is a reference to the Lexical Environment that logically surrounds the inner Lexical Environment.

閉包的優缺點

網上的文章關於這一塊還是講得挺詳細的,本文就不再舉例了。總的來說,閉包有這麼一些優點:

  • 變數常駐記憶體,對於實現某些業務很有幫助,比如計數器之類的。
  • 架起了一座橋樑,讓函式外部訪問函式內部變數成為可能。
  • 私有化,一定程式上解決命名衝突問題,可以實現私有變數。

閉包是雙刃劍,也存在這麼一個比較明顯的缺點:

  • 存在這樣的可能,變數常駐在記憶體中,其佔用記憶體無法被GC回收,導致記憶體溢位。

小結

本文從ECMAScript規範入手,一步一步揭開了閉包的神祕面紗。首先從閉包的定義瞭解到詞法環境,從詞法環境又引出環境記錄,外部詞法環境引用和執行上下文等概念。在對VO, AO等舊概念產生懷疑後,我選擇了從規範中尋找線索,最終有了頭緒。解讀閉包時,我尋找了多方資料,從電腦科學的閉包通用定義入手,將一些關鍵概念對映到javascript中,結合GC的一些知識點,算是有了答案。

寫這篇文章花了不少時間,因為涉及到ECMAScript規範,一些描述必須客觀嚴謹。解讀過程必然存在主觀成分,如有錯誤之處,還望指出!

最後,非常建議大家在有空的時候多多閱讀ECMAScript規範。閱讀語言規範是一個很好的解惑方式,能讓我們更好地理解一門語言的基本原理。就比如假設我們不清楚某個運算子的執行邏輯,那麼直接看語言規範是最穩妥的!

結尾附上一張可以幫助你理解ECMAScript規範的圖片。

如果方便的話,幫我點個贊喲,謝謝!歡迎加我微信laobaife交流,技術會友,閒聊亦可。

參考資料

相關文章