[閉包]該如何理解?

前言

說到閉包,實在是居家旅行破境渡劫攝魄迷魂必備良藥!不吃不知道,一吃哇哇叫,下面咱們也去搞兩盒試試。git

1、閉包是什麼

閉包,一個近乎神話的概念,從字面上理解感受就像是一個比較封閉的東西,百度百科上的定義是:閉包就是可以讀取其餘函數內部變量的函數。github

而我我的比較傾向於這麼理解:閉包就是一個封閉包裹了它所能使用的做用域的函數。閉包

這樣看起來好像有點那個意思了,通俗的說就是:函數這個袋子把一些做用域裝起來了,哪些做用域呢?這個函數做用域鏈上的做用域。異步

光說不寫假帥氣,下面來些例子瞧瞧:函數

1.1 函數傳遞

// 1.函數做爲返回值
function foo() { 
    var a = 2; 
    function bar() {  
        console.log( a ); 
    } 
    return bar; 
} 

var f = foo(); 
f();   // 2 這就是閉包的效果,或者說f即bar函數就是一個閉包,它把a所在的做用域包了起來,以便本身隨時使用
複製代碼

上面的例子是將函數做爲值返回,下面咱們換個方式試試(其實不管使用何種方式對函數類型的值進行傳遞,當函數在別處被調用時均可以觀察到閉包)。工具

// 2.函數做爲參數傳遞
function foo() { 
    var a = 2; 
    function bar() { 
        console.log( a );
    } 
    f(bar); 
} 

function f(fn) { 
    fn();  // 函數做爲參數傳遞,也包裹了a的做用域,這也是閉包
}

foo();  // 2
複製代碼
// 3.間接傳遞函數
var fn; 
function foo() { 
    var a = 2; 
    function bar() { 
        console.log( a ); 
    } 
    fn = bar; // 將bar分配給全局變量fn
} 

function f() { 
    fn(); // fn指向bar,bar包裹着a的做用域,這也是閉包
} 

foo(); 
f(); // 2
複製代碼
// 4.回調函數,傳遞給JS引擎調用
function wait(message) { 
    setTimeout(function timer() { 
        console.log(message); 
    }, 1000); 
} 

wait( "Hello" );  // 'Hello'
// 將一個內部函數timer傳遞給setTimeout,timer具備涵蓋wait做用域的閉包,所以還有對變量message的引用
複製代碼

其實,在定時器、事件監聽器、Ajax請求、跨窗口通訊、Web Workers或者任何其餘的異步(或者同步)任務中,只要使用了回調函數,實際上就是在使用閉包。ui

因此不管經過何種手段將內部函數傳遞到所在的詞法做用域之外,它都會持有對原始定義做用域的引用(包裹),不管在何處執行這個函數都會使用閉包。spa

tip: 詞法做用域指由書寫代碼時變量所在的位置所決定的做用域。code

1.2 IIFE

var a = 2; 
(function IIFE() { 
    console.log(a); 
})();
複製代碼

以上這個當即執行函數是閉包嗎?嗯,看起來應該是。cdn

但嚴格來說它並非閉包。爲何?由於上面的函數並非在它自己的詞法做用域之外執行的,它在定義時所在的做用域中執行,a是經過普通的詞法做用域查找而非閉包被發現的。

儘管IIFE自己並非觀察閉包的恰當例子,但它的確建立了閉包,而且也是最經常使用來建立能夠被封閉起來的閉包的工具,後面咱們會講到。

1.3 循環與閉包

說到這個循環閉包的例子,可謂是如影隨形,惺惺相惜,讓猿欲罷不能。

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

這個想必你們夥就算沒吃過也見過這個豬是怎麼跑的:以每秒一次的頻率輸出五次6,而不是每秒一次一個的分別輸出1~5。

首先解釋6是從哪裏來的:這個循環的終止條件是i再也不<=5,條件首次成立時i的值是6。所以,輸出顯示的是循環結束時i的最終值。

仔細想一下,這好像又是顯而易見的,延遲函數的回調會在循環結束時才執行。但事實上,當定時器運行時即便每一個迭代中執行的是setTimeout(.., 0),全部的回調函數依然是在循環結束後纔會被執行,所以會每次輸出一個6出來。

到底是什麼緣由致使這結果和咱們預想的不同呢?

緣由是咱們試圖假設循環中的每一個迭代在運行時都會給本身「捕獲」一個i的副本。可是根據做用域的工做原理,實際狀況是儘管循環中的五個函數是在各個迭代中分別定義的,可是它們都被封閉在一個共享的全局做用域中,所以實際上只有一個i,因此都是在共享同一個i。

如何解決這個問題?

咱們設想一下若是每次循環函數都能將屬於本身的i包裹起來,而後保存下來,那就須要閉包做用域,下面咱們試試:

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

這樣行嗎?答案是不行。爲何?上面的確建立了五個封閉的做用域,但你們有沒有注意到,但這個做用域是空的,它們並無將i包裹並存儲起來,咱們依舊是引用外部的同一個全局i,因此這個封閉的做用域須要有本身的變量,用來在每一個迭代中儲存i的值:

for (var i=1; i<=5; i++) { 
    (function() { 
        var j = i;   // 將i的值存儲在閉包內
        setTimeout(function timer() { 
            console.log(j); 
        }, j*1000); 
    })(); 
}
複製代碼

搞定!將timer傳遞給setTimeout,時間到後,JS引擎會調用timer函數,而後找到對應包裹起來的i,咱們還能夠再改進一下:

for (var i=1; i<=5; i++) { 
    (function(j) {  // j參數也是屬於函數隱式聲明的變量
        setTimeout(function timer() { 
            console.log(j); 
        }, j*1000); 
    })( i ); 
}
複製代碼

等等,解決這個問題的方法是每次迭代咱們都須要一個塊做用域,那麼用let來生成塊做用域不就搞定了嗎?

for(let i=1; i<=5; i++) {  // 使用let聲明i
    setTimeout(function timer() {
        console.log(i);
    }, i*1000);
}
複製代碼

但let的做用不只僅是生成塊做用域,for循環頭部的let聲明還會有一個特殊的行爲:變量i在循環過程當中不止被聲明一次,每次迭代都會聲明,隨後的每一個迭代都會使用上一個迭代結束時的值來初始化這個變量。

這種每次迭代從新聲明綁定的行爲就相似這樣:

for (var i=1; i<=5; i++) { 
    let j = i;  //每一個迭代從新聲明j並將i的值綁定在這個塊做用域內
    setTimeout( function timer() { 
        console.log(j); 
    }, j*1000); 
}
複製代碼

這樣一路看下來,感受閉包好像也不是那麼神祕嘛,我我的理解的話會把以上概括爲:只要發生了函數傳遞與調用,就會產生閉包。好了,瞭解了閉包是什麼,那下面來看看它有什麼用途。

2、閉包的應用

2.1 模塊

閉包最大的做用莫過於建立模塊了:

function betterModule() {
    var name = 'BetterMan';
    var arr = [1, 2, 3];
    function getName() {
        console.log(name);
    }
    function joinArr() {
        console.log(arr.join('-'));
    }
    return {
        getName: getName,
        joinArr: joinArr
    }
}

var foo = betterModule();
foo.getName();  // 'BetterMan'
foo.joinArr();  // '1-2-3'
複製代碼

以上就是一個利用閉包來建立的模塊,咱們來理一理這段代碼:

首先,betterModule()只是一個函數,必需要經過調用它來建立一個模塊實例。若是不執行外部函數,內部做用域和閉包都沒法被建立。

其次,betterModule()返回一個用對象字面量語法{key: value, ...}來表示的對象,這個返回的對象中含有對內部函數而不是內部數據變量的引用,保持了內部數據變量是隱藏且私有的狀態,能夠將這個對象類型的返回值看做本質上是模塊的公共API。

這個對象類型的返回值最終被賦值給外部的變量foo,而後就能夠經過它來訪問API中的屬性,如foo.joinArr()

tip: 從模塊中返回一個實際的對象並非必須的,也能夠直接返回一個內部函數。jQuery就是如此,jQuery$標識符就是jQuery模塊的公共API,但它們自己都是函數(因爲函數也是對象,它們自己也能夠擁有屬性)。

以上的betterModule函數能夠被調用任意屢次,每次調用都會建立一個新的模塊實例;但若是咱們只須要一個實例時,能夠對這個模式進行簡單的改進來實現單例模式

var foo = (function betterModule() {
    var name = 'BetterMan';
    var arr = [1, 2, 3];
    function getName() {
        console.log(name);
    }
    function joinArr() {
        console.log(arr.join('-'));
    }
    return {
        getName: getName,
        joinArr: joinArr
    }
})();
複製代碼

咱們將模塊函數轉換成了IIFE,當即調用這個函數並將返回值直接賦值給單例的模塊實例foo。

2.2 柯里化

柯里化也用到了閉包,聽起來有點高大上,那什麼是柯里化呢?

柯里化(Currying)是把接受多個參數的函數變換成接受一個單一參數(最初函數的第一個參數)的函數,而且返回接受餘下的參數且返回結果的新函數的技術,看起來是否是有點繞,下面看看例子:

function add(a, b, c) {
    return a + b + c;
}
console.log(add(1,2,3));  // 6

function newAdd(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        }
    }
}
console.log(newAdd(1)(2)(3));  // 6
複製代碼

看着例子對照着定義,看起來描述得仍是挺貼切的嘛,其實上面也是利用了閉包的功能綁定了參數的做用域,使得每次調用函數時能夠訪問上次所傳入的參數。

3、閉包的注意事項

一般,函數的做用域及其全部變量都會在函數執行結束後被銷燬。可是,在建立了一個閉包之後,這個函數的做用域就會一直保存到閉包不存在爲止,由於閉包就是一個函數引用另一個函數的變量,由於變量被引用着因此不會被回收。這是優勢也是缺點,沒必要要的閉包只會徒增內存消耗,因此咱們在使用的時候須要注意這方面。

function add(x) {
  return function(y) {
    return x + y;
  };
}

var add3 = add(3);
var add5 = add(5);

console.log(add3(2));  // 5
console.log(add5(5));  // 10

// 須要手動釋放對閉包的引用
add3 = null;
add5 = null;
複製代碼

以上的add3add5都是閉包,它們共享相同的函數定義,可是保存了不一樣的環境。在add3的環境中,x爲3。而在add5中,x則爲5,最後咱們經過null手動釋放了add3add5對閉包的引用。

最後

若是到了這裏你恍然大悟:原來在個人代碼中已經處處都是閉包了,只是平時沒注意到而已!那說明我這藥方仍是有點效果的,若是真的如此,那就來波點贊關注吧,由於你的支持就是我最大的動力!

GitHub傳送門
博客園傳送門

相關文章
相關標籤/搜索