本文主要參考MDN手冊和Learning Advanced JavaScript
在文章開頭玩徊,我先放出MDN給出的定義:
閉包是指那些能夠訪問獨立(自由)變量的函數(shù) (變量在本地使用恩袱,但定義在一個封閉的作用域中)。換句話說潭辈,這些函數(shù)可以“記憶”它被創(chuàng)建時候的環(huán)境澈吨。
現(xiàn)在不需要看懂它,我會在第一個例子中解釋清楚它的意思修赞。讓我們開始吧桑阶!
2018.3.20更新:現(xiàn)在MDN上的定義已經(jīng)改為:"A closure is the combination of a function and the lexical environment within which that function was declared."
要理解函數(shù)閉包勾邦,就要先知道這兩條特性:
- 函數(shù)外部的代碼無法訪問函數(shù)體內(nèi)部的變量割择,而函數(shù)體內(nèi)部的代碼可以訪問函數(shù)外部的變量锨推。
- 即使函數(shù)已經(jīng)執(zhí)行完畢,在執(zhí)行期間創(chuàng)建的變量也不會被銷毀椎椰,因此每運行一次函數(shù)就會在內(nèi)存中留下一組變量沾鳄。(js當然會有垃圾回收機制,不過如果它發(fā)現(xiàn)你正在使用閉包译荞,則不會清理可能會用到的變量)
使用閉包能產(chǎn)生類似于對象的一組變量集合,看個例子:
function outter() {
var private= "I am private";
function show() {
console.log(private);
}
return show;
}
var ref = outter();
// console.log(private); // 嘗試直接訪問private會報錯:private is not defined
ref(); // 打印I am private
我們調(diào)用了一次outter函數(shù)圈膏,產(chǎn)生了一組變量:private和show篙骡。要不是我們在outter最后一句返回了show,這兩個變量就永遠沒辦法被訪問到了(因為函數(shù)外部的代碼無法訪問函數(shù)體內(nèi)部的變量)尿褪。但是我們現(xiàn)在返回了show得湘,并且ref是show的引用,這樣我們就可以在函數(shù)體外部調(diào)用show了摆马,而show又可以訪問到private鸿吆。
這不就像是一個C++或JAVA中的對象嗎?private就是它的私有成員,嘗試從外部直接訪問它就會收到報錯黎泣;show就是它的公有成員,所以我們可以在外部訪問到它褐着,并且它可以訪問私有成員name托呕。使用閉包能產(chǎn)生類似于對象的一組變量集合。
現(xiàn)在我們對照著這個例子來理解閉包的定義:
閉包是指那些能夠訪問獨立(自由)變量的函數(shù) (變量在本地使用馅扣,但定義在一個封閉的作用域中)着降。換句話說,這些函數(shù)可以“記憶”它被創(chuàng)建時候的環(huán)境蓄喇。
我初學的時候犯了一個錯誤交掏,就是認為outter是閉包函數(shù)(因為我以為將整個閉包結(jié)構(gòu)“包”起來的函數(shù)就是閉包函數(shù)),但其實根據(jù)定義钱骂,被返回的show才是閉包函數(shù)熊尉,也就是那個可以在外部訪問“私有成員”的函數(shù)。
- 定義中的“獨立(自由)變量”其實就是我們剛才說的私有成員张吉,它們是獨立(自由)的催植,是因為定義它的函數(shù)已經(jīng)死了(執(zhí)行完畢)创南!
- “變量在本地使用,但定義在一個封閉的作用域中”的意思是稿辙,自由變量可以在閉包函數(shù)中使用,但是自由變量并不是在閉包中定義的赋咽。
- “閉包函數(shù)可以“記憶”它被創(chuàng)建時候的環(huán)境”的意思是脓匿,outter執(zhí)行的過程產(chǎn)生了一組變量,這些變量就是show被聲明時候的環(huán)境陪毡。show可以記住這個環(huán)境(變量private)毡琉,即使show離開了outter(被return到外部),它依然記得如何訪問這個環(huán)境里的變量精拟。
讓我們再看一個例子
function makeAdder(x) {
return function(y) {
return x + y;
};
}
var add5 = makeAdder(5);
var add10 = makeAdder(10);
console.log(add5(2)); // 7
console.log(add10(2)); // 12
這一次虱歪,makeAdder創(chuàng)建了一個閉包結(jié)構(gòu)笋鄙,傳入的參數(shù)x就是執(zhí)行期間創(chuàng)建的臨時變量,它就相當于是私有成員(自由變量)萧落。而公有成員是一個匿名函數(shù)找岖,這個函數(shù)接受一個參數(shù)y,并將這個參數(shù)與閉包的私有成員x相加兴革,返回結(jié)果蜜唾。
這個例子有意思的地方在于:makeAdder調(diào)用了兩次!每運行一次makeAdder就會在內(nèi)存中產(chǎn)生一組變量(也就是一個“環(huán)境”)擎勘,每一個“環(huán)境”雖然結(jié)構(gòu)相同颖榜,都有私有成員x和公有成員函數(shù)煤裙,但是這兩個“環(huán)境”是互不干涉的积暖。在這個例子中怪与,第一個環(huán)境中x=5缅疟,第二個環(huán)境中x=10。
利用閉包的特性耘斩,可以實現(xiàn)模塊模式桅咆。用一個閉包函數(shù)包裹模塊的代碼岩饼,將不需要暴露的變量隱藏起來(好處是不會污染全局變量空間),將別人要調(diào)用的方法return出去籍茧,就可以實現(xiàn)模塊化了寞冯。實際上Node.js就是這么做的,看我的另一篇文章俭茧。
經(jīng)典面試題
讓我們再看一個常見的錯誤:在循環(huán)中創(chuàng)建閉包
...
<ol>
<li>第一項</li>
<li>第二項</li>
<li>第三項</li>
<li>第四項</li>
</ol>
...
window.onload = function() { // 函數(shù)1
var lis = document.getElementsByTagName('li');
for (var i = 0; i < lis.length; i++) {
lis[i].onclick = function() { // 函數(shù)2
alert(i);
}
}
}
不管我們點擊哪一個li元素漓帚,都會顯示3胰默,而不是分別顯示0到3的數(shù)字因块。這是為什么呢?
這是因為,函數(shù)2被聲明了4次沒錯渣淤,但它們是在同一個環(huán)境中被聲明的(都是在執(zhí)行函數(shù)1的環(huán)境)!因此這四個函數(shù)2“記住”的是同一個i扁耐!當我們點擊li元素時产阱,循環(huán)早已完成,i停在了3王暗。因此庄敛,我們不管點擊哪一個li元素,總是會顯示3绷雏。
既然我們已經(jīng)知道了錯誤的原因怖亭,那么修改的思路也很明確了:四個onclick函數(shù)必須有自己“聲明環(huán)境”依许!既然要產(chǎn)生4個“環(huán)境”,那么就說明必須有一個函數(shù)在for循環(huán)內(nèi)運行膘婶,總共運行4次蛀醉,每次的環(huán)境中都有一個變量private_i,分別等于0脊岳、1垛玻、2帚桩、3。
修改后:
...
<ol>
<li>第一項</li>
<li>第二項</li>
<li>第三項</li>
<li>第四項</li>
</ol>
...
window.onload = function() { // 函數(shù)1
var lis = document.getElementsByTagName('li');
for (var i = 0; i < lis.length; i++) {
lis[i].onclick = (function(private_i) { // 函數(shù)2
return function() { // 函數(shù)3
alert(private_i);
}
})(i); // 這里將i作為參數(shù)莫瞬,調(diào)用函數(shù)2
}
}
注意賦值給onclick的并不是函數(shù)2,而是函數(shù)2的執(zhí)行結(jié)果喂江,也就是函數(shù)3旁振。函數(shù)3內(nèi)的private_i是函數(shù)2調(diào)用時所產(chǎn)生的拐袜,而函數(shù)2總共調(diào)用了4次,為4個函數(shù)3都分別留下了一個“環(huán)境”private_i,四個private_i分別是0沮尿、1畜疾、2、3姥敛。因此瞎暑,點擊4個li元素就會顯示出4個不同的數(shù)字了。
不要隨便在函數(shù)中創(chuàng)建函數(shù)
除非明確你知道你自己需要使用閉包墨榄,否則勿她,不要在函數(shù)中創(chuàng)建另一個函數(shù),這樣會造成速度和性能的浪費之剧。
看一個MDN上的例子:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}
var obj1 = new MyObject("name1", "message1");
var obj2 = new MyObject("name2", "message2");
每次執(zhí)行MyObject背稼,都會在內(nèi)存中創(chuàng)建出兩個函數(shù)辩恼,每次創(chuàng)建的getName函數(shù)都是一樣的,getMessage函數(shù)也是一樣疆前,造成了不必要的浪費竹椒。
實際上,如果我們要讓對象都有一樣的方法书释,只需要在它們的prototype上定義這個方法就行了:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
MyObject.prototype.getName = function() {
return this.name;
};
MyObject.prototype.getMessage = function() {
return this.message;
};
var obj1 = new MyObject("name1", "message1");
var obj2 = new MyObject("name2", "message2");
這樣赊窥,函數(shù)只創(chuàng)建了一次锨能,而obj1和obj2都能繼承到getName和getMessage方法。
有關(guān)原型繼承的細節(jié)熄阻,請閱讀我的另一篇文章《徹底理解js的原型鏈》
閉包函數(shù)究竟是怎么“記住”創(chuàng)建時期的環(huán)境的秃殉?如果你對其原理感到好奇的話钾军,可以看看我的這兩篇文章:
js的執(zhí)行上下文乒省,以及其中的變量對象
徹底理解js的作用域鏈