夜行手札

技術成長 · Web 開發 · JavaScript

JavaScript 的 Closure 是什麼?函式離開作用域後,為什麼還記得外面的變數?

閉包閉包閉
11 分鐘閱讀

上一篇談到作用域時,我們看過下面這段程式:

JavaScript
const name = "全域的Dennis";

function outer() {
    const name = "outer裡面的Dennis";

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

    return inner;
}

const showName = outer();

showName(); // outer裡面的Dennis

當時我們用 Lexical Scope(詞法作用域) 解釋:

函式可以存取哪些外部變數,主要由函式寫在哪裡決定,而不是最後從哪裡被呼叫。

inner() 是寫在 outer() 裡面的,所以它往外尋找 name 時,會先找到:

JavaScript
const name = "outer裡面的Dennis";

而不是最外層的:

JavaScript
const name = "全域的Dennis";

但是這段程式裡,還藏著另一個問題。

當我們執行:

JavaScript
const showName = outer();

咦?

outer() 的這次呼叫明明已經執行完畢,照一般情況來看,如果裡面的資料不再被任何地方參照,大概就會被 JavaScript 回收,那麼後面再次呼叫 showName() 時,

JavaScript
showName();  // outer裡面的Dennis

為什麼還能取得 "outer裡面的Dennis"

這就是今天要談的 Closure(閉包)

---

什麼是 Closure?

Closure 通常翻譯成「閉包」。

閉包?封閉的包?第一次看到時可能會覺得又是一個看起來很莫名其妙的東西。

但我們可以先用比較白話的方式理解:

Closure 是函式保留了自己建立時的作用域關係,所以即使函式離開原本的位置,仍然可以存取當時的外部變數。

正式一點的說法的話會是這樣:

Closure 是函式與它建立時所連結的詞法環境組合。

恩...有點抽象了,沒事我們慢慢拆開。

---
先看看小Tips。

Tips:什麼是詞法環境?

詞法環境可以先理解成:

JavaScript 用來記錄目前有哪些變數,以及函式往外尋找變數時,應該沿著哪條作用域路線走的環境。

沿用剛剛的程式範例的話。

inner() 建立時,它不只擁有自己的函式內容,也知道自己是寫在 outer() 裡面的。

因此它尋找 name 時,會先經過 outer() 的作用域,再繼續往外尋找。

Closure 並不是讓函式突然獲得超能力,而是讓它保留原本就已經建立好的作用域關係。

---

回到原本的例子

再看一次完整程式:

JavaScript
const name = "全域的Dennis";

function outer() {
    const name = "outer裡面的Dennis";

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

    return inner;
}

const showName = outer();

showName(); // outer裡面的Dennis

可以把執行過程拆成幾個步驟。

第一步:建立最外層的 name

JavaScript
const name = "全域的Dennis";

---

第二步:呼叫 outer()

JavaScript
const showName = outer();

進入 outer() 後,會建立屬於這次呼叫的區域變數:

JavaScript
const name = "outer裡面的Dennis";

接著建立 inner()

JavaScript
function inner() {
    console.log(name);
}

因為 inner() 是在 outer() 裡面建立的,所以它的作用域鏈會連到 outer() 的詞法環境。

---

第三步:把 inner 回傳出去

JavaScript
return inner;

然後要注意,這裡不是執行 inner()

JavaScript
return inner();

而是把函式本人回傳出去:

JavaScript
return inner;

因此:

JavaScript
const showName = outer();

可以暫時把結果想成 showName 接收了 inner 函式物件:

JavaScript
const showName = inner;

不過這只是方便理解的示意。實際上,外部不能直接存取 inner 這個名稱,而是透過 outer() 的回傳值取得那個函式。
然後這個 inner 同時還保留著建立時的作用域關係。

可以想像成函式被建立時,就決定了它往外尋找變數的路線。之後不管它被回傳,或賦值給哪個變數,或在什麼地方呼叫,它仍然會按照自己出生時的作用域尋找變數。

---

第四步:outer() 執行結束

照一般情況來看,函式執行完後,它的區域資料如果不再被需要,就有機會被 JavaScript 回收。

但是這次不一樣。

回傳出去的 inner 仍然會使用:

JavaScript
const name = "outer裡面的Dennis";

而且外面還保存著 inner 的參照:

JavaScript
const showName = outer();

所以 JavaScript 不能直接把 inner 還會用到的外部環境丟掉。

---

第五步:呼叫 showName()

JavaScript
showName();

雖然現在是在最外層呼叫它,但函式尋找變數的路線不是根據「在哪裡呼叫」,而是根據「在哪裡建立」。

因此它仍然會先找到:

JavaScript
const name = "outer裡面的Dennis";

最後輸出:

text
outer裡面的Dennis

這就是 Closure。

---

Closure 不是把值拍照喀擦保存下來

這裡有一個很常見的誤解。

有人會把 Closure 想成:

函式建立時,把外面的值複製一份藏在自己身上。

但 Closure 通常不是單純保存一張數值快照,而是保留對外部變數綁定的存取能力。

來看一個例子:

JavaScript
function createCounter() {
    let count = 0;

    return function () {
        count++;
        return count;
    };
}

const counter = createCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

如果 Closure 只是把 count 最初的值 0 拍照保存,那每次呼叫都應該從 0 開始。

但實際上輸出是:

text
1
2
3

表示回傳的函式一直在存取並修改同一個 count

程式流程走走

首先,我們先宣告一個用來「製造計數器」的函式。
目前只是把計數器的製作方式寫好,還沒有真的建立計數器:

JavaScript
function createCounter() {

每次呼叫 createCounter() 時,程式都會建立一個新的 count,並讓它從 0 開始:

JavaScript
let count = 0;

接著,createCounter() 會回傳一個可以存取並修改 count 的函式:

JavaScript
return function () {
    count++;
    return count;
};

這裡回傳的是函式本身,不是把 count 複製一份後一起丟出去。

被回傳的函式會透過 Closure,繼續連結到這次 createCounter() 呼叫所建立的 count

接下來,當我們真正呼叫 createCounter() 時,程式才會建立這一台計數器,並把回傳的函式交給 counter 保管:

JavaScript
const counter = createCounter();

之後每次呼叫 counter(),它都會沿著建立時保留的作用域關係,找到同一個 count,再把它加 1

JavaScript
counter();

因此連續呼叫時,結果會依序增加:

JavaScript
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

它並不是每次呼叫時都重新建立一個 count,而是持續修改同一次 createCounter() 呼叫所建立的那個 count

---

每次建立的 Closure 都有自己的狀態

再看下面這個例子:

JavaScript
function createCounter() {
    let count = 0;

    return function () {
        count++;
        return count;
    };
}

const counterA = createCounter();
const counterB = createCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2

console.log(counterB()); // 1
console.log(counterB()); // 2

counterAcounterB 雖然都使用同一份函式程式碼,但它們來自兩次不同的 createCounter() 呼叫。

每次呼叫 createCounter(),都會建立一個新的 count

JavaScript
const counterA = createCounter(); // 建立第一個count
const counterB = createCounter(); // 建立第二個count

所以:

  • counterA 操作自己的 count
  • counterB 操作自己的 count
  • 兩邊不會互相影響

---

Closure 可以用來保存私有狀態

Closure 很適合用來保存不希望外部直接修改的資料。

例如建立一個簡單的銀行帳戶:

JavaScript
function openBankAccount(owner, initialBalance) {
    let balance = initialBalance;

    return {
        deposit(amount) {
            if (amount <= 0) {
                return "存款金額必須大於0";
            }

            balance += amount;

            return `${owner}存款成功,目前餘額為${balance}元`;
        },

        withdraw(amount) {
            if (amount <= 0) {
                return "提款金額必須大於0";
            }

            if (amount > balance) {
                return "提款失敗,餘額不足";
            }

            balance -= amount;

            return `${owner}提款成功,目前餘額為${balance}元`;
        },

        getBalance() {
            return balance;
        }
    };
}

const dennisAccount = openBankAccount("Dennis", 5000);

console.log(dennisAccount.deposit(1000));
// Dennis存款成功,目前餘額為6000元

console.log(dennisAccount.withdraw(2000));
// Dennis提款成功,目前餘額為4000元

console.log(dennisAccount.getBalance());
// 4000

openBankAccount() 執行完畢後,外面的程式不能直接取得裡面的 balance

JavaScript
console.log(balance); // ReferenceError

但是 deposit()withdraw()getBalance() 都是在同一次 openBankAccount() 呼叫中建立的,所以能透過 Closure 繼續存取同一個 balance

外部只能使用銀行帳戶提供的方法:

JavaScript
dennisAccount.deposit(1000);
dennisAccount.withdraw(2000);
dennisAccount.getBalance();

簡單來說:

你可以透過銀行提供的功能存款、提款和查詢餘額,但不能直接跑進系統裡,把自己的餘額改成一億元。

Tips:什麼是私有狀態?

私有狀態可以理解成:

資料存在於系統內部,外部不能直接修改,只能透過系統提供的方法操作。

這個例子中的 balance,就是被 Closure 保留下來的私有狀態。

不過,Closure 只是建立私有狀態的其中一種方式。

現代 JavaScript 也可以使用 class 的私有欄位,例如:

JavaScript
class BankAccount {
    #balance = 0;
}

不同做法適合不同情境,不代表所有私有資料都一定要使用 Closure。

---

Closure 可以建立帶有不同設定的函式

Closure 也常用來產生具有不同設定的函式。

例如:

JavaScript
function createMultiplier(multiplier) {
    return function (number) {
        return number * multiplier;
    };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);

console.log(double(5)); // 10
console.log(triple(5)); // 15

呼叫:

JavaScript
const double = createMultiplier(2);

會建立一個記得 multiplier = 2 的函式。

呼叫:

JavaScript
const triple = createMultiplier(3);

則會建立另一個記得 multiplier = 3 的函式。

所以即使後面都傳入相同的 5

JavaScript
double(5);
triple(5);

結果仍然不同。

Tips:什麼是工廠函式?

createMultiplier() 這類用來建立並回傳其他函式的函式,常被稱為 Factory Function(工廠函式)

白話來說:

它不是直接完成最後工作,而是根據設定,幫你產出一個專用函式。

像工廠接受不同規格:

JavaScript
createMultiplier(2);
createMultiplier(3);

就能生產出不同功能的產品:

JavaScript
double;
triple;

---

Callback 為什麼還記得以前的資料?

Closure 在事件處理與 Callback 中也非常常見。

例如:

JavaScript
function createGreeting(name) {
    return function () {
        console.log(`晚安,${name}`);
    };
}

const greetDennis = createGreeting("Dennis");

setTimeout(greetDennis, 1000);

setTimeout() 會等一段時間後才執行 greetDennis

到那個時候,createGreeting() 早就執行完了。

但輸出仍然是:

text
晚安,Dennis

因為 Callback 保留了建立時對 name 的存取能力。

程式白話文翻譯

JavaScript
const greetDennis = createGreeting("Dennis");

先建立一個「之後不管在哪裡執行,都還記得 Dennis」的打招呼函式。

JavaScript
setTimeout(greetDennis, 1000);

把它交給 Timer,請它一秒後再執行。

即使一秒後原本的外層函式早就結束,Callback 仍然知道自己要向誰打招呼。

---

Closure 和經典的 var 迴圈題

說到 Callback 和 Closure,就一定會提到這個經典題目:

JavaScript
for (var i = 0; i < 3; i++) {
    setTimeout(function () {
        console.log(i);
    }, 0);
}

很多人第一次看到會猜:

text
0
1
2

但實際輸出是:

text
3
3
3

why?

因為這三個 Callback 都透過 Closure 存取同一個 i

var 沒有一般的區塊作用域,所以整個迴圈共用同一個 i

JavaScript
var i;

每跑一次迴圈,都只是修改同一個變數:

JavaScript
i = 0;
i = 1;
i = 2;
i = 3;

setTimeout() 裡面的 Callback 不會立刻執行。

等同步迴圈全部跑完時:

JavaScript
i === 3

三個 Callback 才陸續執行。

它們不是各自保存了 012,而是全部回去讀取同一個已經變成 3i

所以輸出就是:

text
3
3
3

Tips:不是 Closure 壞掉了

這裡不是 Closure 沒有作用。

剛好相反,正是因為 Closure 正常運作,三個 Callback 才能在迴圈結束後繼續找到外面的 i

真正的問題是:

三個 Callback 找到的是同一個變數,也就是同樣的 i。

---

改成 let 為什麼就正常了?

JavaScript
for (let i = 0; i < 3; i++) { //只把var改成let
    setTimeout(function () {
        console.log(i);
    }, 0);
}

輸出:

text
0
1
2

因為 letfor 迴圈中,會為每次迭代建立新的變數綁定。

可以暫時想成:

JavaScript
{
    let i = 0;

    setTimeout(function () {
        console.log(i);
    }, 0);
}

{
    let i = 1;

    setTimeout(function () {
        console.log(i);
    }, 0);
}

{
    let i = 2;

    setTimeout(function () {
        console.log(i);
    }, 0);
}

因此三個 Callback 各自保留不同的 i

  • 第一個記得 0
  • 第二個記得 1
  • 第三個記得 2

這不是單純因為 letvar 新,而是因為兩者的作用域和迴圈綁定方式不同。

---

Closure 一定要等外層函式結束後才存在嗎?

不一定。

廣義來說,函式建立時,就會保留與建立位置之外部詞法環境的連結。

只是當內層函式被回傳出去,並且在外層函式執行結束後,仍然可以存取原本的外部變數時,Closure 的效果會變得特別明顯。

例如:

JavaScript
function outer() {
    const message = "Hello";

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

    inner();

    return inner;
}

const savedInner = outer();
// Hello

savedInner();
// Hello

第一次執行:

JavaScript
inner();

是在 outer() 還沒結束時發生的。

因為 inner() 建立在 outer() 裡面,所以可以透過詞法作用域取得外面的 message

接著,outer()inner 函式回傳出去:

JavaScript
return inner;

並由外面的 savedInner 保存:

JavaScript
const savedInner = outer();

這時 outer() 的這次呼叫已經執行完畢,但當我們再次呼叫:

JavaScript
savedInner();

它仍然可以取得原本的 message,所以再次輸出:

text
Hello

這表示 inner 函式保留了與這次 outer() 呼叫所建立之詞法環境的連結。

Tips:廣義說法和常見說法

廣義來說,函式建立時就已經保留了建立位置的外部詞法環境,因此具備 Closure 的特性。

不過在一般狀況中,大家通常會特別用下面這種情況解釋 Closure:

外層函式已經執行完畢,但被回傳出去的內層函式,仍然可以存取外層函式原本的變數。

這是 Closure 最經典、也最容易觀察的表現,但不是說一定要等外層函式結束後,Closure 才突然出現。

因此,不要只把 Closure 記成:

外層函式結束後,內層函式還能使用外部變數。

更完整的理解是:

函式會保留自己建立時與外部詞法環境的連結;當函式離開原本的位置後仍然可以使用外部變數時,這個特性就會特別明顯。

---

Closure 會造成 Memory Leak 嗎?

Closure 本身不等於 Memory Leak。

不過,如果一個函式長期存在,它所參照的外部資料也可能因此繼續被保留。

例如:

JavaScript
function createHandler() {
    const largeData = new Array(1000000).fill("資料");

    return function () {
        console.log(largeData.length);
    };
}

const handler = createHandler();

只要 handler 還存在,而且仍然會使用 largeData,JavaScript 就不能直接回收這份資料。

這不是錯誤。

如果程式本來就需要 largeData,保留它是合理的。

真正需要注意的是:

如果 Closure 長期參照已經不再需要的資料,就可能增加不必要的記憶體占用。

Tips:什麼是 Garbage Collection?

Garbage Collection 通常翻成「垃圾回收」。

JavaScript 會自動回收已經無法再被使用的記憶體。

但如果某份資料仍然可以透過變數、物件或 Closure 被找到,JavaScript 就會認為它可能還有用途,不會直接回收。

因此 Closure 不是記憶體問題的原罪。

真正的問題通常是:

  • 不必要的參照長期存在
  • 事件監聽沒有移除
  • Timer 沒有停止
  • 大型資料被 Callback 長期保留
  • 已經不需要的函式仍然被其他物件參照

---

Closure 的常見誤解

誤解一:一定要回傳內層函式,才算 Closure

不一定。

前面有提到,廣義來說,函式建立時就會與建立位置的外部詞法環境產生連結。

return 只是讓內層函式能夠離開外層函式,並在之後繼續被使用,因此特別容易觀察到 Closure 的效果,但不是 Closure 成立的必要條件。

---

誤解二:Closure 會把所有值複製一份

Closure 通常保留的是對外部變數綁定的存取,而不是單純複製當下的值。

所以外部變數後續改變時,Closure 可能讀到新的內容:

JavaScript
function outer() {
    let message = "晚安";

    const showMessage = function () {
        console.log(message);
    };

    message = "早安";

    return showMessage;
}

const showMessage = outer();

showMessage(); // 早安

如果 Closure 保存的是 "晚安" 的快照,結果就應該是晚安。

但實際輸出是:

text
早安

因為它存取的是同一個 message 綁定,而該變數後來已經改成 "早安"

---

誤解三:Closure 一定會造成記憶體洩漏

Closure 只是讓需要的外部環境繼續存在。

只要這些資料確實還有用途,就不是記憶體洩漏。

只有當不再需要的資料,因為錯誤的長期參照而無法被回收時,才可能形成 Memory Leak。

---

誤解四:每個 Closure 都共用同一份資料

不一定。

如果它們來自不同次外層函式呼叫,就會各自擁有獨立環境:

JavaScript
const counterA = createCounter();
const counterB = createCounter();

但如果多個函式是在同一次外層函式呼叫中建立,它們也可能共享同一個外部變數:

JavaScript
function createCounter() {
    let count = 0;

    return {
        increase() {
            count++;
        },

        decrease() {
            count--;
        },

        getCount() {
            return count;
        }
    };
}

increase()decrease()getCount() 都會操作同一個 count

---

為什麼理解 Closure 很重要?

Closure 並不是只會出現在面試題裡。

平常寫 JavaScript 時,很多功能背後都有 Closure:

  • 事件處理函式
  • setTimeout()setInterval()
  • Callback
  • 工廠函式
  • 私有狀態
  • React Hooks
  • Debounce 與 Throttle
  • 模組封裝
  • 函式柯里化
  • 非同步流程
  • 拉不拉多...
  • 黃金獵犬...

所以,當你看到某個函式在稍後執行時,仍然能取得先前的資料,背後通常都和 Closure 有關。

---

簡單整理

Closure 的核心概念是:

函式會保留自己建立時的作用域關係,因此即使離開原本的位置,仍然能存取相關的外部變數。

其中:

  • Closure 建立在 Lexical Scope 之上。
  • 函式尋找變數時,看的是宣告位置,不是呼叫位置。
  • 外層函式執行結束後,內層函式仍可能使用相關的外部變數。
  • Closure 保存的不是單純數值快照,而是對變數綁定的存取能力。
  • 每次呼叫外層函式,都可能建立獨立的詞法環境。
  • 多個 Closure 也可能共享同一次呼叫中的外部變數。
  • Closure 常用來保存狀態、封裝資料及建立特定設定的函式。
  • Closure 本身不是 Memory Leak,但長期保留不必要資料可能增加記憶體占用。
  • var 搭配非同步 Callback 時,多個函式可能存取同一個變數。
  • letfor 迴圈中會為不同迭代建立各自的變數綁定。

短短懶人包:

Closure 就像函式離開自己的領域後,身上仍然保留著回去尋找外部變數的通行證。

下一篇,我們可以繼續處理另一個很容易和作用域混在一起的概念:

JavaScript 的 this 到底指向誰?

作用域與 Closure 解決的是:

函式要去哪裡尋找變數?

this 處理的則是:

這個函式這次被呼叫時,this 代表的是誰?

兩者看起來有點像,但判斷方式完全不同。