對於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 types和specification types兩大類。
language types是語言類型,咱們熟知的類型,也就是使用ECMAScript的程序員們能夠操做的數據類型,包括Undefined
, Null
, Number
, String
, Boolean
和Object
。
而規範類型(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代碼的某些特定語法結構(如FunctionDeclaration,WithStatement或TryStatement的Catch子句)相關聯,而且每次評估此類代碼時都會建立一個新的詞法環境。
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 Statement和12.14 The try Statement。
瞭解了詞法環境(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)分爲兩種:
ECMAScript規範約束了聲明式環境記錄和對象環境記錄都必須實現環境記錄類的一些公共的抽象方法,即使他們在具體實現算法上可能不一樣。
這些公共的抽象方法有:
聲明式環境記錄還應該實現兩個特有的方法:
關於不可變綁定(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的指向,而不是修改arguments。arguments[2] = 3
這種操做在嚴格模式下是不會報錯的。
因此不可變綁定(ImmutableBinding)約束的是引用不可變,而不是約束引用指向的對象不可變。
在咱們使用變量聲明,函數聲明,catch子句時,就會在JS引擎中創建對應的聲明式環境記錄,它們直接將identifier bindings與ECMAScript的language values關聯到一塊兒。
對象環境記錄(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)是一個特殊的詞法環境,在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)。
咱們知道,解釋執行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個動做:
以上這些動做的執行細節取決於代碼類型(分爲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這兩個相對陳舊的概念,因爲沒有官方的解釋,因此基本上是「一千個讀者,一千個哈姆雷特」了,我以爲可能這樣理解也行:
ES5及ES6規範中是沒有這樣的字眼的,因此乾脆忘掉VO, AO吧!
文章最開始提到了閉包是由函數和詞法環境組成。這裏再引用一段維基百科的閉包解釋佐證下。
在計算機科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函數閉包(function closures),是在支持頭等函數的編程語言中實現詞法綁定的一種技術。閉包在實現上是一個結構體,它存儲了一個函數(一般是其入口地址)和一個關聯的環境(至關於一個符號查找表)。環境裏是若干對符號和值的對應關係,它既要包括約束變量(該函數內部綁定的符號),也要包括自由變量(在函數外部定義但在函數內被引用),有些函數也可能沒有自由變量。閉包跟函數最大的不一樣在於,當捕捉閉包的時候,它的自由變量會在捕捉時被肯定,這樣即使脫離了捕捉時的上下文,它也能照常運行。
這是站在計算機科學的角度解釋什麼是閉包,固然這一樣適用於javascript!
裏面提到了一個詞「自由變量」,也就是閉包詞法環境中咱們重點關注的變量。
Chrome瀏覽器彷佛已經成爲了前端的標準,那麼在Chrome瀏覽器中,是如何斷定閉包的呢?不妨來探索下!
function test() { var a = 1; function increase() { debugger; var b = 2; a++; return a; }; increase(); } test();
我把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
中的變量,就不會產生閉包。
因此到這裏,咱們能夠下這樣一個結論,閉包產生的必要條件是:
在面試過程當中,咱們一般被問到的閉包場景是:內部函數引用了外部函數的變量,而且做爲外部函數的返回值。這是一種特殊的閉包,舉個例子看下:
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();
this
關鍵詞的值爲window
對象,建立全局環境(Global Environment)。全局對象下有adder
, test
等變量和函數聲明。test
函數,進入test
函數執行上下文。在test
函數執行過程當中,聲明瞭變量a
,函數increase
和getValue
。最終返回一個對象,該對象的兩個屬性分別引用了函數increase
和getValue
。test
函數執行上下文,test
函數的執行結果賦值給變量adder
,當前執行上下文恢復成全局執行上下文。adder
的increase
方法,進入increase
函數的執行上下文,執行代碼使變量a
自增1
。increase
函數的執行上下文。adder
的getValue
方法,其過程與調用increase
方法的過程相似。對整個執行過程有了必定認識後,咱們彷佛也很難解釋爲何閉包中的變量a
不會被GC回收。只有一個事實是很清楚的,那就是每次執行increase
和getValue
方法時,都依賴函數test
中定義的變量a
,但僅憑這個事實做爲理由顯然也是不具備說服力。
這裏不妨拋出一個問題,代碼是如何解析a
這個標識符的呢?
經過閱讀規範,咱們能夠知道,解析標識符是經過GetIdentifierReference(lex, name, strict)
,其中lex
是詞法環境,name
是標識符名稱,strict
是嚴格模式的布爾型標誌。
那麼在執行函數increase
時,是怎麼解析標識符a
的呢?咱們來分析下!
lex
的值爲函數increase
的localEnv
(函數的本地環境),經過GetIdentifierReference(lex, name, strict)
在localEnv
中解析標識符a
。localEnv
並不能解析到標識符a
(由於a
不是在函數increase
中聲明的,這很明顯),因此會轉到localEnv
的外部詞法環境繼續查找,而這個外部詞法環境其實就是increase
函數的內部屬性[[Scope]](這一點我是從仔細看了多遍規範定義得出的),也就是test
函數的localEnv
的「閹割版」。test
那一步,執行完函數test
後,函數test
中localEnv
中的其餘變量的binding都能在後續GC的過程當中被釋放,惟獨a
的binding不能被釋放,由於還有其餘詞法環境(increase
函數的內部屬性[[Scope]])會引用a
。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.
網上的文章關於這一塊仍是講得挺詳細的,本文就再也不舉例了。總的來講,閉包有這麼一些優勢:
閉包是雙刃劍,也存在這麼一個比較明顯的缺點:
本文從ECMAScript規範入手,一步一步揭開了閉包的神祕面紗。首先從閉包的定義瞭解到詞法環境,從詞法環境又引出環境記錄,外部詞法環境引用和執行上下文等概念。在對VO, AO等舊概念產生懷疑後,我選擇了從規範中尋找線索,最終有了頭緒。解讀閉包時,我尋找了多方資料,從計算機科學的閉包通用定義入手,將一些關鍵概念映射到javascript中,結合GC的一些知識點,算是有了答案。
寫這篇文章花了很多時間,由於涉及到ECMAScript規範,一些描述必須客觀嚴謹。解讀過程必然存在主觀成分,若有錯誤之處,還望指出!
最後,很是建議你們在有空的時候多多閱讀ECMAScript規範。閱讀語言規範是一個很好的解惑方式,能讓咱們更好地理解一門語言的基本原理。就好比假設咱們不清楚某個運算符的執行邏輯,那麼直接看語言規範是最穩妥的!
結尾附上一張能夠幫助你理解ECMAScript規範的圖片。
若是方便的話,幫我點個贊喲,謝謝!歡迎加我微信laobaife
交流,技術會友,閒聊亦可。