閉包不是魔法
這篇文章使用一些簡單的代碼例子來解釋JavaScript閉包的概念,即使新手也可以輕松參透閉包的含義。
其實只要理解了核心概念,閉包並不是那么的難於理解。但是,網上充斥了太多學術性的文章,對於新手來說,看完這些文章可能會更加一頭霧水。
這篇文章面向的是使用主流開發語言的程序員,如果你能讀懂下面這段代碼,恭喜你,你可以開始JavaScript閉包的學習之旅了。
function sayHello(name) {
var text = 'Hello' + name;
var say = function() {
console.log(text);
}
say();
}
sayHello('Joe');
我相信你一定看懂了,那我們就開始吧!
閉包的一個例子
舉例之前,我們先用兩句話概括一下:
- 閉包是支持
一類函數
特性的一種方式(如果你還不知道什么是一類函數,請自行百度);它是一個表達式,這個表達式可以在其作用域(當它被初次定義時)內引用變量,或者被賦值給一個變量,或者被當做一個變量傳遞給某個函數,甚至被當作一個函數的執行結果被返回出去。 - 閉包也可以看作是某個函數被調用時分配的棧幀,而且當這個函數返回結果之后它也不會被回收(就好像它被分配給了堆,而不是棧)
下面的例子返回了對一個方法的引用:
function sayHello2(name){
var text= 'Hello' + name; //局部變量
var say=function(){
console.log(text);
}
return say;
}
var say2=sayHello2('Bob');
say2();//logs='Hello Bob'
我想大多數JavaScript程序員都能理解上面代碼中一個函數的引用是如何被賦值給一個變量(say2
)的。如果你不清楚的話,最好在繼續了解閉包之前弄清楚。使用C語言的程序員或許會認為這個函數是指向另一個函數的指針,並且變量say
和say2
也同樣是指向函數的指針。
然而C語言中指向函數的指針和JavaScript中對一個函數的引用有很大的不同。在JavaScript中,你可以把引用函數的變量當作同時擁有兩個指針:一個指向函數,另一個隱形地指向閉包。
上面的代碼中生成了一個閉包是因為匿名函數function(){console.log(text);}
被定義在了另外一個函數sayHello2()
中。在JavaScript中,如果你在一個函數中定義了另外一個函數,那么你就創建了一個閉包。
在C語言或者其他流行的開發語言當中,函數返回之后,所有局部變量都不能再被訪問,因為棧幀已經被銷毀了。
在JavaScript中,如果在一個函數中定義了另外一個函數,即使從被調用的函數中返回,局部變量依然能夠被訪問到。正如上面例子中我們在得到sayHello()
的返回值之后又調用了say2()
一樣。需要注意到,我們調用的代碼中引用了函數sayHello2()
中的局部變量text
。
function(){console.log(text);} //say2.toString()的輸出結果;
觀察say2.toString()
的輸出結果,我們會發現代碼指向變量text
。這個匿名函數能夠引用值為Hello Bob
的變量text
是因為sayHello2()
的局部變量被保留在了閉包中。
在JavaScript中神奇的地方在於引用一個函數的同時會有一個秘密的引用指向在這個函數內部創建的閉包,類似於委托一個方法指針加一個隱藏的對象引用。
更多例子
當你讀到很多關於閉包的文章時,總會感覺一頭霧水,但是當你看到一些應用的例子時,你就能清晰的理解閉包是如何工作的了。下面是我推薦的一些例子,希望大家能夠認真研究直到真正清楚閉包是如何工作的。如果在你沒有完全理解的情況下就開始使用閉包,你很快就會成為很多奇怪bug的創造者。
下面這個例子展示了局部變量不是被復制,而是被保留在了引用當中。這是當外部函數存在的情況下將棧幀保存在內存中的方法之一。
function say667(){
//處於閉包中的局部變量
var num=42;
var say=function(){console.log(num);}
num++;
return say;
}
var sayNumber=say667();
sayNumber();//logs 43
下面例子中的三個全局函數有對同一個閉包的共同引用,因為他們都在setupSomeGlobals()
中被定義。
var gLogNumber, gIncreaseNumber, gSetNumber;
function setupSomeGlobals() {
//處於閉包中的局部變量
var num = 42;
// 用全局變量存儲對函數的引用
gLogNumber = function() { console.log(num); }
gIncreaseNumber = function() { num++; }
gSetNumber = function(x) { num = x; }
}
setupSomeGlobals();
gIncreaseNumber();
gLogNumber(); // 43
gSetNumber(5);
gLogNumber(); // 5
var oldLog = gLogNumber;
setupSomeGlobals();
gLogNumber(); // 42
oldLog() // 5
當這三個函數被創建時,它們能夠共享對同一個閉包的訪問-即對setupSomeGlobals()
中的局部變量的訪問。
需要注意到在上述例子中,如果你再次調用setupSomeGlobals()
,會創建一個新的閉包。gLogNumber()
、gSetNumber()
和gLogNumber()
會被帶有新閉包的函數重寫(在JavaScript中,當在一個函數中定義另外一個函數時,重新調用外部函數會導致內部函數被重新創建)。
下面這個例子對很多人來說都難以理解,所以你更需要真正理解它。在循環中定義函數時要格外小心:閉包中的局部變量或許不會和你的預想的一樣。
function buildList(list) {
var result = [];
for (var i = 0; i < list.length; i++) {
var item = 'item' + i;
result.push( function() {console.log(item + ' ' + list[i])} );
}
return result;
}
function testList() {
var fnlist = buildList([1,2,3]);
for (var j = 0; j < fnlist.length; j++) {
fnlist[j]();
}
}
testList() //logs "item2 undefined" 3次
注意到result.push( function() {console.log(item + ' ' + list[i])}
向result
數組中插入了三次對匿名函數的引用。如果你對匿名函數不太熟悉,可以想象成下面的代碼:
pointer=function(){console.log(item+''+list[i])};
result.push(pointer);
需要注意到,當你運行上面的例子時,item2 undefined
被打印了三次!這是因為像前一個例子中提到的,buildList
的局部變量只有一個閉包。當在fnlist[j]()
中調用匿名函數時,它們用的都是同一個閉包,而且在這個閉包中使用了i
和item
的當前值(i
的值為3因為循環已經結束,item
的值為item2
)。因為我們從0開始計數所以item
的值為item2
,而i++
會使i
的值變為3
。
下面這個例子展示了閉包在退出之前包含了外部函數中定義的任何局部變量。注意到變量alice
其實是在匿名函數之后定義的。匿名函數先定義,但是當它被調用時它能夠訪問alice
,因為alice
和匿名函數處於同一作用域(JavaScript會進行變量提升)。sayAlice()()
只是直接調用了sayAlice()
返回的函數引用-但結果卻和之前一樣,只不過沒有臨時變量而已。
function sayAlice() {
var say = function() { console.log(alice); }
var alice = 'Hello Alice';
return say;
}
sayAlice()();// logs "Hello Alice"
注意到變量say
也在閉包中,能夠被任何在sayAlice()
中定義的函數訪問,或者在內部函數中被遞歸調用。
最后一個例子展現了每次調用都為局部變量創建一個獨立閉包。不是每個函數定義都會有一個閉包,而是每次函數調用產生一個閉包。
function newClosure(someNum, someRef) {
var num = someNum;
var anArray = [1,2,3];
var ref = someRef;
return function(x) {
num += x;
anArray.push(num);
console.log('num: ' + num +
'; anArray: ' + anArray.toString() +
'; ref.someVar: ' + ref.someVar + ';');
}
}
obj = {someVar: 4};
fn1 = newClosure(4, obj);
fn2 = newClosure(5, obj);
fn1(1); // num: 5; anArray: 1,2,3,5; ref.someVar: 4;
fn2(1); // num: 6; anArray: 1,2,3,6; ref.someVar: 4;
obj.someVar++;
fn1(2); // num: 7; anArray: 1,2,3,5,7; ref.someVar: 5;
fn2(2); // num: 8; anArray: 1,2,3,6,8; ref.someVar: 5;
總結
如果你對於閉包的概念依然不清晰,那么最好的方式就是運行一下上面的例子,看看會發生什么。讀懂一篇長篇大論要比理解一個例子難的多。我對與閉包和棧幀的解釋在技術上並不完全正確-而是為了幫助理解而簡化了。如果這些基本點都掌握之后,你就可以朝着更細微之處進發了。
最后總結幾點:
- 當你在一個函數中定義另外一個函數時,你就使用了閉包。
- 當你在函數中使用
eval()
時,你就使用了閉包。你在eval
中用到的文字可以指向外部函數的局部變量,而且在eval
中你也可以使用eval('val foo=...')
來創建局部變量。 - 當你在函數中使用
new Function(...)
時,不會創建一個閉包(這個新的函數不能引用外部函數的局部變量)。 - JavaScript中的閉包就好像保存了一份局部變量的備份,他們保持在函數退出時的狀態。
- 最好將閉包當作是一個函數的入口創建的,而局部變量是被添加進這個閉包的。
- 當一個帶有閉包的函數被調用時,總會保存一組新的局部變量。
- 兩個看似代碼相同的函數卻有不同的行為,是因為
隱藏的
閉包在作怪。我不認為JavaScript代碼能夠判斷出一個函數引用是否有閉包。 - 如果你嘗試做任何動態代碼的改動(例如:
myFunction = Function(myFunction.toString().replace(/Hello/,'Hola'));
),如果myFunction
是個閉包,那就不會起作用(當然,你不會想在運行時里進行源代碼的字符串替換,除非...)。 - 在函數中定義多層函數是有可能的,這樣你就可以得到多個級別的閉包。
- 我認為在通常情況下,閉包是函數及被捕獲的變量的術語,請注意在這篇文章里我沒有用到閉包的定義。