標籤:
JavaScript內部原理系列-閉包(Closures)概要
本文將介紹一個在JavaScript經常會拿來討論的話題 —— 閉包(closure)。 閉包其實已經是個老生常談的話題了; 有大量文章都介紹過閉包的內容, 儘管如此,這裡還是要試著從理論角度來討論下閉包, 看看ECMAScript中的閉包內部究竟是如何工作的。
概論
在討論ECMAScript閉包之前,先來介紹下函數式編程(與ECMA-262-3 標準無關)中一些基本定義。 然而,為了更好的解釋這些定義,這裡還是拿ECMAScript來舉例。
眾所周知,在函數式語言中(ECMAScript也支援這種風格),函數即是資料。就比方說,函數可以儲存在變數中,可以當參數傳遞給其他函數,還可以當傳回值返回等等。 這類函數有特殊的名字和結構。
定義
函數式參數(“Funarg”) —— 是指值為函數的參數。
如下例子:
function exampleFunc(funArg) { funArg(); } exampleFunc(function () { alert(‘funArg‘); });
上述例子中funArg的實參是一個傳遞給exampleFunc的匿名函數。
反過來,接受函數式參數的函數稱為 高階函數(high-order function 簡稱:HOF)。還可以稱作:函數式函數 或者 偏數理的叫法:操作符函數。 上述例子中,exampleFunc 就是這樣的函數。
此前提到的,函數不僅可以作為參數,還可以作為傳回值。這類以函數為傳回值的函數稱為 _帶函數值的函數(functions with functional value or function valued functions)。
(function functionValued() { return function () { alert(‘returned function is called‘); }; })()();//這種()直接執行的方式要熟悉。
可以以正常資料形式存在的函數(比方說:當參數傳遞,接受函數式參數或者以函數值返回)都稱作 第一類函數(一般說第一類對象)。 在ECMAScript中,所有的函數都是第一類對象。
接受自己作為參數的函數,稱為 自應用函數(auto-applicative function 或者 self-applicative function):
(function selfApplicative(funArg) { if (funArg && funArg === selfApplicative) { alert(‘self-applicative‘); return; } selfApplicative(selfApplicative); })();
以自己為傳回值的函數稱為 自複製函數(auto-replicative function 或者 self-replicative function)。 通常,“自複製”這個詞用在文學作品中:
(function selfReplicative() { return selfReplicative; })();
在函數式參數中定義的變數,在“funArg”啟用時就能夠訪問了(因為儲存上下文資料的變數對象每次在進入內容相關的時候就建立出來了):
function testFn(funArg) { // 啟用funArg, 本地變數localVar可訪問 funArg(10); // 20 funArg(20); // 30 } testFn(function (arg) { var localVar = 10; alert(arg + localVar); });
然而,我們知道,在ECMAScript中,函數是可以封裝在父函數中的,並可以使用父函數內容相關的變數。 這個特性會引發 funArg問題。
FunArg問題
在面向堆棧的程式設計語言中,函數的本地變數都是儲存在堆棧上的, 每當函數啟用的時候,這些變數和函數參數都會壓棧到該堆棧上。
當函數返回的時候,這些參數又會從堆棧中移除。這種模型對將函數作為函數式值使用的時候有很大的限制(比方說,作為傳回值從父函數中返回)。 絕大部分情況下,問題會出現在當函數有 自由變數的時候。
自由變數是指在函數中使用的,但既不是函數參數也不是函數的局部變數的變數
如下所示:
function testFn() { var localVar = 10; function innerFn(innerParam) { alert(innerParam + localVar); } return innerFn; } var someFn = testFn(); someFn(20); // 30
上述例子中,對於innerFn函數來說,localVar就屬於自由變數。
對於採用 面向堆棧模型來儲存局部變數的系統而言,就意味著當testFn函數調用結束後,其局部變數都會從堆棧中移除。 這樣一來,當從外部對innerFn進行函數調用的時候,就會發生錯誤(因為localVar變數已經不存在了)。
而且,上述例子在 面向堆棧實現模型中,要想將innerFn以傳回值返回根本是不可能的。 因為它也是testFn函數的局部變數,也會隨著testFn的返回而移除。
還有一個函數對象問題和當系統採用動態範圍,函數作為函數參數使用的時候有關。
看如下例子:
var z = 10; function foo() { alert(z); } foo(); // 10 – 靜態範圍和動態範圍情況下都是 (function () { var z = 20; foo(); // 10 – 靜態範圍情況下, 20 – 動態範圍情況下 })(); // 將foo函數以參數傳遞情況也是一樣的 (function (funArg) { var z = 30; funArg(); // 10 – 靜態範圍情況下, 30 – 動態範圍情況下 })(foo);
我們看到,採用動態範圍,變數(標識符)處理是通過動態堆棧來管理的。 因此,自由變數是在當前活躍的動態鏈中查詢的,而不是在函數建立的時候儲存起來的靜態範圍鏈中查詢的。
這樣就會產生衝突。比方說,即使Z仍然存在(與之前從堆棧中移除變數的例子相反),還是會有這樣一個問題: 在不同的函數調用中,Z的值到底取哪個呢(從哪個上下文,哪個範圍中查詢)?
上述描述的就是兩類 funArg問題 —— 取決於是否將函數以傳回值返回(第一類問題)以及是否將函數當函數參數使用(第二類問題)。
為瞭解決上述問題,就引入了 閉包的概念。下面重點來了,閉包的面紗就此揭開。
閉包
閉包是代碼塊和建立該代碼塊的上下文中資料的結合。
讓我們來看下面這個例子:
var x = 20; function foo() { alert(x); // 自由變數 "x" == 20 } // foo的閉包 fooClosure = { call: foo // 對函數的引用 lexicalEnvironment: {x: 20} // 查詢自由變數的上下文 };
上述例子中,“fooClosure”部分是虛擬碼。對應的,在ECMAScript中,“foo”函數已經有了一個內部屬性——建立該函數內容相關的範圍鏈。
這裡“lexical”是不言而喻的,通常是省略的。上述例子中是為了強調在閉包建立的同時,內容相關的資料就會儲存起來。 當下次調用該函數的時候,自由變數就可以在儲存的(閉包)上下文中找到了,正如上述代碼所示,變數“z”的值總是10。
定義中我們使用的比較廣義的詞 —— “代碼塊”,然而,通常(在ECMAScript中)會使用我們經常用到的函數。 當然了,並不是所有對閉包的實現都會將閉包和函數綁在一起,比方說,在Ruby語言中,閉包就有可能是: 一個程式對象(procedure object), 一個lambda運算式或者是代碼塊。
對於要實現將局部變數在上下文銷毀後仍然儲存下來,基於堆棧的實現顯然是不適用的(因為與基於堆棧的結構相矛盾)。 因此在這種情況下,上層範圍的閉包資料是通過 動態分配記憶體的方式來實現的(基於“堆”的實現),配合使用記憶體回收行程(garbage collector簡稱GC)和 引用計數(reference counting)。 這種實現方式比基於堆棧的實現效能要低,然而,任何一種實現總是可以最佳化的: 可以分析函數是否使用了自由變數,函數式參數或者函數式值,然後根據情況來決定 —— 是將資料存放在堆棧中還是堆中。
ECMAScript閉包的實現
討論完理論部分,接下來讓我們來介紹下ECMAScript中閉包究竟是如何?的。 這裡還是有必要再次強調下:ECMAScript只使用靜態(詞法)範圍(而諸如Perl這樣的語言,既可以使用靜態範圍也可以使用動態範圍進行變數聲明)。
var x = 10; function foo() { alert(x); } (function (funArg) { var x = 20; // funArg的變數 "x" 是靜態儲存的,在該函數建立的時候就儲存了 funArg(); // 10, 而不是 20 })(foo);
從技術角度來說,建立該函數的上層內容相關的資料是儲存在函數的內部屬性[[Scope]]中的。如果你對[[Scope]]和範圍鏈的知識完全理解了的話,那對閉包也就完全理解了。
根據函數建立的演算法,我們看到 在ECMAScript中,所有的函數都是閉包,因為它們都是在建立的時候就儲存了上層內容相關的範圍鏈(除開異常的情況) (不管這個函數後續是否會啟用 —— [[Scope]]在函數建立的時候就有了):
var x = 10; function foo() { alert(x); } // foo is a closure foo: FunctionObject = { [[Call]]: code block of foo, [[Scope]]: [ global: { x: 10 } ], ... // other properties };
正如此前提到過的,出於最佳化的目的,當函數不使用自由變數的時候,實現層可能就不會儲存上層範圍鏈。 然而,ECMAScript-262-3標準中並未對此作任何說明;因此,嚴格來說 —— 所有函數都會在建立的時候將上層範圍鏈儲存在[[Scope]]中。
“萬能”的[[Scope]]
這裡不得不說JavaScript的範圍鏈
這裡還要注意的是:在ECMAScript中,同一個上下文中建立的閉包是共用一個[[Scope]]屬性的。 也就是說,某個閉包對其中的變數做修改會影響到其他閉包對其變數的讀取:
var firstClosure; var secondClosure; function foo() { var x = 1; firstClosure = function () { return ++x; }; secondClosure = function () { return --x; }; x = 2; // 對AO["x"]產生了影響, 其值在兩個閉包的[[Scope]]中 alert(firstClosure()); // 3, 通過 firstClosure.[[Scope]] } foo(); alert(firstClosure()); // 4 alert(secondClosure()); // 3
正因為這個特性,很多人都會犯一個非常常見的錯誤: 當在迴圈中建立了函數,然後將迴圈的索引值和每個函數綁定的時候,通常得到的結果不是預期的(預期是希望每個函數都能夠擷取各自對應的索引值)。這個錯誤也是題目中說提到的那段代碼的最大錯誤的地方,下面我們來揭秘為啥button點擊彈出來的都是5.
var data = []; for (var k = 0; k < 3; k++) { data[k] = function () { alert(k); }; } data[0](); // 3, 而不是 0 data[1](); // 3, 而不是 1 data[2](); // 3, 而不是 2
上述例子就證明了 —— 同一個上下文中建立的閉包是共用一個[[Scope]]屬性的。因此上層上下文中的變數“k”是可以很容易就被改變的。
如下所示:
activeContext.Scope = [ ... // higher variable objects {data: [...], k: 3} // activation object ]; data[0].[[Scope]] === Scope; data[1].[[Scope]] === Scope; data[2].[[Scope]] === Scope;
這樣一來,在函數啟用的時候,最終使用到的k就已經變成了3了。
如下所示,建立一個額外的閉包就可以解決這個問題了:
var data = []; for (var k = 0; k < 3; k++) { data[k] = (function _helper(x) { return function () { alert(x); }; })(k); // 將 "k" 值傳遞進去 } // 現在就對了 data[0](); // 0 data[1](); // 1 data[2](); // 2
上述例子中,函數“_helper”建立出來之後,通過參數“k”啟用。其傳回值也是個函數,該函數儲存在對應的數組元素中。 這種技術產生了如下效果: 在函數啟用時,每次“_helper”都會建立一個新的變數對象,其中含有參數“x”,“x”的值就是傳遞進來的“k”的值。 這樣一來,返回的函數的[[Scope]]就成了如下所示:
data[0].[[Scope]] === [ ... // 更上層的變數對象 上層內容相關的AO: {data: [...], k: 3}, _helper內容相關的AO: {x: 0} ]; data[1].[[Scope]] === [ ... // 更上層的變數對象 上層內容相關的AO: {data: [...], k: 3}, _helper內容相關的AO: {x: 1} ]; data[2].[[Scope]] === [ ... // 更上層的變數對象 上層內容相關的AO: {data: [...], k: 3}, _helper內容相關的AO: {x: 2} ];
我們看到,這個時候函數的[[Scope]]屬性就有了真正想要的值了,為了達到這樣的目的,我們不得不在[[Scope]]中建立額外的變數對象。 要注意的是,在返回的函數中,如果要擷取“k”的值,那麼該值還是會是3。
順便提下,大量介紹JavaScript的文章都認為只有額外建立的函數才是閉包,這種說法是錯誤的(我也差點有這個錯誤的認識,再次感謝作者指出。)。 實踐得出,這種方式是最有效,然而,從理論角度來說,在ECMAScript中所有的函數都是閉包。
然而,上述提到的方法並不是唯一的方法。通過其他方式也可以獲得正確的“k”的值,如下所示:
var data = []; for (var k = 0; k < 3; k++) { (data[k] = function () { alert(arguments.callee.x); }).x = k; // 將“k”儲存為函數的一個屬性 } // 同樣也是可行的 data[0](); // 0 data[1](); // 1 data[2](); // 2
然而,arguments.callee從ECMAScript (ES5)中移除了,所以這個方法瞭解下就行了。
FunArg和return
另外一個特性是從閉包中返回。在ECMAScript中,閉包中的返回語句會將控制流程返回給調用上下文(調用者)。 而在其他語言中,比如,Ruby,有很多中形式的閉包,相應的處理閉包返回也都不同,下面幾種方式都是可能的:可能直接返回給調用者,或者在某些情況下——直接從上下文退出。
ECMAScript標準的退出行為如下:
function getElement() { [1, 2, 3].forEach(function (element) { if (element % 2 == 0) { // 返回給函數"forEach", // 而不會從getElement函數返回 alert(‘found: ‘ + element); // found: 2 return element; } }); return null; } alert(getElement()); // null, 而不是 2
然而,在ECMAScript中通過try catch可以實現如下效果(這個方法簡直太棒了!):
var $break = {}; function getElement() { try { [1, 2, 3].forEach(function (element) { if (element % 2 == 0) { // 直接從getElement"返回" alert(‘found: ‘ + element); // found: 2 $break.data = element; throw $break; } }); } catch (e) { if (e == $break) { return $break.data; } } return null; } alert(getElement()); // 2
理論版本
通常,程式員會錯誤的認為,只有匿名函數才是閉包。其實並非如此,正如我們所看到的 —— 正是因為範圍鏈,使得所有的函數都是閉包(與函數類型無關: 匿名函數,FE,NFE,FD都是閉包), 這裡只有一類函數除外,那就是通過Function構造器建立的函數,因為其[[Scope]]只包含全域對象。 為了更好的澄清該問題,我們對ECMAScript中的閉包作兩個定義(即兩種閉包):
ECMAScript中,閉包指的是:
- 從理論角度:所有的函數。因為它們都在建立的時候就將上層內容相關的資料儲存起來了。哪怕是簡單的全域變數也是如此,因為函數中訪問全域變數就相當於是在訪問自由變數,這個時候使用最外層的範圍。
- 從實踐角度:以下函數才算是閉包:
- 即使建立它的上下文已經銷毀,它仍然存在(比如,內建函式從父函數中返回)
- 在代碼中引用了自由變數
閉包實踐
實際使用的時候,閉包可以建立出非常優雅的設計,允許對funArg上定義的多種計算方式進行定製。 如下就是數組排序的例子,它接受一個排序條件函數作為參數:
[1, 2, 3].sort(function (a, b) { ... // 排序條件 });
同樣的例子還有,數組的map方法(並非所有的實現都支援數組map方法,SpiderMonkey從1.6版本開始有支援),該方法根據函數中定義的條件將原數組映射到一個新的數組中:
[1, 2, 3].map(function (element) { return element * 2; }); // [2, 4, 6]
使用函數式參數,可以很方便的實現一個搜尋方法,並且可以支援無窮多的搜尋條件:
someCollection.find(function (element) { return element.someProperty == ‘searchCondition‘; });
還有應用函數,比如常見的forEach方法,將funArg應用到每個數組元素:
[1, 2, 3].forEach(function (element) { if (element % 2 != 0) { alert(element); } }); // 1, 3
順便提下,函數對象的 apply 和 call方法,在函數式編程中也可以用作應用函數。這裡,我們將它們看作是應用函數 —— 應用到參數中的函數(在apply中是參數列表,在call中是獨立的參數):
(function () { alert([].join.call(arguments, ‘;‘)); // 1;2;3 }).apply(this, [1, 2, 3]);
閉包還有另外一個非常重要的應用 —— 延遲調用:
var a = 10; setTimeout(function () { alert(a); // 10, 一秒鐘後 }, 1000);
也可以用於回呼函數:
... var x = 10; // only for example xmlHttpRequestObject.onreadystatechange = function () { // 當資料就緒的時候,才會調用; // 這裡,不論是在哪個上下文中建立,變數“x”的值已經存在了 alert(x); // 10 }; ..
還可以用於封裝範圍來隱藏輔助對象:
var foo = {}; // initialization (function (object) { var x = 10; object.getX = function _getX() { return x; }; })(foo); alert(foo.getX()); // get closured "x" – 10
幾個閉包範例
感謝JavaScript Closures For Dummies(鏡像)
例子1:閉包中局部變數是引用而非拷貝
function say667() { // Local variable that ends up within closure var num = 666; var sayAlert = function() { alert(num); } num++; return sayAlert; } var sayAlert = say667(); sayAlert()
因此執行結果應該彈出的667而非666。
例子2:多個函數綁定同一個閉包,因為他們定義在同一個函數內。
function setupSomeGlobals() { // Local variable that ends up within closure var num = 666; // Store some references to functions as global variables gAlertNumber = function() { alert(num); } gIncreaseNumber = function() { num++; } gSetNumber = function(x) { num = x; } } setupSomeGlobals(); // 為三個全域變數賦值 gAlertNumber(); //666 gIncreaseNumber(); gAlertNumber(); // 667 gSetNumber(12); // gAlertNumber(); //12
例子3:當在一個迴圈中賦值函數時,這些函數將綁定同樣的閉包
function buildList(list) { var result = []; for (var i = 0; i < list.length; i++) { var item = ‘item‘ + list[i]; result.push( function() {alert(item + ‘ ‘ + list[i])} ); } return result; } function testList() { var fnList = buildList([1,2,3]); // using j only to help prevent confusion - could use i for (var j = 0; j < fnList.length; j++) { fnList[j](); } }
testList的執行結果是彈出item3 undefined視窗三次,因為這三個函數綁定了同一個閉包,而且item的值為最後計算的結果,但是當i跳出迴圈時i值為4,所以list[4]的結果為undefined.(再具體原因,前面有解釋過。)
例子4:外部函數所有局部變數都在閉包內,即使這個變數聲明在內建函式定義之後。
function sayAlice() { var sayAlert = function() { alert(alice); } // Local variable that ends up within closure var alice = ‘Hello Alice‘; return sayAlert; } var helloAlice=sayAlice(); helloAlice();
執行結果是彈出”Hello Alice”的視窗。即使局部變數聲明在函數sayAlert之後,局部變數仍然可以被訪問到。
例子5:每次函數調用的時候建立一個新的閉包
function newClosure(someNum, someRef) { // Local variables that end up within closure var num = someNum; var anArray = [1,2,3]; var ref = someRef; return function(x) { num += x; anArray.push(num); alert(‘num: ‘ + num + ‘\nanArray ‘ + anArray.toString() + ‘\nref.someVar ‘ + ref.someVar); } } closure1=newClosure(40,{someVar:‘closure 1‘}); closure2=newClosure(1000,{someVar:‘closure 2‘}); closure1(5); // num:45 anArray[1,2,3,45] ref:‘someVar closure1‘ closure2(-10);// num:990 anArray[1,2,3,990] ref:‘someVar closure2‘
Singleton 單件:
var singleton = function () { var privateVariable; function privateFunction(x) { ...privateVariable... } return { firstMethod: function (a, b) { ...privateVariable... }, secondMethod: function (c) { ...privateFunction()... } };
這個單件通過閉包來實現。通過閉包完成了私人的成員和方法的封裝。匿名主函數返回一個對象。對象包含了兩個方法,方法1可以方法私人變數,方法2訪 問內部私人函數。需要注意的地方是匿名主函數結束的地方的’()’,如果沒有這個’()’就不能產生單件。因為匿名函數只能返回了唯一的對象,而且不能被 其他地方調用。這個就是利用閉包產生單件的方法。
JavaScript閉包!