[閉包]該如何理解?

NO IMAGE

[閉包]該如何理解?

前言

說到閉包,實在是居家旅行破境渡劫攝魄迷魂必備良藥!不吃不知道,一吃哇哇叫,下面我們也去搞兩盒試試。

一、閉包是什麼

閉包,一個近乎神話的概念,從字面上理解感覺就像是一個比較封閉的東西,百度百科上的定義是:閉包就是能夠讀取其他函數內部變量的函數。

而我個人比較傾向於這麼理解:閉包就是一個封閉包裹了它所能使用的作用域的函數。

這樣看起來好像有點那個意思了,通俗的說就是:函數這個袋子把一些作用域裝起來了,哪些作用域呢?這個函數作用域鏈上的作用域。

光說不寫假帥氣,下面來些例子瞧瞧:

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;

以上的add3add5都是閉包,它們共享相同的函數定義,但是保存了不同的環境。在add3的環境中,x為3。而在add5中,x則為5,最後我們通過null手動釋放了add3add5對閉包的引用。

最後

如果到了這裡你恍然大悟:原來在我的代碼中已經到處都是閉包了,只是平時沒注意到而已!那說明我這藥方還是有點效果的,如果真的如此,那就來波點贊關注吧,因為你的支持就是我最大的動力!

GitHub傳送門
博客園傳送門

[閉包]該如何理解?

相關文章

理理Vue細節

ES6讀書筆記(三)

ES6讀書筆記(二)

ES6讀書筆記(一)