深入學習JavaScript之閉包
接下來需要對之前我們學的作用域原理來一個清晰的認識。
此例中,外部函式指的是包括了內部函式的函式
1.1 閉包的是什麼?
閉包是基於詞法作用域書寫程式碼時所產生的自然結果,閉包是一個晦澀難懂的概念。它準確點來說是,函式和建立該函式的詞法作用域的組合,這個環境包括了這個閉包建立時所能訪問的所有區域性變數。
下面我們通過幾個例子來理解閉包的概念。
function foo() { var a=2; function bar() { console.log(a); } return bar(); } var baz=foo(); baz; //2
在此函式中,我們定義了一個函式foo(),它有一個區域性變數a,以及一個隱藏(區域性)函式bar(),此函式的返回值為隱藏函式
我們在全域性作用域中建立了一個物件,讓它引用foo(),在return的作用下,它會間接的引用bar(),這樣在外部我們也能夠使用內部函式,打破了之前說的函式作用域呼叫規則-----------內部函式在自己定義的詞法域之外執行
在foo()函式執行完畢後,按道理要被引擎當做垃圾回收,但是事實並沒有,因為在呼叫foo()函式完畢後,還要呼叫內部函式bar(),bar()引用foo()中的函式a,所以在foo()函式呼叫結束後,bar()還需要foo(),所以bar()產生的閉包(包括了bar()函式以及所產生建立的區域性變數等)阻止了引擎對foo()的回收-----------------bar()保持著對foo()的引用,這個引用就是閉包
這個函式在定義時的詞法域以外的地方被呼叫,閉包使得它可以繼續訪問定義時的詞法作用域
我們可以這樣理解:
在大倉庫中有若干個小倉庫以及貨物,公司派人來採購物品(函式呼叫)需要用到小倉庫裡面的貨物,於是大倉庫通知小倉庫管理員(內部函式)與採購人員在外進行協商(函式在定義時的詞法域以外被呼叫),這時小倉庫管理員明確表示採購用到的商品一部分在大倉庫中(內部函式對外部函式中變數的引用),於是大倉庫管理員也被迫留在此進行協商(阻止內部函式引用的外部函式銷燬),達成協議(閉包)。所以閉包更像是一種協議,內部函式與引用的外部函式以及引擎之間的協議。
從上我們可以看出形成閉包必要的特徵
- 內部函式發生呼叫
- 內部函式引用外部函式的變數
- 內部函式在定義以外的詞法作用域執行
無論使用何種方式對函式的值進行傳遞,當函式在別處被呼叫時總能觀察到閉包
例如
function foo() {
var a=2;
function baz() {
console.log(a);
}
bar(baz);
}
function bar(fn) {
fn();
}
foo();
把內部函式baz傳給bar,當呼叫這個內部函式(fn)時,它涵蓋的foo(),內部作用域的閉包就可以觀察到了,因為它會輸出a。
間接的傳遞函式也是可以的
var fn;
function foo() {
var a=2;
function bar() {
console.log(a);
}
fn=bar(); //傳遞值給全域性變數fn
}
function baz() {
fn();
}
foo();
baz();
在foo(),中我們將內部函式bar()賦值給了全域性變數fn,在另一個函式中引用了fn,在這裡面引用的是fn,實際上引用的卻是bar(),因而會出現閉包-------------間接調用出現閉包。
無論通過何種手段將內部函式傳遞到所在的詞法作用域以外,它都會持有對原始定義作用域的引用,無論在何處執行這個函式都會使用閉包。
在我們通常寫程式碼時經常有接觸到閉包。只是你以前並沒有發現而已。比如我們經常使用的JavaScript內建函式工具。
function wait(message) {
setTimeout(function timer() {
console.log(message);
},1000);
};
wait("Hello Word");
setTimeout函式是JavaScript中的延時處理函式,它的宣告位置並不在wait函式中。但是它在wait()中,呼叫了內部函式timer()。timer()擁有包括整個wait()的閉包。因此能夠引用wait()中的message。
我們現在來討論一下,引擎實現此程式碼的原理。內建的setTimeout(...)持有對一個引數的引用,這個引數沒有固定的名稱,在這裡它的名稱是 timer 。引擎會在wait(...)執行完畢後再開始處理setTimeout(....)函式,而詞法作用域只在閉包的作用下保持完整---------這就是閉包。
本質上無論何時何地,如果將函式(訪問它們各自的詞法作用域)當作第一級的值型別併到處傳遞,你就會看到閉包在這些函式中的應用,。在定時器,監聽器、Ajax請求、跨視窗通訊、Web Workers或者任何其他的非同步或者同步任務中,只要使用了回撥函式,實際上就是在使用閉包
總結:閉包在函式呼叫時發生,無論函式是如何呼叫,只要它在自己的詞法作用域外發生了呼叫,且它本身引用了外部函式的變數,那麼在閉包的作用下,外部函式不會被銷燬,它還能訪問之前的詞法作用域------如此便達到了,在全域性作用域下,使用外部函式的變數和資料
1.2 迴圈中的閉包
在for迴圈中配合著JavaScript工具函式會生成閉包。這樣恰好說明了JavaScript中的工具函式執行是在其宣告處。
for(var i=1;i<=5;i++){
setTimeout(function foo() {
console.log(i);
},i*1000);
};
單單按照以前學的知識來看,這裡會輸出1-5的數字,每次出現的時間是當前數字乘以1000毫秒。
但是,我們現在學了閉包,採用閉包理解的話,你就不這麼認為了。結果是,會輸出5次6。
為什麼會這樣呢???
首先得解釋6從哪裡來,這個迴圈的終止條件是i不在<=5,條件首次成立時 i 的值是6。因此,輸出顯示的是迴圈結束時 i 的最終值。
仔細想想的話,確實是這麼一回事,延遲函式的回撥會在迴圈結束的時執行。事實上,當定時器執行時即使每個迭代中執行的是setTimeout(....,0),所有的回撥函式依然是在迴圈結束後才會被執行,因此每次輸出一個6出來。
我們還要理解setTimeout(...)函式,它是非同步執行的,什麼是非同步執行的呢?就是當主程式執行完畢以後才會執行,JavaScript是單執行緒執行的,每一次的迴圈都得等到for(....)中的i迴圈完畢了以後,setTimeout(...)才會開始執行。
那麼為什麼會列印5次呢???
答案是在JavaScript中維護著一個setTimeout(...)佇列,在第一次i=1時,便啟動了setTimeout(....),但是它必須等待for(...)執行完畢才會執行,所以會加入setTimeout(...)佇列中,當i=2時,又執行了新的setTimeout(...)佇列,於是又加入了setTimeout(....)佇列中,一直到 i=6 ,此時有五個setTimeout(....)等待執行。更因為這些setTimeout(...)共用的是一個 i 所以當它們準備輸出結果時,i早已變成6。因此輸出五個6
值得一提的是在setTimeout(...,0);中的數字可以改變優先順序,數字越小優先順序越高執行越早。
那麼我們想要讓之前的程式碼執行起來每一次都會setTimeout(....)都會輸出當前的 i 值。
接下來進行思考,既然setTimeout(....)是得當for(...)迴圈結束了以後才執行的,那麼我們能否用IIFE立即執行函式表示式讓它立即執行呢?
根據之前學習的作用域,儘管五個函式是分別在五個迭代中定義的,但是,他們使用的卻是共享的i,所以我們需要五個函式都有自己的閉包作用域這樣才能記錄下 i 的值。
使用IIFE剛好可以建立一個全新的詞法作用域。
for(var i=1;i<=5;i++)
{
(function IIFE() {
setTimeout(function foo() {
console.log(i);
},i*1000);
})();
};
測試一下
依舊輸出5次6,那麼是什麼原因呢?
IIFE建立了新的作用域,但是此作用域中沒有任何實質內容,我們在裡面建立一個變數記錄下 i 的值。
for(var i=1;i<=5;i++)
{
(function IIFE() {
var j=i;
setTimeout(function foo() {
console.log(i);
},i*1000);
})();
};
輸出結果:
接下來根據我們之前學習的IIFE的作用之一,括號傳遞引數,來對上面式子進行改進。
for(var i=1;i<=5;i++)
{
(function IIFE(j) {
setTimeout(function foo() {
console.log(j);
},j*1000);
})(i); //括號傳遞引數
};
在迭代內使用IIFE會為每一個迭代生成一個全新的作用域,使得延遲函式的回撥,可以將新的作用域封閉在每個迭代中,每個迭代中將會有一個正確的變數供我們使用。
總結:在迴圈中,呼叫了JavaScript的工具函式,因為JavaScript是單執行緒語言,工具函式就會進入待執行棧,等到for函式執行完畢之後,再執行。在for中每一次迴圈迭代都會引用一次工具函式,所以也就輸出多個相同的結果。並且它們在每一次迭代中被呼叫一次,但是它們所用的 i 卻是共享的。
所以我們需要每次迭代時都有一個自己的閉包,能都記錄下這個i值供我們輸出
如何解決呢?將工具函式放入IIFE中,記錄下每次變化的i值(j=i),工具函式對記錄下的i值進行呼叫。
1.3 塊作用域與閉包
仔細思考我們對前面的解決方案的分析。我們使用IIFE在每次迭代時都建立一個新的作用域,目的就是為了讓setTimeout(...)佇列不再使用同一個共享變數 i 。換句話說,每一次迴圈迭代我們都需要一個塊作用域。那麼我們能否用 "let" 關鍵字來為每一次迭代建立一個新的塊作用域呢?
for(var i=1;i<=5;i++)
{
let j=i;
setTimeout(function foo() {
console.log(j);
},j*1000);
};
輸出結果
事實證明這是可行的,但是在JavaScript中還有更酷的行為
for(let i=1;i<=5;i++)
{
setTimeout(function foo() {
console.log(i);
},i*1000);
};
使用 let 關鍵字在for(...)中建立變數有一個特點,每次迭代都將重新宣告一次變數,且此變數的值將是上一次迭代迴圈的值。那麼我們使用 let 便可實現
1.4 模組
模組與閉包息息相關,它是程式碼編寫的一種模式。
接下來我們將學習模組
考慮以下程式碼:
function foo(){
var something="cool";
var another=[1,2,3];
function dosomething(){
console.log(something);
}
function another(){
console.log(another.join("!"));
};
}
正如這段程式碼所示的,這裡面,只有兩個內部變數dosomething、another。兩個內部函式dosomething(...)、another(...)。它們的詞法作用域(閉包)就是foo函式的內部作用域。
接下來考慮以下程式碼
function CoolModule() {
var something="cool";
var another=[1,2,3];
function dosomething() {
console.log(something);
};
function doanother() {
console.log(another.join("!"));
};
return{
dosomething: dosomething,
doanother: doanother
}
}
var foo=CoolMudule();
foo.dosomething(); //cool
foo.doanother(); //1!2!3
這個模式在JavaScript叫做模組,最常見的實現模組模式的方法叫做模組暴露。
接下來對這段程式碼進行分析,CoolModule是一個函式,必須要通過呼叫它來建立一個模組例項。如果不執行外部函式,那麼內部作用域和閉包都無法實現。
細心點你或許會注意到在CoolModule函式中的返回值型別不同,這是字面量語法(key:value.......)來表示物件,當呼叫dosomething時呼叫dosomething,當呼叫doanother時呼叫doanother。
這個返回物件中含有對內部函式而不是內部變數資料的引用。我們保持這個內部資料變數是隱藏且私有的狀態,可以將這個物件的返回值看成是模組共有的API。要用哪種功能,獲取這個物件,然後呼叫函式就對了。
這個物件型別的返回值最終被賦給外部變數foo,然後就可以通過訪問foo來訪問API中的內容和方法了。比如:foo.dosomething();
注意在模組中返回一個物件{ return object}不是必須的,你也可以返回一個內部函式,我們經常使用的 jquery 就是這樣。jquery和$符是 jquery 的API,但是它們都是 jquery 的內部函式,因為函式也是物件,所以它們擁有屬性。
dosomething()以及doanother()都具有涵蓋模組例項內部作用域的閉包,不過要注意,在此例中是一定要呼叫CoolModule(....)函式,當返回一個含有屬性引用(呼叫外部函式的屬性,此例中dosomething引用something屬性,doanother引用another屬性)的物件的方式來將函式傳遞到詞法作用域外部時,我們已經創造了閉包。
模組建立的條件
-
必須有外部的封閉函式,且該外部函式至少呼叫一次(每呼叫一次都會建立一次模組例項)
-
外部封閉函式的返回值必須是一個內部函式或者內部函式的物件,且內部函式有對外部函式的私有變數的引用(如此才能形成涵蓋外部函式的閉包)
一個帶有函式屬性的物件不一定是模組,它還得形成涵蓋自己作用域的閉包才行。
1.4.1 單例模式
在上一個我們編寫的CoolModule(...)中,我們每呼叫一次CoolModule(...)就會建立一個例項,如果我們只需要呼叫一次CoolModule(...)時,就可以用單例模組模式。
建立單例模組模式非常簡單
- 外部封閉函式在IIFE中編寫
- IIFE賦值建立模組例項物件
- 呼叫內部函式時,物件.內部函式
var foo= ( function CoolMudule() {
var something="cool";
var another=[1,2,3];
function dosomething() {
console.log(something);
};
function doanother() {
console.log(another.join("!"));
};
return{
dosomething: dosomething,
doanother: doanother
}
})();
foo.dosomething();
foo.doanother();
我們將外部函式轉換成了IIFE,並且建立了外部函式的例項物件foo。當我們需要呼叫內部函式時,只需要物件.內部函式即可。
1.4.2 傳遞引數的模組
模組知識形式特殊的函式,所以也具有一般函式的特性,比如傳遞引數
function CoolModule(id) {
function identify() {
console.log(id);
}
return{
identify: identify()
};
}
var foo=CoolModule("boot1");
var foo1=CoolModule("boot2");
foo.identify();
foo1.identify();
像在此例中,我們需要建立兩個例項,因此就不需要用到單例模組模式。
1.4.3 通過例項更改公共API的方法
模組模式一個強大的用法是,通過例項更改公共API內部的函式
var foo=(function CoolModule(id) {
var publicAPI={
change:change,
identify:identify1
}
function change() { //改變publicAPI中的屬性 identify的值,使它等於identify2
publicAPI.identify=identify2;
}
function identify1() { //輸出ID
console.log(id);
}
function identify2() { //改變ID為大寫
console.log(id.toUpperCase());
}
return publicAPI;
})("foo module")
foo.identify(); //foo module
foo.change();
foo.identify(); //FOO MODULE
我們首先 var 了一個變數publicAPI,這個變數中有兩個型別屬性,一個是change,另一個是identify。CoolModule(...)中有三個內部函式,一個是change,它的作用是改變publicAPI中 identify 的值使它的值變為 identify2;一個是 identify1 輸出id的值,還有一個是 identify2 輸出id的大寫值。
這裡面我們用到了傳遞引數、單例模式。
我們通過例項呼叫內部函式,更改了公共API的方法使之兩個相同的函式,輸出不一樣的結果。
通過在模組例項的內部保留對公共API物件的內部引用,可以從內部對模組例項方法或者屬性進行修改,包括新增、刪除、命名以及重新賦值等等。
總結: 閉包並不是一個晦澀難懂的概念,只要是內部函式被包含自己的外部封閉函式以外的物件呼叫並且它還保持著對原來詞法域的引用,那麼就產生了閉包。
在for迴圈中建立函式,那麼很容易因為閉包而出現問題,原因是:在for中建立函式,這裡的函式並不會執行、而是宣告,等到呼叫時或者說是主程式完成時才開始執行,而這時 i 值已經為結束迴圈的值了,每一次迴圈,每一次迭代時都會建立一個函式,這些函式沒有閉包自己的作用域,共用一個i值,所以輸出為相同的 i。
解決的方法主要是為每一個函式建立一個閉包。方法有
-
將建立的函式放入IIFE中(讓它立刻執行),並記錄下每一次迴圈的i值
-
利用 let 建立迴圈變數,let 在迴圈中每一次迭代都會建立新的變數,這個變數的值是上一個變數的值,所以如此一來,這些函式就不會共用一個迴圈變數 i 了
模組:不暴露私有的資料和函式下,外部函式的返回值為內部函式或者物件。當想要呼叫該外部封閉函式的方法時,建立變數並且賦值,再呼叫方法。
模組特徵:
-
為建立內部作用域而呼叫了一個包裝函式(外部封閉函式)
-
包裝函式的返回值至少包括一個對內部函式的引用,這樣就會建立涵蓋整個包裝函式內部作用域的閉包。