閉包詳解——通俗易懂
前言
說到閉包,實在是居家旅行破境渡劫攝魄迷魂必備良藥!不吃不知道,一吃哇哇叫,下面我們也去搞兩盒試試。
一、閉包是什麽
閉包,一個近乎神話的概念,從字面上理解感覺就像是一個比較封閉的東西,百度百科上的定義是:閉包就是能夠讀取其他函數內部變量的函數。
而我個人比較傾向於這麽理解:閉包就是一個封閉包裹了它所能使用的作用域的函數。
這樣看起來好像有點那個意思了,通俗的說就是:函數這個袋子把一些作用域裝起來了
,哪些作用域呢?這個函數作用域鏈上的作用域。
光說不寫假帥氣,下面來些例子瞧瞧:
1.1 函數傳遞
// 1.函數作為返回值 function foo() { var a = 2; function bar() { console.log( a ); } return bar; } var f = foo(); f(); // 2 這就是閉包的效果,或者說f即bar函數就是一個閉包,它把a所在的作用域包了起來,以便自己隨時使用
上面的例子是將函數作為值返回,下面我們換個方式試試(其實無論使用何種方式對函數類型的值進行傳遞,當函數在別處被調用時都可以觀察到閉包)。
// 2.函數作為參數傳遞
function foo() {
var a = 2;
function bar() {
console.log( a );
}
f(bar);
}
function f(fn) {
fn(); // 函數作為參數傳遞,也包裹了a的作用域,這也是閉包
}
foo(); // 2
// 3.間接傳遞函數 var fn; function foo() { var a = 2; function bar() { console.log( a ); } fn = bar; // 將bar分配給全局變量fn } function f() { fn(); // fn指向bar,bar包裹著a的作用域,這也是閉包 } foo(); f(); // 2
// 4.回調函數,傳遞給JS引擎調用
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait( "Hello" ); // ‘Hello‘
// 將一個內部函數timer傳遞給setTimeout,timer具有涵蓋wait作用域的閉包,因此還有對變量message的引用
其實,在定時器、事件監聽器、Ajax請求、跨窗口通信、Web Workers或者任何其他的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包。
所以無論通過何種手段將內部函數傳遞到所在的詞法作用域以外
持有對原始定義作用域的引用
(包裹),無論在何處執行這個函數都會使用閉包。
tip: 詞法作用域指由書寫代碼時變量所在的位置所決定的作用域。
1.2 IIFE
var a = 2;
(function IIFE() {
console.log(a);
})();
以上這個立即執行函數是閉包嗎?嗯,看起來應該是。
但嚴格來講它並不是閉包。為什麽?因為上面的函數並不是在它本身的詞法作用域以外執行的,它在定義時所在的作用域中執行,a是通過普通的詞法作用域查找而非閉包被發現的。
盡管IIFE本身並不是觀察閉包的恰當例子,但它的確創建了閉包,並且也是最常用來創建可以被封閉起來的閉包的工具,後面我們會講到。
1.3 循環與閉包
說到這個循環閉包的例子,可謂是如影隨形,惺惺相惜,讓猿欲罷不能。
for (var i=1; i<=5; i++) {
setTimeout(function timer() {
console.log(i);
}, i*1000);
}
這個想必大家夥就算沒吃過也見過這個豬是怎麽跑的:以每秒一次的頻率輸出五次6,而不是每秒一次一個的分別輸出1~5。
首先解釋6是從哪裏來的:這個循環的終止條件是i不再<=5,條件首次成立時i的值是6。因此,輸出顯示的是循環結束時i的最終值。
仔細想一下,這好像又是顯而易見的,延遲函數的回調會在循環結束時才執行。但事實上,當定時器運行時即使每個叠代中執行的是setTimeout(.., 0),所有的回調函數依然是在循環結束後才會被執行,因此會每次輸出一個6出來。
究竟是什麽原因導致這結果和我們預想的不一樣呢?
原因是我們試圖假設循環中的每個叠代在運行時都會給自己“捕獲”一個i的副本。但是根據作用域的工作原理,實際情況是盡管循環中的五個函數是在各個叠代中分別定義的,但是它們都被封閉在一個共享的全局作用域中,因此實際上只有一個i,所以都是在共享同一個i。
如何解決這個問題?
我們設想一下如果每次循環函數都能將屬於自己的i包裹起來,然後保存下來,那就需要閉包作用域,下面我們試試:
for (var i=1; i<=5; i++) {
(function() {
setTimeout(function timer() {
console.log( i );
}, i*1000);
})();
}
這樣行嗎?答案是不行。為什麽?上面的確創建了五個封閉的作用域,但大家有沒有註意到,但這個作用域是空的,它們並沒有將i包裹並存儲起來,我們依舊是引用外部的同一個全局i,所以這個封閉的作用域需要有自己的變量,用來在每個叠代中儲存i的值:
for (var i=1; i<=5; i++) {
(function() {
var j = i; // 將i的值存儲在閉包內
setTimeout(function timer() {
console.log(j);
}, j*1000);
})();
}
搞定!將timer傳遞給setTimeout,時間到後,JS引擎會調用timer函數,然後找到對應包裹起來的i,我們還可以再改進一下:
for (var i=1; i<=5; i++) {
(function(j) { // j參數也是屬於函數隱式聲明的變量
setTimeout(function timer() {
console.log(j);
}, j*1000);
})( i );
}
等等,解決這個問題的方法是每次叠代我們都需要一個塊作用域,那麽用let來生成塊作用域不就搞定了嗎?
for(let i=1; i<=5; i++) { // 使用let聲明i
setTimeout(function timer() {
console.log(i);
}, i*1000);
}
但let的作用不僅僅是生成塊作用域,for循環頭部的let聲明還會有一個特殊的行為:變量i在循環過程中不止被聲明一次,每次叠代都會聲明
,隨後的每個叠代都會使用上一個叠代結束時的值來初始化這個變量。
這種每次叠代重新聲明綁定的行為就類似這樣:
for (var i=1; i<=5; i++) {
let j = i; //每個叠代重新聲明j並將i的值綁定在這個塊作用域內
setTimeout( function timer() {
console.log(j);
}, j*1000);
}
這樣一路看下來,感覺閉包好像也不是那麽神秘嘛,我個人理解的話會把以上歸納為:只要發生了函數傳遞與調用,就會產生閉包。
好了,了解了閉包是什麽,那下面來看看它有什麽用途。
二、閉包的應用
2.1 模塊
閉包最大的作用莫過於創建模塊
了:
function betterModule() {
var name = ‘BetterMan‘;
var arr = [1, 2, 3];
function getName() {
console.log(name);
}
function joinArr() {
console.log(arr.join(‘-‘));
}
return {
getName: getName,
joinArr: joinArr
}
}
var foo = betterModule();
foo.getName(); // ‘BetterMan‘
foo.joinArr(); // ‘1-2-3‘
以上就是一個利用閉包來創建的模塊,我們來理一理這段代碼:
首先,betterModule()
只是一個函數,必須要通過調用它來創建一個模塊實例。如果不執行外部函數,內部作用域和閉包都無法被創建。
其次,betterModule()
返回一個用對象字面量語法{key: value, ...}來表示的對象,這個返回的對象中含有對內部函數而不是內部數據變量的引用
,保持了內部數據變量是隱藏且私有的狀態,可以將這個對象類型的返回值看作本質上是模塊的公共API。
這個對象類型的返回值最終被賦值給外部的變量foo
,然後就可以通過它來訪問API中的屬性,如foo.joinArr()
。
tip: 從模塊中返回一個實際的對象並不是必須的,也可以直接返回一個內部函數。
jQuery
就是如此,jQuery
和$
標識符就是jQuery
模塊的公共API,但它們本身都是函數(由於函數也是對象,它們本身也可以擁有屬性)。
以上的betterModule
函數可以被調用任意多次,每次調用都會創建一個新的模塊實例;但如果我們只需要一個實例時,可以對這個模式進行簡單的改進來實現單例模式
:
var foo = (function betterModule() {
var name = ‘BetterMan‘;
var arr = [1, 2, 3];
function getName() {
console.log(name);
}
function joinArr() {
console.log(arr.join(‘-‘));
}
return {
getName: getName,
joinArr: joinArr
}
})();
我們將模塊函數轉換成了IIFE,立即調用這個函數並將返回值直接賦值給單例的模塊實例foo。
2.2 柯裏化
柯裏化也用到了閉包,聽起來有點高大上,那什麽是柯裏化呢?
柯裏化
(Currying)是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,並且返回接受余下的參數且返回結果的新函數的技術,看起來是不是有點繞,下面看看例子:
function add(a, b, c) {
return a + b + c;
}
console.log(add(1,2,3)); // 6
function newAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
}
}
}
console.log(newAdd(1)(2)(3)); // 6
看著例子對照著定義,看起來描述得還是挺貼切的嘛,其實上面也是利用了閉包的功能綁定了參數的作用域,使得每次調用函數時可以訪問上次所傳入的參數。
三、閉包的註意事項
通常,函數的作用域及其所有變量都會在函數執行結束後被銷毀。但是,在創建了一個閉包以後,這個函數的作用域就會一直保存到閉包不存在為止,因為閉包就是一個函數引用另外一個函數的變量,因為變量被引用著所以不會被回收。這是優點也是缺點,不必要的閉包只會徒增內存消耗,所以我們在使用的時候需要註意這方面。
function add(x) {
return function(y) {
return x + y;
};
}
var add3 = add(3);
var add5 = add(5);
console.log(add3(2)); // 5
console.log(add5(5)); // 10
// 需要手動釋放對閉包的引用
add3 = null;
add5 = null;
以上的add3
和add5
都是閉包,它們共享相同的函數定義,但是保存了不同的環境。在add3
的環境中,x為3。而在add5中,x則為5,最後我們通過null手動釋放了add3
和add5
對閉包的引用。
最後
如果到了這裏你恍然大悟:原來在我的代碼中已經到處都是閉包了,只是平時沒註意到而已!那說明我這藥方還是有點效果的,如果真的如此,那就來波點贊
與關註
吧,因為你的支持就是我最大的動力!
GitHub傳送門
博客園傳送門
閉包詳解——通俗易懂