# Day 29：閉包 (Closures) 進階打怪實戰

昨天介紹了基本的閉包用法，本篇就來看一些比較進階的閉包應用，或是情境比較複雜的例子。

## 模擬 Class 物件導向用法中的私有成員變數效果

用過其他 Class-based 物件導向語言的開發者，對於 `private` 用法一定不陌生。

例如以下是一個 Java 的私有成員變數範例：

```java
class Person{
    private String name;
    public Person(String n) { this.name = n; }
    public String getName() { return this.name; }
    public void setName(String n) { this.name = n; }
    public String sayHi() { return "Hi I am " + this.name; }
}

public class HelloWorld{
    public static void main(String []args){
        Person p1 = new Person("OneJar");
        Person p2 = new Person("Tony Stark");
        System.out.println(p1.sayHi());
        System.out.println(p2.sayHi());

        p2.setName("Steven Rogers");
        System.out.println(p2.sayHi());
    }
}
```

執行結果：

```
Hi I am OneJar
Hi I am Tony Stark
Hi I am Steven Rogers
```

類別 `Person` 的成員變數 `name` 被設為私有 (private) 屬性，類別外的使用者無法直接對 `name` 作存取，必須透過開放的函式，例如 `getName()`、`setName()`、`sayHi()`。

這樣的好處是可以確保 `name` 的資料安全性，能對 `name` 作什麼程度的資料操控，取決於 `Person` 類別願意開放多少動作函式。

JavaScript 並非傳統 Class-based 物件導向語言，缺乏這種語法支援。

雖然可以用 `new` 關鍵字搭配函數建構子 (Function Constructors) 的用法，模擬類似物件導向效果，但仍缺乏私有成員變數的效果，無法限制外部直接對成員變數作存取。

例如下面這個範例：

```javascript
function Person(n) {
    this.name = n;
    this.sayHi = function(){
        return "Hi, I'm " + this.name;
    }
}

var p1 = new Person("OneJar");
var p2 = new Person("Tony Stark");
console.log(p1.sayHi());    // "Hi, I'm OneJar"
console.log(p2.sayHi());    // "Hi, I'm Tony Stark"

p2.name = "Steven Rogers";  // (外部仍可以直接存取)
console.log(p2.sayHi());    // "Hi, I'm Steven Rogers"
```

ES6 提供了 Class 的語法，但只是一種語法糖，讓語法看起來和傳統 Class-based 寫法相像，本質仍是傳統 JavaScript，而非傳統 Class-based 物件導向。

例如下面是 ES6 的 Class 寫法，仍舊可以直接對 `name` 屬性作存取：

```javascript
class Person {
    constructor(name) {
        this.name = name;
    }
    sayHi(){
        return `Hi I am ${this.name}`;
    }
}

var p1 = new Person("OneJar");
var p2 = new Person("Tony Stark");
console.log(p1.sayHi());    // "Hi, I'm OneJar"
console.log(p2.sayHi());    // "Hi, I'm Tony Stark"

p2.name = "Steven Rogers";  // (外部仍可以直接存取)
console.log(p2.sayHi());    // "Hi, I'm Steven Rogers"
```

但是如果透過閉包，就能模擬出類似私有成員的效果：

```javascript
function createPerson(name){
    var methods = {
      getName: function() { return name; },
      setName: function(n) { name = n; },
      sayHi: function() { return `Hi I am ${name}`; }
    }
    return methods;
}

var p1 = createPerson('OneJar');
var p2 = createPerson('Tony Stark');

console.log(p1.sayHi());           // "Hi I am OneJar"
console.log(p2.sayHi());           // "Hi I am Tony Stark"

p2.setName('Steven Rogers');
console.log(p2.sayHi());           // "Hi I am Steven Rogers"
```

* 變數 `name` 可以持續存活於閉包環境，不會因函數結束而失效。
* 可以對 `name` 作什麼程度的操控，取決於定義時願意開放多少動作函數。例如想修改 `name`，就一定要透過 `setName()`。
* 每個閉包引用的都是獨立的環境，因此 `p1` 和 `p2` 不互相干擾。

## 閉包引用外層函數變數的混淆範例

下面是偶然看到的閉包範例，覺得非常有趣。

### 範例 1

```javascript
function buildFunctions() {
  var arr = [];

  for(var i = 0; i < 3; i++) {
    arr.push(function() {
      console.log(i);
    });
  }

  return arr;
}

var fs = buildFunctions();
fs[0]();
fs[1]();
fs[2]();
```

`fs` 是一個陣列，儲存了 3 個函數，各別呼叫會印出什麼？

我第一眼看過去，直覺以為會印出 `0`、`1`、`2`，因為不是說每個閉包都是獨立的環境嗎？

執行結果：

```
3
3
3
```

![](https://i.imgur.com/JC52Dvn.png)\
(Source: [白爛貓貼圖](https://store.line.me/stickershop/product/1236945/?ref=Desktop))

趕快出動我的銀色子彈，分析一下發生什麼事。

**1. 呼叫 `buildFunctions()` ，宣告陣列變數：**

```javascript
function buildFunctions() {
  var arr = [];
  ...........
}

var fs = buildFunctions();
..........
```

![](https://i.imgur.com/uVMczgK.png)

* 陣列在 JavaScript 裡是 Object 型別，所以 `arr` 變數盒子裡存放的會是一個位址，引用到陣列實際的資料位置。

**2. 進入 for 迴圈，當 for 迴圈的 `i = 0`：**

```javascript
function buildFunctions() {
  var arr = [];
  for(var i = 0; i < 3; i++) {
    arr.push(function() {
      console.log(i);
    });
  }
  .................
}

................
```

![](https://i.imgur.com/1B58IFU.png)

* 宣告變數 `i` 作為計數器。
* 建立一個新的函數物件，存放在匿名盒子 `0x101`。
* `0x101` 函數物件內容的 `console.log(i)`，會引用變數 `i`。
* `arr[0]` 會引用 `0x101`。

**3. 當 for 迴圈的 `i = 1`：**

![](https://i.imgur.com/BwcuLh2.png)

* 建立一個新的函數物件，存放在匿名盒子 `0x102`。
* `0x102` 函數物件內容的 `console.log(i)`，會引用變數 `i`。
* `arr[1]` 會引用 `0x102`。

**4. 當 for 迴圈的 `i = 2`：**

![](https://i.imgur.com/IiIxo0C.png)

* 建立一個新的函數物件，存放在匿名盒子 `0x103`。
* `0x103` 函數物件內容的 `console.log(i)`，會引用變數 `i`。
* `arr[2]` 會引用 `0x103`。

**5. 當 for 迴圈的 `i = 3`，離開迴圈：**

![](https://i.imgur.com/lCIoFlR.png)

* 變數 `i` 的值會再被加一，所以 `i` 的內容是 `3`，然後才跳離迴圈。

**6. 回傳 `arr` 到 Global 環境：**

```javascript
function buildFunctions() {
  ............
  return arr;
}

var fs = buildFunctions();
.............
```

![](https://i.imgur.com/HN24G2z.png)

* Global 環境宣告變數 `fs`，承接 `arr` 所存的位址 `0x002`。
* 變數 `fs` 和 `0x002` 建立引用關係。

**7. Local 環境結束，回收用不到的盒子：**

```javascript
................

var fs = buildFunctions();
fs[0]();
fs[1]();
fs[2]();
```

![](https://i.imgur.com/Sel9rpk.png)

* `0x001` 的變數名稱 `arr` 失效，變成匿名盒子，且沒有被任何人引用，回收。
* `0x003` 的變數名稱 `i` 失效，變成匿名盒子，但因為還被其他人引用，因此繼續存活。
* `0x002` 原本就是匿名盒子，原本的引用者 `arr` 被回收，但仍被 Global 變數 `fs` 引用，因此繼續存活。
* `fs[0]`、`fs[1]`、`fs[2]` 裡面的 `console.log(i)` 都是引用 `0x003`，因此印出來都是 `3`。

### 範例 2

延續上面的範例，如果希望印出來是 `0`、`1`、`2` 呢？

可以利用 `let` 宣告一個 Block Scope 的變數來達到效果：

```javascript
function buildFunctions() {
  var arr = [];

  for(var i = 0; i < 3; i++) {
    let j = i;             // 用 `let` 宣告變數 `j`
    arr.push(function() {
      console.log(j);      // 這裡引用變數 `j`
    });
  }

  return arr;
}

var fs = buildFunctions();
fs[0]();
fs[1]();
fs[2]();
```

執行結果：

```
0
1
2
```

為什麼這樣就能印出 `0`、`1`、`2`？

**1. 呼叫 `buildFunctions()`，宣告陣列變數：**

```javascript
function buildFunctions() {
  var arr = [];
  ...........
}

var fs = buildFunctions();
..........
```

![](https://i.imgur.com/uVMczgK.png)

* 這裡和範例 1 沒有差別。

**2. 進入 for 迴圈，當 for 迴圈的 `i = 0`，建立新的 Block Scope：**

```javascript
function buildFunctions() {
  ..................
  for(var i = 0; i < 3; i++) {
    let j = i;
    ...................
  }
  .......................
}

................
```

![](https://i.imgur.com/3QUWxvI.png)

* 用 `var` 宣告變數 `i` 作為計數器，`i` 屬於 Function Scope。
* 用 `let` 宣告變數 `j`，**因為用 `let` 宣告，會產生一個新的 Block Scope**。
* `j` 複製一份當前 `i` 的值。

**3. 當 for 迴圈的 `i = 0`，產生新的函數物件：**

```javascript
function buildFunctions() {
  .....................
  for(var i = 0; i < 3; i++) {
    let j = i;
    arr.push(function() {
      console.log(j);
    });
  }
  ................
}

.............
```

![](https://i.imgur.com/A5PQuEJ.png)

* 建立一個新的函數物件，存放在匿名盒子 `0x101`。
* `0x101` 函數物件內容的 `console.log(j)`，會引用變數 `j`。
* `arr[0]` 會引用 `0x101`。

**4. 當 for 迴圈的 `i = 0` 結束，Block Scope 失效：**

![](https://i.imgur.com/gksbidh.png)

* `0x004` 的變數名稱 `j` 失效，變成匿名盒子，但因為還被其他人引用，因此繼續存活。

**5. 當 for 迴圈的 `i = 1`，建立新的 Block Scope：**

```javascript
function buildFunctions() {
  ..................
  for(var i = 0; i < 3; i++) {
    let j = i;
    ...................
  }
  .......................
}

................
```

![](https://i.imgur.com/mIg5qFx.png)

* 再次用 `let` 在新的 Block Scope 宣告變數 `j`，儲存當前 `i` 的值。
* 和 `i = 0` 時是不同的 Block Scope，所以 `j` 的變數資料互相無關。

**6. 當 for 迴圈的 `i = 1`，產生新的函數物件：**

```javascript
function buildFunctions() {
  .....................
  for(var i = 0; i < 3; i++) {
    let j = i;
    arr.push(function() {
      console.log(j);
    });
  }
  ................
}

.............
```

![](https://i.imgur.com/EINWfm5.png)

* 建立一個新的函數物件，存放在匿名盒子 `0x102`。
* `0x102` 函數物件內容的 `console.log(j)`，會引用變數 `j`。
* `arr[1]` 會引用 `0x102`。

**7. 當 for 迴圈的 `i = 1` 結束，Block Scope 失效：**

![](https://i.imgur.com/gfRDN8N.png)

* `0x005` 的變數名稱 `j` 失效，變成匿名盒子，但因為還被其他人引用，因此繼續存活。

**8. 當 for 迴圈的 `i = 3`，離開迴圈：**

![](https://i.imgur.com/ZOGgoxp.png)

* 當 `i = 2` 的情況依此類推。
* 當 `i = 3` 時，然後跳離迴圈。

**9. 將 `arr` 回傳到 Global 環境：**

```javascript
function buildFunctions() {
  ............
  return arr;
}

var fs = buildFunctions();
.............
```

![](https://i.imgur.com/2HTBRz8.png)

* Global 環境宣告變數 `fs`，承接 `arr` 所存的位址 `0x002`。

**10. Local 環境結束，回收用不到的盒子：**

```javascript
................

var fs = buildFunctions();
fs[0]();
fs[1]();
fs[2]();
```

![](https://i.imgur.com/S9KpfGp.png)

* `0x001` 的變數名稱 `arr` 失效，變成匿名盒子，且沒有被任何人引用，回收。
* `0x003` 的變數名稱 `i` 失效，變成匿名盒子，且沒有被任何人引用，回收。
* `0x002` 是匿名盒子，被 Global 變數 `fs` 引用，繼續存活。
* `fs[0]`、`fs[1]`、`fs[2]` 裡面的 `console.log(j)` 各自引用 `0x004`、`0x005`、`0x006`，因此印出結果是 `0`、`1`、`2`。

### 範例 3

如果範例 2 不是用 `let` 宣告變數 `j`，改用 `var`，其他程式碼都不變：

```javascript
function buildFunctions() {
  var arr = [];

  for(var i = 0; i < 3; i++) {
    var j = i;              // 改用 `var` 宣告
    arr.push(function() {
      console.log(j);
    });
  }

  return arr;
}

fs2 = buildFunctions();

fs2[0]();
fs2[1]();
fs2[2]();
```

會印出 `0`、`1`、`2` 還是 `3`、`3`、`3` 呢？

執行結果：

```
2
2
2
```

![](https://i.imgur.com/ap8KLrg.png)\
(Source: [網路圖片](https://fs2.my-bras.com/upload/ftp/00.Web/04.Column/Chau.jpg))

為什麼？

**這是因為變數宣告的 Hoisting 效果**，會將變數宣告提到 Scope 最頂端，**而用 `var` 宣告的變數屬於 Function Scope Level**，所以變數 `i` 和 `j` 都相當於宣告在函數一開始：

```javascript
function buildFunctions() {
  var arr = [];
  var i, j;    // 因為 Hoisting 效果，相當於宣告在這
  for(i = 0; i < 3; i++) {
    j = i;
    arr.push(function() {
      console.log(j);
    });
  }

  return arr;
}
```

所以範例 3 和 範例 1 的狀況是相近的，在函數 `buildFunctions()` 內只會產生一次 `j` 的變數盒子，三個閉包函數都是引用同一個 `j` 的資料盒子，所以印出來的結果都一樣。

而之所以印出 `2` 而非 `3`，是 for 迴圈的關係，`j = i` 在 for 迴圈內執行，但當 `i = 3` 時並不會進入迴圈內，因此 `j` 會停留在 `2`。

這個範例原理和範例 1 類似，就不附分解示意圖 (用手畫可能不用 1 分鐘，畫成投影片超乎想像地費時……Orz)。

## 總結

變數的引用 (References) 相對抽象，需要自己想像變數間的牽連。

閉包概念就是建立在函數和引用的基礎上，而程式碼可能組成的情境又是千變萬化，一個疏忽可能就想錯了結果。

為了確保自己不會想錯程式邏輯，找到一個套路，類似數學公式的效果，幫助開發過程不管遇到什麼程式碼情境，都能套用同一套思考模式來導出正確的行為，也就是我暱稱的銀色子彈。

從這篇文章的範例來看，銀色子彈的效果還不錯，可以避免自己一些似是而非的直覺性邏輯。

## References

* [W3Schools - JavaScript Closures](https://www.w3schools.com/js/js_function_closures.asp)
* [JavaScript Function Closure (閉包)](https://www.fooish.com/javascript/function-closure.html)
* [Javascript中的傳遞參考與closure (2)](https://ithelp.ithome.com.tw/articles/10130860)
* [Day 20 閉包](https://ithelp.ithome.com.tw/articles/10207900)


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://something-about-js-book.onejar99.com/day29.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
