閉包是JavaScript中的一個基本概念,每個認真的程序員都應該對它瞭如指掌。html
互聯網上充斥着大量關於「什麼是閉包」的解釋,卻不多有人深刻探究它「爲何」的一面。程序員
我發現理解閉包的內在原理會使開發者們在使用開發工具時有更大的把握。因此,本文將致力於講解閉包是如何工做的以及其工做原理的具體細節。編程
但願在你能從中得到更好的知識儲備,以便在平常工做中更好地利用閉包。讓咱們開始吧!瀏覽器
什麼是閉包?markdown
閉包是 JavaScript (以及其餘大多數編程語言) 的一個極其強大的屬性。正如在MDN (Mozilla Developer Network) 中定義的那樣:數據結構
閉包是指可以訪問自由變量的函數。換句話說,在閉包中定義的函數能夠「記憶」它被建立的環境。
注:自由變量是既不是在本地聲明又不做爲參數傳遞的一類變量。(譯者注:若是一個做用域中使用的變量並非在該做用域中聲明的,那麼這個變量對於該做用域來講就是自由變量)閉包
讓咱們來看一些例子:ecmascript
Example 1: Function numberGenerator() { // Local 「free」 variable that ends up within the closure var num = 1; function checkNumber() { console.log(num); } num++; return checkNumber; } var number = numberGenerator(); number(); // 2
在 GitHub 上查看 rawnumberGenerator.js 編程語言
在以上例子中,numberGenerator 函數建立了一個局部的自由變量 num (一個數字) 和 checkNumber 函數 (一個在控制檯打印 num 的函數)。checkNumber 函數沒有本身的局部變量,可是,因爲使用了閉包,它能夠經過 numberGenerator 這個外部函數來訪問(外部聲明的)變量。所以即便在 numberGenerator 函數被返回之後,checkNumber 函數也可使用 numberGenerator 中聲明的變量 num 從而成功地在控制檯記錄日誌。ide
Example 2: function sayHello() { var say = function() { console.log(hello); } // Local variable that ends up within the closure var hello = 'Hello, world!'; return say; } var sayHelloClosure = sayHello(); sayHelloClosure(); // ‘Hello, world!’
在 GitHub 上查看 rawsayHello.js
在這個例子中咱們演示了一個閉包包含了外圍函數中聲明的所有局部變量。
請注意,變量 hello 是在匿名函數以後定義的,可是該匿名函數仍然能夠訪問到 hello 這個變量。這是由於變量hello在建立這個函數的「做用域」時就已經被定義了,這使得它在匿名函數最終執行的時候是可用的。(沒必要擔憂,我會在本文的後面解釋「做用域」是什麼,如今暫時跳過它!)
深刻理解閉包
這些例子從更深層次闡述了什麼是閉包。整體來講狀況是這樣的:即便聲明這些變量的外圍函數已經返回之後,咱們仍然能夠訪問在外圍函數中聲明的變量。顯然,在這背後有一些事情發生了,使得這些變量在外圍函數返回值之後仍然能夠被訪問到。
爲了理解這是如何發生的,咱們須要接觸到幾個相關的概念——從3000英尺的高空(抽象的概念)逐步地返回到閉包的「陸地」上來。讓咱們從函數運行中最重要的內容——「執行上下文」開始吧!
Execution Context 執行上下文
執行上下文是一個抽象的概念,ECMAScript 規範使用它來追蹤代碼的執行。它多是你的代碼第一次執行或執行的流程進入函數主體時所在的全局上下文。
執行上下文
在任意一個時間點,只能有惟一一個執行上下文在運行之中。這就是爲何 JavaScript 是「單線程」的緣由,意思就是一次只能處理一個請求。通常來講,瀏覽器會用「棧」來保存這個執行上下文。棧是一種「後進先出」 (Last In First Out) 的數據結構,即最後插入該棧的元素會最早從棧中被彈出(這是由於咱們只能從棧的頂部插入或刪除元素)。當前的執行上下文,或者說正在運行中的執行上下文永遠在棧頂。當運行中的上下文被徹底執行之後,它會由棧頂彈出,使得下一個棧頂的項接替它成爲正在運行的執行上下文。
除此以外,一個執行上下文正在運行並不表明另外一個執行上下文須要等待它完成運行以後才能夠開始運行。有時會出現這樣的狀況,一個正在運行中的上下文暫停或停止,另一個上下文開始執行。暫停的上下文可能在稍後某一時間點從它停止的位置繼續執行。一個新的執行上下文被建立並推入棧頂,成爲當前的執行上下文,這就是執行上下文替代的機制。
如下是這個概念在瀏覽器中的行爲實例:
var x = 10; function foo(a) { var b = 20; function bar(c) { var d = 30; return boop(x + a + b + c + d); } function boop(e) { return e * -1; } return bar; } var moar = foo(5); // Closure /* The function below executes the function bar which was returned when we executed the function foo in the line above. The function bar invokes boop, at which point bar gets suspended and boop gets push onto the top of the call stack (see the screenshot below) */ moar(15);
在 GitHub 上查看 rawexecutionContext.js
當 boop 返回時,它會從棧中彈出,bar 函數會恢復運行:
當咱們有不少執行上下文一個接一個地運行時——一般狀況下會在中間暫停而後再恢復運行——爲了能很好地管理這些上下文的順序和執行狀況,咱們須要用一些方法來對其狀態進行追蹤。而實際上也是如此,根據ECMAScript的規範,每一個執行上下文都有用於跟蹤代碼執行進程的各類狀態的組件。包括:
* 代碼執行狀態:任何須要開始運行,暫停和恢復執行上下文相關代碼執行的狀態
* 函數:上下文中正在執行的函數對象(正在執行的上下文是腳本或模塊的狀況下多是null)
* Realm:一系列內部對象,一個ECMAScript全局環境,全部在全局環境的做用域內加載的ECMAScript代碼,和其餘相關的狀態及資源。
* 詞法環境:用於解決此執行上下文內代碼所作的標識符引用。
* 變量環境:一種詞法環境,該詞法環境的環境記錄保留了變量聲明時在執行上下文中建立的綁定關係。
若是以上這些讓你讀起來很困惑,沒必要擔憂。在全部變量之中,詞法環境變量是咱們最感興趣的一個,由於它明確聲明它解決了這個執行上下文內代碼中的「標識符引用」。你能夠把「標識符」想成是變量。因爲咱們最初的目的就是弄清楚它是如何作到在一個函數(或「上下文」)返回之後還能神奇地訪問變量,所以詞法環境看起來就是咱們須要深刻挖掘的東西!
注意:從技術上來講,變量環境和詞法環境都是用來實現閉包的,但爲了簡單起見,咱們將這兩者概括爲「環境」。想了解關於詞法環境和變量環境的區別的更詳盡的解釋,能夠參看 Alex Rauschmayer 博士這篇很是棒的文章。
詞法環境
定義:詞法環境是一個基於 ECMAScript 代碼的詞法嵌套結構來定義特定變量和函數標識符的關聯的規範類型。詞法環境由一個環境記錄及一個可能爲空的對外部詞法環境的引用構成。一般,一個詞法環境會與ECMAScript代碼的一些特定語法結構相關聯,例如:FunctionDeclaration(函數聲明), BlockStatement(塊語句), TryStatement(Try語句)的Catch clause(Catch子句)。每當此類代碼執行時,都會建立一個新的詞法環境。— ECMAScript-262/6.0
讓咱們來把這個概念分解一下。
「用於定義標識符的關聯」:詞法環境目的就是在代碼中管理數據(即標識符)。換句話說,它給標識符賦予了含義。好比當咱們寫出這樣一行代碼 「log(x /10)」,若是咱們沒有給變量x賦予一些含義(聲明變量 x),那麼這個變量(或者說標識符)x 就是毫無心義的。詞法環境就經過它的環境記錄(參見下文)提供了這個含義(或「關聯」)。
「詞法環境包含一個環境記錄」:環境記錄保留了全部存在於該詞法環境中的標識符及其綁定的記錄。每個詞法環境都有它本身的環境記錄。
Source: http://4.bp.blogspot.com/
抽象地來講,(嵌套的)環境就像下面的僞代碼中表現的這樣:
LexicalEnvironment = { EnvironmentRecord: { // Identifier bindings go here }, // Reference to the outer environment outer: < > };
在 GitHub 上查看 rawlexicalEnv.js
總之,每一個執行上下文都有一個詞法環境。這個詞法環境保留了變量和與其相關聯的值,以及對其外部環境的引用。詞法環境能夠是全局環境,模塊的環境(包含一個模塊的頂級聲明的綁定),或是函數的環境(該環境隨着函數的調用而建立)。
做用域鏈
基於以上概念,咱們知道了一個環境能夠訪問它的父環境,而且該父環境還能夠繼續訪問它的父環境,以此類推。每一個環境可以訪問的一系列標識符,咱們稱其爲「做用域」。咱們能夠將多個做用域嵌套到一個環境的分級鏈式結構中,即「做用域鏈」。
讓咱們來看這種嵌套結構的一個例子:
在 GitHub 上查看 rawnesting.js
var x = 10; function foo() { var y = 20; // free variable function bar() { var z = 15; // free variable return x + y + z; } return bar; }
能夠看到,bar 嵌套在 foo 之中。爲了幫助你更清晰地看到嵌套結構,請看下方圖解:
咱們會在本文的後面重溫這個例子。
這個做用域鏈,或者說與函數相關聯的環境鏈,在函數被建立時就被保存在函數對象當中。換句話說,它按照位置被靜態地定義在源代碼內部。(這也被稱爲「詞法做用域」。)
讓咱們來快速地繞個路,來理解一下「動態做用域」和「靜態做用域」的區別。它講幫助咱們闡明爲何想實現閉包,靜態做用域(或詞法做用域)是必不可少的。
動態做用域 vs. 靜態做用域
動態做用域的語言「基於棧來實現」,意思就是函數的局部變量和參數都儲存在棧中。所以,程序堆棧的運行狀態決定你引用的是什麼變量。
另外一方面,靜態做用域是指當建立上下文時,被引用的變量就被記錄在其中。也就是說,這個程序的源代碼結構決定你指向的是什麼變量。
此刻你可能會想動態做用域和靜態做用域究竟有何不一樣。在此咱們藉助兩個例子來講明:
Example 1:
在 GitHub 上查看 rawstaticvsdynamic1.js
var x = 10; function foo() { var y = x + 5; return y; } function bar() { var x = 2; return foo(); } function main() { foo(); // Static scope: 15; Dynamic scope: 15 bar(); // Static scope: 15; Dynamic scope: 7 return 0; }
從上述代碼咱們看到,當調用函數 bar 的時候,靜態做用域和動態做用域返回了不一樣的值。
在靜態做用域中,bar 的返回值是基於函數 foo 建立時 x 的值。這是由於源代碼的靜態和詞法的結構致使 x 是 10 而最終結果是 15.
而另外一方面,動態做用域給了咱們一個在運行時追蹤變量定義的棧——所以,因爲咱們使用的 x 在運行時被動態地定義,因此它的值取決於 x 在當前做用域中的實際的定義。函數 bar 在運行時將 x=2 推入棧頂,從而使得 foo 返回 7.
Example 2:
var myVar = 100; function foo() { console.log(myVar); } foo(); // Static scope: 100; Dynamic scope: 100 (function () { var myVar = 50; foo(); // Static scope: 100; Dynamic scope: 50 })(); // Higher-order function (function (arg) { var myVar = 1500; arg(); // Static scope: 100; Dynamic scope: 1500 })(foo);
在 GitHub 上查看 rawstaticvsdynamic2.js
相似地,在以上動態做用域的例子中,變量 myVar 是經過被調用的函數中(動態定義)的 myVar 來解析的 ,而相對靜態做用域來講,myVar 解析爲在建立時即儲存於兩個當即調用函數(IIFE, Immediately Invoked Function Expression)的做用域中的變量。
能夠看到,動態做用域一般會致使一些歧義。它沒有明確自由變量會從哪一個做用域被解析。
閉包
你可能會認爲以上討論是題外話,但事實上,咱們已經覆蓋了須要用來理解閉包的全部(知識):
每一個函數都有一個執行上下文,它包括一個在函數中可以賦予變量含義的環境和一個對其父環境的引用。對父環境的引用使得它父環境中的全部變量能夠用於內部函數,不管內部函數是在建立它們(這些變量)的做用域之外仍是之內調用的。
所以,這看起來就像是函數會「記得」這個環境(或者說做用域),由於字面上來看函數可以引用環境(和環境中定義的變量)!
讓咱們回到這個嵌套結構的例子
在 GitHub 上查看 rawnesting2.js
var x = 10; function foo() { var y = 20; // free variable function bar() { var z = 15; // free variable return x + y + z; } return bar; } var test = foo(); test(); // 45
基於咱們對環境如何運做的理解,咱們能夠說,在上述例子中環境的定義看起來就像是如下代碼中這樣的(注意,這只是僞代碼而已):
GlobalEnvironment = { EnvironmentRecord: { // built-in identifiers Array: '<func>', Object: '<func>', // etc.. // custom identifiers x: 10 }, outer: null }; fooEnvironment = { EnvironmentRecord: { y: 20, bar: '<func>' } outer: GlobalEnvironment }; barEnvironment = { EnvironmentRecord: { z: 15 } outer: fooEnvironment };
在 GitHub 上查看 rawnestingEnv.js
當咱們調用函數test,咱們獲得的值是 45,它也是調用函數 bar 的返回值(由於 foo 返回函數 bar)。即便 foo 已經返回了值,可是 bar 仍然能夠訪問自由變量 y,由於 bar 經過外部環境引用 y,這個外部環境即 foo 的環境!bar 還能夠訪問全局變量 x,由於 foo 的環境通向全局環境。這叫作「做用域鏈查找」。
回到咱們關於動態做用域和靜態做用域的討論:爲了實現閉包,咱們不能經由一個動態的棧來儲存變量(不能使用動態做用域)。緣由是,這(使用動態做用域)意味着當一個函數返回時,變量將會從棧中彈出而且再也不可用——這與咱們最初定義的閉包相互矛盾。真正的狀況應該正相反,閉包中父上下文的數據儲存於「堆」(heap,一種數據結構)中,它容許數據在調用的函數返回(也就是在執行上下文在執行調用的棧中彈出)之後仍然可以保留。
明白了嗎?好的!既然咱們已經從抽象的層面理解了內在含義,讓咱們來多看幾個例子:
Example 1:
咱們在 for-loop 中試圖將其中的計數變量和其它函數關聯在一塊兒時的一個典型的例子/錯誤:
var result = []; for (var i = 0; i < 5; i++) { result[i] = function () { console.log(i); }; } result[0](); // 5, expected 0 result[1](); // 5, expected 1 result[2](); // 5, expected 2 result[3](); // 5, expected 3 result[4](); // 5, expected 4
在 GitHub 上查看 rawforloopwrong.js
回顧咱們剛剛學習的知識,就會超級容易看出這裏的錯誤!用僞代碼來分析,當 for-loop 存在時,它的環境看起來是這樣的:
environment: { EnvironmentRecord: { result: [...], i: 5 }, outer: null, }
在 GitHub 上查看 rawforloopwrongenv.js
這裏錯誤的假設就是,在結果(result)數列中,五個函數的做用域是不一樣的。事實上正相反,實際上五個函數的環境(上下文/做用域)所有相同。所以,每次變量i增長時,做用域都會更新——這個做用域被全部函數共享。這就是爲何這五個函數中的任意一個在訪問i時都返回 5(i 在 for-loop 存在時等於 5)。
一個解決辦法就是爲每一個函數建立一個額外的封閉環境,使得它們各自都有本身的執行上下文/做用域。
var result = []; for (var i = 0; i < 5; i++) { result[i] = (function inner(x) { // additional enclosing context return function() { console.log(x); } })(i); } result[0](); // 0, expected 0 result[1](); // 1, expected 1 result[2](); // 2, expected 2 result[3](); // 3, expected 3 result[4](); // 4, expected 4
在 GitHub 上查看 rawforloopcorrect.js
耶!這樣就改好了:)
另外,一個很是聰明的途徑就是使用 let 來代替 var,由於 let 聲明的是塊級做用域,所以每次 for-loop 的迭代都會建立一個新的標識符綁定。
var result = []; for (let i = 0; i < 5; i++) { result[i] = function () { console.log(i); }; } result[0](); // 0, expected 0 result[1](); // 1, expected 1 result[2](); // 2, expected 2 result[3](); // 3, expected 3 result[4](); // 4, expected 4
在 GitHub 上查看 rawforlooplet.js
(感嘆!)
Example 2:
這個例子展現了每調用一次函數就會建立一個新的單獨的閉包:
function iCantThinkOfAName(num, obj) { // This array variable, along with the 2 parameters passed in, // are 'captured' by the nested function 'doSomething' var array = [1, 2, 3]; function doSomething(i) { num += i; array.push(num); console.log('num: ' + num); console.log('array: ' + array); console.log('obj.value: ' + obj.value); } return doSomething; } var referenceObject = { value: 10 }; var foo = iCantThinkOfAName(2, referenceObject); // closure #1 var bar = iCantThinkOfAName(6, referenceObject); // closure #2 foo(2); /* num: 4 array: 1,2,3,4 obj.value: 10 */ bar(2); /* num: 8 array: 1,2,3,8 obj.value: 10 */ referenceObject.value++; foo(4); /* num: 8 array: 1,2,3,4,8 obj.value: 11 */ bar(4); /* num: 12 array: 1,2,3,8,12 obj.value: 11 */
在 GitHub 上查看 rawiCantThinkOfAName.js
在這個例子中,能夠看到每次調用函數 iCantThinkOfAName 都會建立一個新的閉包,叫作foo和bar。隨後對每一個閉包函數的調用更新了其中的變量,代表在 iCantThinkOfAName 返回之後的很長一段時間,每一個閉包中的變量仍可以繼續在iCantThinkOfAName 的 doSomething 函數中繼續使用。
Example 3:
function mysteriousCalculator(a, b) { var mysteriousVariable = 3; return { add: function() { var result = a + b + mysteriousVariable; return toFixedTwoPlaces(result); }, subtract: function() { var result = a - b - mysteriousVariable; return toFixedTwoPlaces(result); } } } function toFixedTwoPlaces(value) { return value.toFixed(2); } var myCalculator = mysteriousCalculator(10.01, 2.01); myCalculator.add() // 15.02 myCalculator.subtract() // 5.00
在 GitHub 上查看 rawmysteriousCalculator.js
能夠觀察到 mysteriousCalculator 在全局做用域中,而且它返回兩個函數。用僞代碼分析,以上例子的環境看起來是這個樣子的:
GlobalEnvironment = { EnvironmentRecord: { // built-in identifiers Array: '<func>', Object: '<func>', // etc... // custom identifiers mysteriousCalculator: '<func>', toFixedTwoPlaces: '<func>', }, outer: null, }; mysteriousCalculatorEnvironment = { EnvironmentRecord: { a: 10.01, b: 2.01, mysteriousVariable: 3, } outer: GlobalEnvironment, }; addEnvironment = { EnvironmentRecord: { result: 15.02 } outer: mysteriousCalculatorEnvironment, }; subtractEnvironment = { EnvironmentRecord: { result: 5.00 } outer: mysteriousCalculatorEnvironment, };
在 GitHub 上查看 rawmysteriousCalculatorEnv.js
由於咱們的 add 和 subtract 函數引用了 mysteriousCalculator 函數的環境,這兩個函數可以使用該環境中的變量來計算結果。
Example 4:
最後一個例子代表了閉包的一個很是重要的用途:保留外部做用域對一個變量的私有引用(僅經過惟一途徑例如某一個特定函數來訪問一個變量)。
function secretPassword() { var password = 'xh38sk'; return { guessPassword: function(guess) { if (guess === password) { return true; } else { return false; } } } } var passwordGame = secretPassword(); passwordGame.guessPassword('heyisthisit?'); // false passwordGame.guessPassword('xh38sk'); // true
在 GitHub 上查看 rawsecretPassword.js
這是一個很是強大的技術——它使閉包函數 guessPassword 能獨家訪問 password 變量,也保證了不能從外部(其餘途徑)訪問 password。
太長不想看?如下是本文摘要
執行上下文是由 ECMAScript 規範所使用的一個抽象的概念,它用於追蹤代碼的執行狀態。在任意時間點,只能有惟一一個執行上下文對應正在執行的代碼。
每一個執行上下文都有一個詞法環境。這個詞法環境保持着標識符的綁定(即變量和與其相關聯的變量),還能夠引用它的外部環境。
每一個環境可以訪問的標識符集叫作「做用域」。咱們能夠將這些做用域嵌套成爲一個分級的環境鏈——就是咱們所知的「做用域鏈」。
每一個函數都有一個執行上下文,它包括一個在函數中賦予變量含義的詞法環境和對其父環境的引用。由於函數對環境的引用,使它看起來就像是函數「記住了」這個環境(做用域)同樣。這就是一個閉包
每當一個封閉的外部函數被調用時都會建立一個閉包。換句話說,內部函數不須要爲了建立閉包而返回。
在 JavaScript 中,閉包是詞法相關的,意思是它在源代碼中由它的位置而被靜態地定義。
結語
但願這篇文章對你有必定幫助,而且能讓你在頭腦中造成一個關於 JavaScript 中閉包是如何實現的模型。能夠看到,理解它工做原理的細節能讓人更容易看懂閉包——更不用說這會讓咱們在debug的時候不那麼頭痛。
另外:人無完人,我也會犯一些錯誤——因此若是你發現其中的錯誤,請告知!
相關閱讀
爲簡短時間間,我省略了一些讀者可能會感興趣的話題。如下是我但願和你們分享的幾個連接:
什麼是執行上下文的變量環境?Axel Rauschmayer博士作了一些非凡的工做來解釋它。該連接是它的博文: http://www.2ality.com/2011/04/ecmascript-5-spec-lexicalenvironment.html
不一樣類型的環境記錄都有什麼?請在這裏閱讀: http://www.ecma-international.org/ecma-262/6.0/#sec-environment-records
MDN有關閉包的一篇很是好的文章:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures