對於那些有一點 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
