1. 程式人生 > 其它 >閉包原來並沒有那麼難!一文帶你深入理解JavaScript的閉包

閉包原來並沒有那麼難!一文帶你深入理解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