進擊的 JavaScript(四) 之 閉包

周大俠啊發表於2018-07-13

原文連結:周大俠啊 進擊的 JavaScript(四) 之 閉包

上一節說了執行上下文,這節我們們就乘勝追擊來搞搞閉包!頭疼的東西讓你不再頭疼!

一、函式也是引用型別的。

function f(){ console.log("not change") };

var ff = f;
 
function f(){ console.log("changed") };

ff();

//"changed"
//ff 儲存著函式 f 的引用,改變f 的值, ff也變了

//來個對比,估計你就明白了。
var f = "not change";

var ff = f;

f = "changed";

console.log(ff);

//"not change"
//ff 儲存著跟 f 一樣的值,改變f 的值, ff 不會變
複製程式碼

其實,就是引用型別 和 基本型別的 區別。


二、函式建立一個引數,就相當於在其內部宣告瞭該變數

function f(arg){
    console.log(arg)
}

f();

//undefined

function f(arg){
    arg = 5;
    console.log(arg);
}
f();

//5
複製程式碼

三、引數傳遞,就相當於變數複製(值的傳遞)

基本型別時,變數儲存的是資料,引用型別時,變數儲存的是記憶體地址。引數傳遞,就是把變數儲存的值 複製給 引數。

var o = { a: 5 };

function f(arg){
    arg.a = 6;
}

f(o);

console.log(o.a);
//6
複製程式碼

四、垃圾收集機制

JavaScript 具有自動垃圾收集機制,執行環境會負責管理程式碼執行過程中使用的記憶體。函式中,正常的區域性變數和函式宣告只在函式執行的過程中存在,當函式執行結束後,就會釋放它們所佔的記憶體(銷燬變數和函式)。

而js 中 主要有兩種收集方式:

  1. 標記清除(常見) //給變數標記為“進入環境” 和 “離開環境”,回收標記為“離開環境”的變數。
  2. 引用計數 // 一個引用型別值,被賦值給一個變數,引用次數加1,通過變數取得引用型別值,則減1,回收為次數為0 的引用型別值。

知道個大概情況就可以了,《JavaScript高階程式設計 第三版》 4.3節 有詳解,有興趣,可以看下。.


五、作用域

之前說過,JavaScript中的作用域無非就是兩種:全域性作用域區域性作用域。 根據作用域鏈的特性,我們知道,作用域鏈是單向的。也就是說,在函式內部,可以直接訪問函式外部和全域性變數,函式。但是,反過來,函式外部和全域性,是訪問不了函式內的變數,函式的。

function testA(){
    var a = 666;
}
console.log(a);

//報錯,a is not defined

var b = 566;
function testB(){
    console.log(b);
}

//566
複製程式碼

但是,有時候,我們需要在函式外部 訪問函式內部的變數,函式。一般情況下,我們是辦不到的,這時,我們就需要閉包來實現了。


六、開始閉包!

function fa(){
    var va = "this is fa";

    function fb(){
        console.log(va);
    }
    
    return fb;
}

var fc = fa();
fc();

//"this is fa"
複製程式碼

想要讀取fa 函式內的變數 va,我們在內部定義了一個函式 fb,但是不執行它,把它返回給外部,用 變數fc接受。此時,在外部再執行fc,就讀取了fa 函式內的變數 va


七、閉包的概念

其實,簡單點說,就是在 A 函式內部,存在 B 函式, B函式 在 A 函式 執行完畢後再執行。B執行時,訪問了已經執行完畢的 A函式內部的變數和函式。

由此可知:閉包是函式A的執行環境 以及 執行環境中的函式 B組合而構成的。

上篇文章中說過,變數等 都儲存在 其所在執行環境的活動物件中,所以說是 函式A 的執行環境。

當 函式A執行完畢後,函式B再執行,B的作用域中就保留著 函式A 的活動物件,因此B中可以訪問 A中的 變數,函式,arguments物件。此時產生了閉包。大部分書中,都把 函式B 稱為閉包,而在谷歌瀏覽器中,把 A函式稱為閉包。


八、閉包的本質

之前說過,當函式執行完畢後,區域性活動物件就會被銷燬。其中儲存的變數,函式都會被銷燬。記憶體中僅儲存全域性作用域(全域性執行環境的變數物件)。但是,閉包的情況就不同了。

以上面的例子來說,函式fb 和其所在的環境 函式fa,就組成了閉包。函式fa執行完畢後,按道理說, 函式fa 執行環境中的 活動物件就應該被銷燬了。但是,因為 函式fa 執行時,其中的 函式fb 被 返回,被 變數fc 引用著。導致,函式fa 的活動物件沒有被銷燬。而在其後 fc() 執行,就是 函式fb 執行時,構建的作用域中儲存著 函式fa 的活動物件,因此,函式fb 中 可以通過作用域鏈訪問 函式fa 中的變數。

我已經盡力地說明白了。就看各位的了。哈哈!其實,簡單的說:就是fa函式執行完畢了,其內部的 fb函式沒有執行,並返回fb的引用,當fb再次執行時,fb的作用域中保留著 fa函式的活動物件。

再來個有趣經典的例子:

for (var i=1; i<=5; i++) {
    setTimeout(function(){
        console.log(i);
    },i*1000);
}

//每隔一秒輸出一個6,共5個。
複製程式碼

是不是跟你想的不一樣?其實,這個例子重點就在setTimeout函式上,這個函式的第一個引數接受一個函式作為回撥函式,這個回撥函式並不會立即執行,它會在當前程式碼執行完,並在給定的時間後執行。這樣就導致了上面情況的發生。

可以下面對這個例子進行變形,可以有助於你的理解把:

var i = 1;
while(i <= 5){
    setTimeout(function(){
        console.log(i);
    },i*1000)
     
    i = i+1;
}
複製程式碼

正因為,setTimeout裡的第一個函式不會立即執行,當這段程式碼執行完之後,i 已經 被賦值為6了(等於5時,進入迴圈,最後又加了1),所以 這時再執行setTimeout 的回撥函式,讀取 i 的值,回撥函式作用域內沒有i,向上讀取,上面作用域內i的值就是6了。但是 i * 1000,是立即執行的,所以,每次讀的 i 值 都是對的。

這時候,就需要利用閉包來儲存每個迴圈時, i 不同的值。

function makeClosures(i){     //這裡就和 內部的匿名函式構成閉包了
    var i = i;    //這步是不需要的,為了讓看客們看的輕鬆點
    return function(){
        console.log(i);     //匿名沒有執行,它可以訪問i 的值,儲存著這個i 的值。
    }
}

for (var i=1; i<=5; i++) {
    setTimeout(makeClosures(i),i*1000);  
    
    //這裡簡單說下,這裡makeClosures(i), 是函式執行,並不是傳參,不是一個概念
    //每次迴圈時,都執行了makeClosures函式,都返回了一個沒有被執行的匿名函式
    //(這裡就是返回了5個匿名函式),每個匿名函式都是一個區域性作用域,儲存著每次傳進來的i值
    //因此,每個匿名函式執行時,讀取`i`值,都是自己作用域內儲存的值,是不一樣的。所以,就得到了想要的結果
}

//1
//2
//3
//4
//5
複製程式碼

閉包的關鍵就在,外部的函式執行完畢後,內部的函式再執行,並訪問了外部函式內的變數。

你可能在別處,或者自己想到了下面這種解法:

for (var i=1; i<=5; i++) {
    (function(i){
        setTimeout(function(){
            console.log(i);
        },i*1000);
    })(i);
}
複製程式碼

如果你一直把這個當做閉包,那你可能看到的是不同的閉包定義吧(犀牛書和高程對閉包的定義不同)。嚴格來說,這不是閉包,這是利用了立即執行函式函式作用域 來解決的。

做下變形,你再看看:

for (var i=1; i<=5; i++) {
    function f(i){
        setTimeout(function(){
            console.log(i);
        },i*1000);
    };
    
    f(i);
}
複製程式碼

這樣看就很明顯了吧,主要是利用了函式作用域,而使用立即執行函式,是為了簡化步驟。

總結:判斷是不是閉包,我總結了要滿足以下三點:

  1. 兩個函式。有內函式 和 外函式。
  2. 外函式執行完畢後,內函式 還沒有執行。
  3. 當內函式執行時(通過外部引用或者返回內函式),訪問了 外函式內部的 變數,函式等(說是訪問,其實內函式儲存著外函式的活動物件,因此,arguments物件也可以訪問到)。


附錄:

其實這道題,知道ES6let 關鍵詞,估計也想到了另一個解法:

for (let i=1; i<=5; i++) {   //這裡的關鍵就是使用的let 關鍵詞,來形成塊級作用域
    setTimeout(function(){
        console.log(i);
    },i*1000);
}
複製程式碼

我不知道,大家有沒有疑惑啊,為啥使用了塊級作用域就可以了呢。反正我當初就糾結了半天。

11月 2日修正:

這個答案的關鍵就在於 塊級作用域的規則了。它讓let宣告的變數只在{}內有效,外部是訪問不了的。

做下變形,這個是為了方便理解的,事實並非如此:

for (var i=1; i<=5; i++) {
    let j = i;
    setTimeout(function(){
        console.log(j);
    },j*1000);
}
複製程式碼

當for 的() 內使用 let時,for 迴圈就存在兩個作用域,() 括號裡的父作用域,和 {} 中括號裡的 子作用域。

每次迴圈都會建立一個 子作用域。儲存著父作用域傳來的值,這樣,每個子作用域內的值都是不同的。當setTimeout 的匿名函式執行時,自己的作用域沒有i 的值,向上讀取到了該 子作用域i 值。因此每次的值才會不一樣。

上面用立即執行函式模擬塊級作用域,就是這個道理啦!

相關文章