JavaScript 閉包

一個函數和對其周圍狀態(lexical environment,詞法環境)的引用捆綁在一起(或者說函數被引用包圍),這樣的組合就是閉包(closure)。也就是說,閉包讓你可以在一個內層函數中訪問到其外層函數的作用域。在 JavaScript 中,每當創建一個函數,閉包就會在函數創建的同時被創建出來。–MDN

閉包?

每當一個函數被創建時,這個函數就和創建其的詞法環境綁定了。這保證了這個函數始終能訪問創建其的詞法環境里的數據。

例如如下代碼 00:

function 張三(){
    let foo = "張三的foo";
    let bar = "張三的bar";
    function show(){
        console.log(foo,bar)
    }
    show();
}
張三();			// 張三的foo 張三的bar
console.log(foo,bar);	// Uncaught ReferenceError: foo is not defined

張三被調用時,創建了一個詞法環境,其中包含了foobarshow等變量,他們僅在該詞法環境和其內層詞法環境中可見,而外層詞法環境不可見。

張三被執行完畢,這個詞法環境里的foobar隨即銷毀,這個詞法環境也不復存在。

而閉包就使得這個詞法環境得以保持,並且外部/其他詞法環境可以訪問其中的數據。
例如如下代碼 01:

let foo = "全局的foo";
let bar = "全局的bar";

function 張三(){
    let foo = "張三的foo";
    return function show(){      // *
        console.log(foo,bar);
    }
}
function 李四(){
    let foo = "李四的foo";
    let bar = "李四的bar";
    let show = 張三();
    show();			// 張三的foo 全局的bar
}
李四();

let show = 張三();
show();			// 張三的foo 全局的bar
┌───────────────────────┐		  ┌───────────────────────┐
│[ 張三 ] 		│		  │[ 李四 ]		  │
│			│		  │   foo: '李四的foo' 	  │
│  foo: '張三的foo'     >>>>function show>>>>  bar: '李四的bar'  	  │
│			│		  │   show: function 	  │
│			│		  │			  │
└──────────↡────────────┘		  └───────────────────────┘
	   ↡
	   ↳>>>>function show>>>> [ global ]

*處的return function show將張三的詞法環境「打開」,使得擁有這個返回的函數的詞法環境能夠訪問張三的詞法環境。
無論這個函數被傳遞到哪裡,它都會持有對原始定義作用域的引用,在訪問foobar時總會先搜索張三的詞法環境,如果沒有則搜索其外部詞法環境直至全局詞法環境,如果在任何地方都找不到這個變量,那麼就會報錯。
張三被執行完畢,這個詞法環境也不會立即銷毀(張三自己會被銷毀),因為還有”李四和全局要訪問它”,即李四和全局持有對張三的詞法環境的引用show
這個在 其他作用域 中對 原始定義時的作用域引用就是閉包

關於銷毀

上文說道,show能使定義它的作用域不被銷毀,但不會阻止張三不被銷毀
例如如下代碼 02:

function 張三他爹(){
    function 張三(){
        let foo = "張三的foo";
        return function show(){
            console.log(foo);
        }
    }
    return 張三();
}
let show = 張三他爹();
show();	// 張三的foo

張三他爹執行完後,張三不復存在,但show仍能訪問定義它的作用域
或者來點更直接的,殺死張三,代碼 03:

function 張三(){
    let foo = "張三的foo";
    return function show(){
        console.log(foo);
    }
}
let show = 張三();
張三 = null;	//張三死了
show();		//張三的foo

張三死了,但他的精神還在

作用域 閉包

顯然,多個作用域持有的閉包指向同一個詞法環境。如果某個作用域中通過閉包修改了指向的詞法環境的某個數據,其它作用域也會感知到。
例如以下代碼 04:

function 張三(){
    let size = 10;
    return [
        function(n){
            size += n;
            console.log(size);
        },
        function(n){
            size += n;
            console.log(size);
        }
    ]
}
[enlarge1,enlarge2] = 張三();
enlarge1(2);			// 12
enlarge2(2);			// 14

enlarge1enlarge2同時指向了同一個詞法環境,因此兩次enlarge了同一個張三的size

代碼 02看起來有些怪異。
請看代碼 05:

function 張三(){
    let size = 10;
    return function(n){
        size += n;
        console.log(size);
    }
}
enlarge1 = 張三();
enlarge2 = 張三();
enlarge1(2);			// 12
enlarge2(2);			// 12

看起來正常多了,但是結果不是我們想要的。

兩次調用張三實際上創建了兩個獨立的詞法環境 [張三1, 張三2],所以兩次enlarge了兩個不同的張三的size

循環

比較經典的是循環與閉包結合
例如以下代碼 06:

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

輸出為5個6。
異步的setTimeout總是在當前事件(循環及其後面的同步代碼)執行結束後再開始執行。每次向setTimeout傳遞的function都是對循環內詞法環境的閉包,因此當這些function被調用時,它們訪問循環內詞法環境中的i,得到的是累加完畢的6。

let

如果使用現代的let而非var,結果會完全不同
例如以下代碼 07:

for (let i=1; i<=5; i++) {
    setTimeout( function logi() {
        console.log( i );
    }, i*100 );
}

輸出為1 2 3 4 5。
這個循環實際上有兩個作用域

┌─────────────────────────┐
│[ ()內 ] i		  │
│   ┌──────────────────┐  │
│   │[ {}內 ] i        >>>>function logi>>>>[ global ]
│   └──────────────────┘  │
│			  │
└─────────────────────────┘

每次迭代,JavaScript就會用上次迭代結束時i的值重新聲明並初始化 {}內的 i

驗證

代碼 08:

for(let i=1;i<5;i++){
    console.log(i); //Uncaught ReferenceError: Cannot access 'i' before initialization
    let i = 1;
}

在ES6中,let聲明會被提升到作用域開頭但不會初始化,這就是暫時性死區。
上述代碼拋出了ReferenceError: Cannot access 'i' before initialization
而不是SyntaxError: Identifier 'i' has already been declared
證明i並不在{}內作用域定義,而是屬於其外層作用域。
i確實存在在{}內作用域中

猜測:由於整個for循環的執行體中並沒有使用let,但是執行中每次都產生了塊級作用域,我猜想是由底層代碼創建並塞給for執行體中。
//www.cnblogs.com/echolun/p/10584703.html

應用

工廠函數

可以通過一個函數創建多個特定函數
例如代碼 09:

function createAdder(m){
    return function(n){
        return m+n;
    }
}
let addTo5 = createAdder(5);
addTo5(10);

調用createAdder會產生一個偏函數,實現功能更狹窄但更精確的函數。
addTo5返回某數與5的和。

為什麼我們通常會創建一個偏函數?
好處是我們可以創建一個具有可讀性高的名字(double,triple)的獨立函數。我們可以使用它,並且不必每次都提供一個參數,因為參數是被綁定了的。
另一方面,當我們有一個非常通用的函數,並希望有一個通用型更低的該函數的變體時,偏函數會非常有用。
//zh.javascript.info/bind

模塊/類

例如代碼 0A:

let person = (function createPerson(){
    let age = 1;
    let name = "Li Hua";
    return {
        grow(){
            age++
        },
        say(){
            console.log(`I'm ${name}, and I'm ${age} years old`)
        }
    }
})();
person.say()	// I'm Li Hua, and I'm 1 years old
person.grow()
person.say()	// I'm Li Hua, and I'm 2 years old

createPerson拋出了兩個方法growsay,使其他作用域可以訪問其內部的數據、方法。

性能

如果不是某些特定任務需要使用閉包,在其它函數中創建函數是不明智的,因為閉包在處理速度和內存消耗方面對腳本性能具有負面影響。
— MDN

使用閉包來實現一些東西不是明智的,例如代碼 0A中每創建一個對象都要把方法賦給這個對象。
改為使用原型,代碼 0B:

let father  = {
    age: undefined,
    name: undefined,
    init(name){
        this.age = 1;
        this.name = name;
    },
    grow(){
        this.age++;
    },
    say(){
        console.log(`I'm ${this.name}, and I'm ${this.age} years old`)
    }
}
let p1 = Object.create(father);
p1.init("Li Hua")
let p2 = Object.create(father);
p2.init("Li Ming")
p1.grow()
p1.say()	// I'm Li Hua, and I'm 2 years old
p2.say()	// I'm Li Ming, and I'm 1 years old

或使用class來實現。
這樣,方法都存在他們的原型中,免去了每次創建的賦值。

內存消耗

從理論上說,閉包會保持住其 原始定義作用域 的所有數據,因此會導致較大的內存消耗。
現代的JavaScript引擎可能會分析出一片作用域中沒有使用的量,並把他們從環境中刪除。
例如 代碼0C:

function 張三(){
    let foo = "張三的foo";
    let bar = "張三的bar";
    return function show(){
        console.log(foo);
    }
}
let show = 張三();
show();

當通過show()訪問張三作用域時,bar是不存在的,因為沒有使用。
image
測試環境:Edge 95,JavaScript: V8 9.5.91
但這個和引擎的實現有關,實際使用時應當慎重。

總結

事實上,JavaScript中到處都有閉包存在,每當函數被傳遞到當前詞法作用域之外執行,就產生了閉包。
更確切地說,所有的函數都是閉包的,當函數被傳遞到當前詞法作用域之外執行,閉包的效果得以表現。
學會並能夠識別閉包可以減少出錯,寫出更健壯的代碼。

在面試時,前端開發者通常會被問到「什麼是閉包?」,正確的回答應該是閉包的定義,並解釋清楚為什麼 JavaScript 中的所有函數都是閉包的,以及可能的關於詞法環境原理的技術細節。
//zh.javascript.info/closure