[JS] Closure 閉包
在看本篇文章前,建議先了解 scope & scope chain 再來看會比較好。
什麼是閉包?
closure 閉包 就是在某函數內宣告另一個函數,該內部函數能夠存取當下的作用域環境,之後即使在該作用域鏈以外的地方使用調用該內部函數,仍然能夠存取該作用域環境的值。
光看敘述可能很難理解意思,直接看以下的範例
1 | var a = 20; |
outer()
會回傳 inner
函數,將該函數分配給 test
常數,執行 test()
便會列印出 a
變數的值 10
。
看起來好像很合理,但記得在函數裡宣告的變數壽命在函數被調用時創建,在函數執行結束後被刪除,從這點來看變數 a
在 outer
被調用時建立,回傳 inner
給 test
後便刪除了,那麼之後執行 test()
為何仍然能夠存取到變數 a
印出它的值?
難道在全域的情況下仍能夠存取到 outer
裡的 a
嗎? 看上面代碼第 16 行執行的結果顯然不是,那麼到底是為什麼?
這與 scope chain 作用域鏈以及 static scope 靜態作用域有關,scope chain 是在函式被宣告的當下確定的,而不是在被調用執行的時候決定,所以 inner
的 a
要存取時的作用域為 inner scope -> outer scope -> global scope,在 outer scope 該層找到 a
,於是結果便為 10。
closure 的作用使得即使在函式執行結束,仍然能夠記住該函式內部的函式之作用域環境。
使用 closure 的好處
雖然瞭解了 closure 的作用,但這麼做有什麼好處呢? 使用 closure 可以做到變數或方法的私有化,讓他人不能隨意的存取變動,也避免過多的全域變數造成汙染或衝突。
- 不使用閉包
1
2
3
4
5
6
7
8
9
10var count = 0;
function add() {
return ++count;
}
console.log(add()); // 1
console.log(add()); // 2
console.log(count += 10); // 12
在不使用閉包的情況下,可以直接對變數進行存取操作
使用閉包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15function outer() {
var count = 0;
function inner() {
return ++count;
}
return inner;
}
const add = outer();
console.log(add()); // 1
console.log(add()); // 2
console.log(count += 10); // Uncaught ReferenceError: count is not defined只能透過
add
對outer
的count
進行存取,達到私有化的效果
這裡要注意的是像以下的情況
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17function outer() {
var count = 0;
function inner() {
return ++count;
}
return inner;
}
const add = outer();
const add2 = outer();
console.log(add()); // 1
console.log(add()); // 2
console.log(add2()); // 1可以發現
add
與add2
是獨立的、互不干涉,這是由於每次呼叫outer()
都會建立一個新的作用域環境,所以存取的作用域是不一樣的,結果就是add
與add2
共享函式的定義,卻保有不同的環境。
接著再來看下面的代碼,執行時很多人會以為第 1 秒印出 1、第 2 秒印出 2、第 3 秒印出 3、第 4 秒印出 4、第 5 秒印出 5,然而結果是每一秒都印出 6 !
1
2
3
4
5for(var i=1; i<=5; i++) {
window.setTimeout(() => {
console.log(i);
}, i*1000);
}這是很多人在不了解作用域及閉包的情況下會遇到的問題,首先你以為上面的迴圈是這樣子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19window.setTimeout(() => {
console.log(1);
}, 1*1000);
window.setTimeout(() => {
console.log(2);
}, 2*1000);
window.setTimeout(() => {
console.log(3);
}, 3*1000);
window.setTimeout(() => {
console.log(4);
}, 4*1000);
window.setTimeout(() => {
console.log(5);
}, 5*1000);
但實際上是這樣
1 | window.setTimeout(() => { |
會這樣是因為執行時,for
迴圈不會等到執行完 setTimeout
的 callback func 才進行下一個迴圈,而是一下就全部跑完所有迴圈,每次迴圈做的只是向 window
物件設置定時器,等到時間到了才會執行 callback func。
看到這裡可能還會有以下的疑惑
為什麼
i
是 6 ?
因為在迴圈的最後一圈i = 5
,執行結束後i++
變成 6 不符合條件所以離開迴圈。如果
i
是 6,那為什麼執行不是一到 6 秒一次印出 5 個 6 ?
你可能以為每次的迴圈是長下面這樣1
2
3window.setTimeout(() => {
console.log(i);
}, 6*1000);雖然每次的迴圈雖然沒有馬上執行
window.setTimeout
的 callback,但是馬上對window
進行了定時器的註冊,當下註冊的時間即是該次迴圈的i
值乘上 1000 ms,而不是等到離開迴圈i = 6
才開始註冊定時器。
那麼要如何做才能如預期的每一秒印出對印的值?
- 使用 IIFE (Immediately Invoked Function Expression, 立即函式)
IIFE 的特性是當中的函式在宣告的時候便會執行,之後就不會在調用該函式,也就是每次的迴圈執行的函式都不是同一個,作用域也都不一樣,所以這裡將 i 傳入該函式,便會存取當下的作用域環境值
1 | for(var i=1; i<=5; i++) { |
- 使用 let 關鍵字
因為 let 的特性所以{}
(block scope 區塊作用域) 可以作為它的作用域,每次迴圈都會產生一個新的作用域,也因此可以保留當下的 i 值迴圈的情況相當於以下1
2
3
4
5for(let i=1; i<=5; i++) { // var 改成 let
window.setTimeout(() => {
console.log(i);
}, i*1000);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34{ // block scope
let i=1
window.setTimeout(() => {
console.log(i);
}, i*1000);
}
{
let i=2
window.setTimeout(() => {
console.log(i);
}, i*1000);
}
{
let i=3
window.setTimeout(() => {
console.log(i);
}, i*1000);
}
{
let i=4
window.setTimeout(() => {
console.log(i);
}, i*1000);
}
{
let i=5
window.setTimeout(() => {
console.log(i);
}, i*1000);
}
以上為常見的解決方式,但根據不同的情況也有其它的辦法。
參考資料: