[JS] Closure 閉包

在看本篇文章前,建議先了解 scope & scope chain 再來看會比較好。

什麼是閉包?

closure 閉包 就是在某函數內宣告另一個函數,該內部函數能夠存取當下的作用域環境,之後即使在該作用域鏈以外的地方使用調用該內部函數,仍然能夠存取該作用域環境的值。

光看敘述可能很難理解意思,直接看以下的範例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a = 20;

function outer() {
var a = 10;

function inner() {
console.log(a);
}

return inner;
}

const test = outer();
test(); // 10

console.log(a); // 20

outer() 會回傳 inner 函數,將該函數分配給 test 常數,執行 test() 便會列印出 a 變數的值 10

看起來好像很合理,但記得在函數裡宣告的變數壽命在函數被調用時創建,在函數執行結束後被刪除,從這點來看變數 aouter 被調用時建立,回傳 innertest 後便刪除了,那麼之後執行 test() 為何仍然能夠存取到變數 a 印出它的值?

難道在全域的情況下仍能夠存取到 outer 裡的 a 嗎? 看上面代碼第 16 行執行的結果顯然不是,那麼到底是為什麼?

這與 scope chain 作用域鏈以及 static scope 靜態作用域有關,scope chain 是在函式被宣告的當下確定的,而不是在被調用執行的時候決定,所以 innera 要存取時的作用域為 inner scope -> outer scope -> global scope,在 outer scope 該層找到 a,於是結果便為 10。

closure 的作用使得即使在函式執行結束,仍然能夠記住該函式內部的函式之作用域環境。

使用 closure 的好處

雖然瞭解了 closure 的作用,但這麼做有什麼好處呢? 使用 closure 可以做到變數或方法的私有化,讓他人不能隨意的存取變動,也避免過多的全域變數造成汙染或衝突。

  • 不使用閉包
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    var 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
    15
    function 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

    只能透過 addoutercount 進行存取,達到私有化的效果


  • 這裡要注意的是像以下的情況

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function 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

    可以發現 addadd2 是獨立的、互不干涉,這是由於每次呼叫 outer() 都會建立一個新的作用域環境,所以存取的作用域是不一樣的,結果就是 addadd2 共享函式的定義,卻保有不同的環境。


  • 接著再來看下面的代碼,執行時很多人會以為第 1 秒印出 1、第 2 秒印出 2、第 3 秒印出 3、第 4 秒印出 4、第 5 秒印出 5,然而結果是每一秒都印出 6 !

    1
    2
    3
    4
    5
    for(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
    19
    window.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
window.setTimeout(() => {
console.log(i);
}, 1*1000);

window.setTimeout(() => {
console.log(i);
}, 2*1000);

window.setTimeout(() => {
console.log(i);
}, 3*1000);

window.setTimeout(() => {
console.log(i);
}, 4*1000);

window.setTimeout(() => {
console.log(i);
}, 5*1000);

會這樣是因為執行時,for 迴圈不會等到執行完 setTimeout 的 callback func 才進行下一個迴圈,而是一下就全部跑完所有迴圈,每次迴圈做的只是向 window 物件設置定時器,等到時間到了才會執行 callback func。

看到這裡可能還會有以下的疑惑

  1. 為什麼 i 是 6 ?
    因為在迴圈的最後一圈 i = 5,執行結束後 i++ 變成 6 不符合條件所以離開迴圈。

  2. 如果 i 是 6,那為什麼執行不是一到 6 秒一次印出 5 個 6 ?

    你可能以為每次的迴圈是長下面這樣

    1
    2
    3
    window.setTimeout(() => {
    console.log(i);
    }, 6*1000);

    雖然每次的迴圈雖然沒有馬上執行 window.setTimeout 的 callback,但是馬上對 window 進行了定時器的註冊,當下註冊的時間即是該次迴圈的 i 值乘上 1000 ms,而不是等到離開迴圈 i = 6 才開始註冊定時器。


那麼要如何做才能如預期的每一秒印出對印的值?

  1. 使用 IIFE (Immediately Invoked Function Expression, 立即函式)
    IIFE 的特性是當中的函式在宣告的時候便會執行,之後就不會在調用該函式,也就是每次的迴圈執行的函式都不是同一個,作用域也都不一樣,所以這裡將 i 傳入該函式,便會存取當下的作用域環境值
1
2
3
4
5
6
7
for(var i=1; i<=5; i++) {
(function(num) {
window.setTimeout(() => {
console.log(num);
}, i*1000);
})(i);
}

  1. 使用 let 關鍵字
    因為 let 的特性所以 {} (block scope 區塊作用域) 可以作為它的作用域,每次迴圈都會產生一個新的作用域,也因此可以保留當下的 i 值
    1
    2
    3
    4
    5
    for(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);
    }

以上為常見的解決方式,但根據不同的情況也有其它的辦法。


參考資料:

  1. 所有的函式都是閉包:談 JS 中的作用域與 Closure
  2. 重新認識 JavaScript: Day 19 閉包 Closure
  3. 重新認識 JavaScript: Day 18 Callback Function 與 IIFE
  4. 重新認識 JavaScript: Day 10 函式 Functions 的基本概念
  5. MDN: 閉包
  6. 你懂 JavaScript 嗎?#15 閉包