閉包原來並沒有那麼難!一文帶你深入理解JavaScript的閉包
對於那些有一點 JavaScript 使用經驗但從未真正理解閉包概念的人來說,理解閉包可以看作是某種意義上的重生,但是需要付出非常多的努力和犧牲才能理解這個概念。
——《你不知道的JavaScript》
在JavaScript中的”神獸“,很多小夥伴會覺得閉包這玩意太噁心了,怎麼著都理解不了...其實剛接觸JavaScript的時候我也是這樣。
但是!!!閉包真的非常重要!非常重要!非常重要!重要的事情說三遍!!!
接下來,我會帶著大家真正意義上的理解閉包。
一 、閉包概念描述
《JavaScript權威指南》這樣描述:
函式物件可以通過作用域鏈相互關聯起來,函式體內部的變數都可以儲存在函式作用域內,這就是叫閉包。
《你不知道的JavaScript》這樣描述:
閉包是基於詞法作用域書寫程式碼時所產生的自然結果。
當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函式是在當前詞法作用域之外執行。
從以上的描述。要真正理解閉包概念,要先深刻理解以下幾個知識點,可以稱為閉包前置知識點
二 、閉包前置知識點
1、作用域
《你不知道的JavaScript》這樣描述:
作用域可以理解為一套規則,來定義變數儲存在哪裡,使用的時候怎麼找到他們。
作用域是負責收集並維護由變數組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的程式碼對變數的訪問許可權。
而我是這麼理解的:作用域就是一個獨立的物件,裡面儲存了變數,在物件中定義了一系列的規則,來限制外部訪問裡面的變數,來區分變數讓不同作用域下同名變數不會有衝突。
如下圖所示,紅框區域就是一個作用域
2、作用域鏈
2.1 概念
作用域鏈可以理解為一個全域性物件。在不包含巢狀的函式體,作用域鏈上有兩個物件,第一個定義函式引數和區域性變數的物件,第二個是全域性物件。在一個巢狀的函式體內,作用域鏈上至少有三個物件。
舉個栗子,如圖所示這是不包含巢狀的函式體的作用域鏈
舉個栗子,如圖所示這是包含巢狀的函式體的作用域鏈
2.2 使用規則
當一個塊或函式巢狀在另一個塊或函式中時,就發生了作用域的巢狀。因此,在當前作用域中無法找到某個變數時,就會在外層巢狀的作用域中繼續查詢,直到找到該變數,或抵達最外層的作用域(也就是全域性作用域)為止。
舉個栗子,如圖所示
比如要在作用域3中查詢變數a的值,但是發現作用域3中沒有變數a,就去作用域2中找,發現了變數a就停止了查詢。
注意:在查詢過程中不會跑去作用域4中查詢。因為作用域4不是作用域3的外層巢狀作用域。
2.3 建立規則
理解作用域鏈的建立規則對理解閉包是非常重要的
首先我們定義一個函式的時候,開始就建立並儲存了一條作用域鏈,裡面包含一個全域性作用域物件,當函式被呼叫時,會建立一個新物件(作用域)來儲存它的變數,並將這個物件新增到開始建立的作用域鏈上,同時建立一條新的表示呼叫函式的作用域的“鏈”。
仔細琢磨一下下面程式碼,就可以理解。
function foo(a){
let b = a*3;
function bar (c){
console.log(a,b,c)
}
bar(b*2)
}
foo(2);//2,6,12
foo(3);//3,9,18
3、詞法作用域
詞法作用域是作用域的一個工作模型。
詞法作用域就是定義在詞法階段的作用域。換句話說,詞法作用域是由你寫程式碼時將變數和塊作用域寫在哪裡來決定的。
舉個栗子,如下圖我把let b = a *3 寫在foo(){...}這個函式作用域中,那麼變數b的作用域就是foo(){...}這個函式作用域。
三、解釋閉包下面以一個非常典型的閉包例子來解釋閉包。
function foo(){
let a = 2;
function bao(){
console.log(a)
}
return bao
}
let bar=foo();
bar();
上面程式碼中,閉包是哪個,是foo(){...},還是bao(){...}。用Chrome斷點除錯一下就知道。
閉包是foo(){...}這個函式,再看一下電腦科學文獻是怎麼定義閉包的。
這個術語非常古老,是指函式中的變數可以被隱藏在作用域之內,因此看起來是函式將變數“包裹”起來。
上面foo(){...}將變數a隱藏在它的作用域內,從程式碼上看把變數a包含在函式內。
看到這裡你也許會這麼想,為什麼下面的函式pyh不是閉包,它也把變數b包含在函式內。
function pyh(){
let b = 2;
console.log(b)
}
再讀一下《JavaScript權威指南》中怎麼表述閉包
函式物件可以通過作用域鏈相互關聯起來,函式體內部的變數都可以儲存在函式作用域內,這就是叫閉包。
注意上面說函式內的變數可以儲存在函式作用域內,那麼pyh函式內的變數b可以儲存在pyh(){...}這個函式作用域內。
顯然不能的,因為pyh函式執行後,pyh(){...}這個作用域會被銷燬,自然變數b就不存在,不會被儲存。
瀏覽器垃圾回收策略,規定如果一個物件沒有被引用,會被當垃圾一樣回收並銷燬。
那怎麼不讓pyh(){...}作用域不會銷燬,很簡單,作用域也是個物件,讓它被引用,就不會被銷燬,這樣作用域中的變數b自然也得以儲存,這樣就實現閉包。
怎麼讓它被引用?可以通過作用域鏈來實現。正如上面所說函式物件可以通過作用域鏈相互關聯起來。
對pyh函式進行改造一下
function pyh(){
let b = 2;
function bao(){
console.log(b)
}
bao();
}
我們用作用域的建立規則來講解一下pyh(){...}作用域怎麼被引用。
pyh函式定義時建立了一條作用域鏈A,呼叫時將pyh(){...}作用域新增到作用域鏈A上,bao函式定義時建立了一條作用域鏈B,呼叫時將bao(){...}作用域新增到作用域鏈B,同時建立一條表示函式呼叫作用域的“鏈”,將作用域鏈A和作用域鏈B連在一起,相當pyh(){...}作用域嵌套了bao(){...}作用域。
這時候,bao函式中的console.log(b)執行時會去尋找變數b,發現bao(){...}作用域中沒有,就會根據作用域鏈的使用規則去pyh(){...}作用域中尋找,找到後使用其中的變數b,這就對pyh(){...}作用域進行引用,導致pyh函式呼叫後pyh(){...}作用域本來是會被銷燬,但是它被bao函式引用了,導致無法銷燬得以儲存,自然作用域中的變數b也得以儲存,這時pyh函式就變成了閉包。
當然pyh函式不是一個完整的閉包,它只運用到閉包規則的一部分,這部分是閉包規則的核心,非常重要。
返回最上面那個典型的閉包例子foo函式,給大家解釋一下foo函式怎麼形成閉包。
在foo函式外部定義變數bar來儲存foo函式返回的結果。foo函式定義時建立一條作用域鏈A,呼叫時foo(){...}作用域被新增到作用域鏈A,bao函式定義時建立了一條作用域鏈B,呼叫時將bao(){...}作用域新增到作用域鏈B,同時建立一條表示函式呼叫作用域的“鏈”,將作用域鏈A和作用域鏈B連在一起,相當foo(){...}作用域嵌套了bao(){...}作用域。
在foo函式執行完畢時候,返回bao函式,並賦值到外部變數bar上,當執行bar();時,相當呼叫bao函式,bao函式中的console.log(a)執行時會去尋找變數a,發現bao(){...}作用域中沒有,就會根據作用域鏈的使用規則去foo(){...}作用域中尋找,找到後使用其中的變數a,這就對foo(){...}作用域進行引用,導致foo函式呼叫後foo(){...}作用域本來是會被銷燬,但是它被bao函式引用了,導致無法銷燬得以儲存。
大家注意了,bao函式呼叫後bao(){...}作用域會被銷燬,這時候foo(){...}作用域的引用就會消失,也會被銷燬。但是,但是foo函式返回值是bao函式,被外部變數bar引用了,被賦值給外部變數bar,這就導致foo(){...}作用域是無法銷燬,那麼作用域foo(){...}中的變數a就可以得以儲存。這是foo函式就形成了一個閉包,foo函式把變數a包裹起來。
理解閉包過程中,要切記一點,函式呼叫結束後,在函式定義時建立的作用域鏈式不會馬上消失的。
四、再次解釋閉包
上面是通過作用域鏈來解釋閉包,大家看起來是不是雲裡霧裡的。其實閉包沒那麼神祕,難以理解。
在《你不知道的JavaScript》中寫的特別好。
JavaScript中閉包無處不在,你只需要能夠識別並擁抱它,閉包是基於詞法作用域書寫程式碼時所產生的自然結果。
當函式可以記住並訪問所在的詞法作用域時,就產生了閉包,即使函式是在當前詞法作用域之外執行。
還是以一個非常典型的閉包例子來解釋閉包。
function foo(){
let a = 2;
function bao(){
console.log(a)
}
return bao
}
let bar=foo();
bar();
首先,我們很清楚知道bao函式的內部作用域3能夠訪問bao函式的詞法作用域2。
然後bao函式被當作foo函式的返回值。在foo函式執行後,其返回值(也就是內部的bao函式)賦值給變數bar並呼叫bar(),實際上是呼叫了內部的bao函式。這時bao函式在自己定義的詞法作用域2以外的地方(作用域3)執行。
在foo函式執行後,其內部作用域2通常會被銷燬,因為瀏覽器垃圾回收器會將不被引用的物件回收銷燬。 事實上foo函式的內部作用域2依然存在,沒有被回收。誰在引用這個內部作用域2?是bao函式中變數a在引用。
當變數bar被實際呼叫(呼叫內部bao函式),它可以訪問bao函式定義時的詞法作用域2,因此它可以訪問變數a。這時bao函式在定義時的詞法作用域2以外的地方被呼叫。仍然可以繼續訪問定義時的詞法作用域。 這就是閉包。
按照《你不知道的JavaScript》中描述閉包可以這樣描述:
當bao函式可以記住並訪問所在的詞法作用域2時,就產生了閉包(foo函式),bao函式不在詞法作用域2中被呼叫仍然可以訪問詞法作用域2。
這樣描述閉包是不是清楚了很多,不要特意去想如何實現閉包,閉包就是基於詞法作用域書寫程式碼時所產生的自然結果。
五、閉包的應用
1、私有化全域性變數
說起閉包的作用,我不禁想起我第一次接觸閉包的場景。那時在做個輪播圖,需要一變數來儲存點選按鈕的次數,當時想都沒想就在全域性這麼寫
var prevCount = 0;
var nextCount = 0;
在後面稽核程式碼時候,就挨訓了,經理就問我一句話,如果其它地方的變數名跟這一樣,那怎麼辦?你用閉包把這段程式碼重新改造一下。
接著就去看閉包,結果看的雲裡霧裡的,只能再問經理。經理隨口就說用立即執行函式。
<div id="prev">上一張</div>
<script>
(function() {
var prevCount = 0;
var nextCount = 0;
function prev() {
//輪播的程式碼
prevCount++;
console.log(prevCount)
}
$('#prev').click(prev)
})()
</script>
這樣變數prevCount和變數nextCount就變成wheel私有的。
2、外部訪問函式內部變數
眾所周知,外部是訪問不到函式內部的變數。
function foo(){
let a ='我是foo函式內部的變數a'
}
console.log(a);//Uncaught ReferenceError: a is not defined
那麼怎麼在外部訪問到foo函式內部的變數a,閉包帶你實現。
function foo(){
let a ='我是foo函式內部的變數a'
function bao(){
return a
}
return bao
}
let b=foo();
console.log(b());//我是foo函式內部的變數a
也許你會覺得這個沒必要用的閉包,這樣就行
function foo(){
let a ='我是foo函式內部的變數a'
return a
}
let b=foo();
console.log(b);//我是foo函式內部的變數a
那麼如果你要修改foo函式內部的變數a呢?
function foo(){
let a ='我是foo函式內部的變數a'
function bao(c){
a = c
return a
}
return bao
}
let b=foo();
console.log(b('我修改了foo函式內部的變數a'));//我修改了foo函式內部的變數a
3、構建私有作用域
一個很經典的例子,就是for迴圈中閉包應用。
var arr=[]
for(var i = 0; i<10;i++){
arr[i]=function(){
console.log(i)
}
}
arr[6]()
上面arr[6]()輸出的是10,而不是6,那麼要怎麼做才輸出6。
在塊級作用域出現前,我們使用閉包構建私有作用域解決。
var arr=[]
for(var i = 0; i<10;i++){
(function(i){
arr[i]=function(){
console.log(i)
}
})(i)
}
arr[6]()
4、模組輸出
function module() {
let n = 0;
function get(){
console.log(n)
}
function set(){
n++;
console.log(n)
}
return {
get:get,
set:set
}
}
let a = module();
let b = module();
a.get();//0
a.set();//1
b.get();//0
a和b都用於自己的私有作用域,互不影響
六、閉包的副作用
1、在函式中使用定時器,形成閉包,導致記憶體洩露
function foo(){
var a =1
setInterval(function(){
console.log(a)
},2000)
}
foo()
以上在foo函式中使用了定時器,是foo函式成為閉包,本來foo函式執行後變數a會被回收銷燬,但是定時器中呼叫函式有引用到變數a,導致變數a無法被銷燬一直存在記憶體中。應該使用個外部變數賦值定時器,以便停止。
let timer = null;
function foo(){
var a =1
timer=setInterval(function(){
console.log(a)
},2000)
}
foo();
clearInterval(timer)
2、閉包返回被外部變數引用,導致記憶體洩露
function foo() {
var a = 1
function bao() {
console.log(a)
}
return bao
}
let bar = foo();
bar();
bar = null;
以上在foo函式中返回了bao函式,foo函式又把執行結果,賦值給變數bar,執行bar(),即是執行函式bao,而函式bao對變數a有引用,導致foo函式執行後變數a,不能釋放,導致記憶體洩露。
可以通過將bar = null,斷開變數bar對變數a的引用,釋放變數a。
如果想要更高效、更系統地學會javascript,最好採用邊學邊練的學習模式。
如果覺得javascript的學習難度較高,不易理解,建議採用視訊的方式進行學習,推薦一套看過講的很不錯的視訊教程,可點選以下連結觀看:
https://www.bilibili.com/video/BV1Ft411N7R3