JavaScript閉包,只學這篇就會了
昨天發的文章,排版出現了重大失誤。讓大家的眼睛受累了。今天再發一遍。 這篇文章使用一些簡單的程式碼例子來解釋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(); // 42oldLog() // 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
是個閉包,那就不會起作用(當然,你不會想在執行時裡進行原始碼的字串替換,除非...)。 - 在函式中定義多層函式是有可能的,這樣你就可以得到多個級別的閉包。