解讀閉包,此次從ECMAScript詞法環境,執行上下文提及

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

講閉包的文章可能你們都看了幾十篇了吧,並且也能發現,一些文章(我沒說所有)行文都是一個套路,基本上都在關注兩個點,什麼是閉包,閉包舉例,頗有搬運工的嫌疑。我看了這些文章以後,一個很大的感覺是:若是讓我給別人講解閉包這個知識點,我能說得清楚嗎?個人依據是什麼?可信度有多大?我以爲我是懷疑我本身的,否認三連估計是妥了。前端

好像懂了嗎

不一樣的階段作不一樣的事,當有一些基礎後,咱們仍是能夠適當地研究下原理,不要浮在問題表面!那麼技術水平通常的咱們,應該怎麼辦,怎麼從這些雜亂的文章中突圍?我以爲一個辦法是從一些比較權威的文檔上去找線索,好比ES規範,MDN,維基百科等。java

關於閉包(closure),老是有着不一樣的解釋。git

第一種說法是,閉包是由函數以及聲明該函數的詞法環境組合而成的。這個說法來源於MDN-閉包程序員

另一種說法是,閉包是指有權訪問另一個函數做用域中的變量的函數。github

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

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

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

回到主題,上文提到的詞法環境(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交流,技術會友,閒聊亦可。

參考資料

相關文章
相關標籤/搜索