技術成長 · Web 開發 · JavaScript
JavaScript 的 Closure 是什麼?函式離開作用域後,為什麼還記得外面的變數?
上一篇談到作用域時,我們看過下面這段程式:
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 時,會先找到:
const name = "outer裡面的Dennis";而不是最外層的:
const name = "全域的Dennis";但是這段程式裡,還藏著另一個問題。
當我們執行:
const showName = outer();咦?
outer() 的這次呼叫明明已經執行完畢,照一般情況來看,如果裡面的資料不再被任何地方參照,大概就會被 JavaScript 回收,那麼後面再次呼叫 showName() 時,
showName(); // outer裡面的Dennis為什麼還能取得 "outer裡面的Dennis"?
這就是今天要談的 Closure(閉包)。
---
什麼是 Closure?
Closure 通常翻譯成「閉包」。
閉包?封閉的包?第一次看到時可能會覺得又是一個看起來很莫名其妙的東西。
但我們可以先用比較白話的方式理解:
Closure 是函式保留了自己建立時的作用域關係,所以即使函式離開原本的位置,仍然可以存取當時的外部變數。
正式一點的說法的話會是這樣:
Closure 是函式與它建立時所連結的詞法環境組合。
恩...有點抽象了,沒事我們慢慢拆開。
---
先看看小Tips。
Tips:什麼是詞法環境?
詞法環境可以先理解成:
JavaScript 用來記錄目前有哪些變數,以及函式往外尋找變數時,應該沿著哪條作用域路線走的環境。
沿用剛剛的程式範例的話。
當 inner() 建立時,它不只擁有自己的函式內容,也知道自己是寫在 outer() 裡面的。
因此它尋找 name 時,會先經過 outer() 的作用域,再繼續往外尋找。
Closure 並不是讓函式突然獲得超能力,而是讓它保留原本就已經建立好的作用域關係。
---
回到原本的例子
再看一次完整程式:
const name = "全域的Dennis";
function outer() {
const name = "outer裡面的Dennis";
function inner() {
console.log(name);
}
return inner;
}
const showName = outer();
showName(); // outer裡面的Dennis可以把執行過程拆成幾個步驟。
第一步:建立最外層的 name
const name = "全域的Dennis";---
第二步:呼叫 outer()
const showName = outer();進入 outer() 後,會建立屬於這次呼叫的區域變數:
const name = "outer裡面的Dennis";接著建立 inner():
function inner() {
console.log(name);
}因為 inner() 是在 outer() 裡面建立的,所以它的作用域鏈會連到 outer() 的詞法環境。
---
第三步:把 inner 回傳出去
return inner;然後要注意,這裡不是執行 inner():
return inner();而是把函式本人回傳出去:
return inner;因此:
const showName = outer();可以暫時把結果想成 showName 接收了 inner 函式物件:
const showName = inner;
不過這只是方便理解的示意。實際上,外部不能直接存取 inner 這個名稱,而是透過 outer() 的回傳值取得那個函式。
然後這個 inner 同時還保留著建立時的作用域關係。
可以想像成函式被建立時,就決定了它往外尋找變數的路線。之後不管它被回傳,或賦值給哪個變數,或在什麼地方呼叫,它仍然會按照自己出生時的作用域尋找變數。
---
第四步:outer() 執行結束
照一般情況來看,函式執行完後,它的區域資料如果不再被需要,就有機會被 JavaScript 回收。
但是這次不一樣。
回傳出去的 inner 仍然會使用:
const name = "outer裡面的Dennis";而且外面還保存著 inner 的參照:
const showName = outer();所以 JavaScript 不能直接把 inner 還會用到的外部環境丟掉。
---
第五步:呼叫 showName()
showName();雖然現在是在最外層呼叫它,但函式尋找變數的路線不是根據「在哪裡呼叫」,而是根據「在哪裡建立」。
因此它仍然會先找到:
const name = "outer裡面的Dennis";最後輸出:
outer裡面的Dennis這就是 Closure。
---
Closure 不是把值拍照喀擦保存下來
這裡有一個很常見的誤解。
有人會把 Closure 想成:
函式建立時,把外面的值複製一份藏在自己身上。
但 Closure 通常不是單純保存一張數值快照,而是保留對外部變數綁定的存取能力。
來看一個例子:
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 開始。
但實際上輸出是:
1
2
3表示回傳的函式一直在存取並修改同一個 count。
程式流程走走
首先,我們先宣告一個用來「製造計數器」的函式。
目前只是把計數器的製作方式寫好,還沒有真的建立計數器:
function createCounter() {每次呼叫 createCounter() 時,程式都會建立一個新的 count,並讓它從 0 開始:
let count = 0;接著,createCounter() 會回傳一個可以存取並修改 count 的函式:
return function () {
count++;
return count;
};這裡回傳的是函式本身,不是把 count 複製一份後一起丟出去。
被回傳的函式會透過 Closure,繼續連結到這次 createCounter() 呼叫所建立的 count。
接下來,當我們真正呼叫 createCounter() 時,程式才會建立這一台計數器,並把回傳的函式交給 counter 保管:
const counter = createCounter();之後每次呼叫 counter(),它都會沿著建立時保留的作用域關係,找到同一個 count,再把它加 1:
counter();因此連續呼叫時,結果會依序增加:
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3它並不是每次呼叫時都重新建立一個 count,而是持續修改同一次 createCounter() 呼叫所建立的那個 count。
---
每次建立的 Closure 都有自己的狀態
再看下面這個例子:
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()); // 2counterA 和 counterB 雖然都使用同一份函式程式碼,但它們來自兩次不同的 createCounter() 呼叫。
每次呼叫 createCounter(),都會建立一個新的 count:
const counterA = createCounter(); // 建立第一個count
const counterB = createCounter(); // 建立第二個count所以:
counterA操作自己的countcounterB操作自己的count- 兩邊不會互相影響
---
Closure 可以用來保存私有狀態
Closure 很適合用來保存不希望外部直接修改的資料。
例如建立一個簡單的銀行帳戶:
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());
// 4000openBankAccount() 執行完畢後,外面的程式不能直接取得裡面的 balance:
console.log(balance); // ReferenceError但是 deposit()、withdraw() 和 getBalance() 都是在同一次 openBankAccount() 呼叫中建立的,所以能透過 Closure 繼續存取同一個 balance。
外部只能使用銀行帳戶提供的方法:
dennisAccount.deposit(1000);
dennisAccount.withdraw(2000);
dennisAccount.getBalance();簡單來說:
你可以透過銀行提供的功能存款、提款和查詢餘額,但不能直接跑進系統裡,把自己的餘額改成一億元。
Tips:什麼是私有狀態?
私有狀態可以理解成:
資料存在於系統內部,外部不能直接修改,只能透過系統提供的方法操作。
這個例子中的 balance,就是被 Closure 保留下來的私有狀態。
不過,Closure 只是建立私有狀態的其中一種方式。
現代 JavaScript 也可以使用 class 的私有欄位,例如:
class BankAccount {
#balance = 0;
}不同做法適合不同情境,不代表所有私有資料都一定要使用 Closure。
---
Closure 可以建立帶有不同設定的函式
Closure 也常用來產生具有不同設定的函式。
例如:
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呼叫:
const double = createMultiplier(2);會建立一個記得 multiplier = 2 的函式。
呼叫:
const triple = createMultiplier(3);則會建立另一個記得 multiplier = 3 的函式。
所以即使後面都傳入相同的 5:
double(5);
triple(5);結果仍然不同。
Tips:什麼是工廠函式?
createMultiplier() 這類用來建立並回傳其他函式的函式,常被稱為 Factory Function(工廠函式)。
白話來說:
它不是直接完成最後工作,而是根據設定,幫你產出一個專用函式。
像工廠接受不同規格:
createMultiplier(2);
createMultiplier(3);就能生產出不同功能的產品:
double;
triple;---
Callback 為什麼還記得以前的資料?
Closure 在事件處理與 Callback 中也非常常見。
例如:
function createGreeting(name) {
return function () {
console.log(`晚安,${name}`);
};
}
const greetDennis = createGreeting("Dennis");
setTimeout(greetDennis, 1000);setTimeout() 會等一段時間後才執行 greetDennis。
到那個時候,createGreeting() 早就執行完了。
但輸出仍然是:
晚安,Dennis因為 Callback 保留了建立時對 name 的存取能力。
程式白話文翻譯
const greetDennis = createGreeting("Dennis");先建立一個「之後不管在哪裡執行,都還記得 Dennis」的打招呼函式。
setTimeout(greetDennis, 1000);把它交給 Timer,請它一秒後再執行。
即使一秒後原本的外層函式早就結束,Callback 仍然知道自己要向誰打招呼。
---
Closure 和經典的 var 迴圈題
說到 Callback 和 Closure,就一定會提到這個經典題目:
for (var i = 0; i < 3; i++) {
setTimeout(function () {
console.log(i);
}, 0);
}很多人第一次看到會猜:
0
1
2但實際輸出是:
3
3
3why?
因為這三個 Callback 都透過 Closure 存取同一個 i。
var 沒有一般的區塊作用域,所以整個迴圈共用同一個 i:
var i;每跑一次迴圈,都只是修改同一個變數:
i = 0;
i = 1;
i = 2;
i = 3;而 setTimeout() 裡面的 Callback 不會立刻執行。
等同步迴圈全部跑完時:
i === 3三個 Callback 才陸續執行。
它們不是各自保存了 0、1、2,而是全部回去讀取同一個已經變成 3 的 i。
所以輸出就是:
3
3
3Tips:不是 Closure 壞掉了
這裡不是 Closure 沒有作用。
剛好相反,正是因為 Closure 正常運作,三個 Callback 才能在迴圈結束後繼續找到外面的 i。
真正的問題是:
三個 Callback 找到的是同一個變數,也就是同樣的 i。
---
改成 let 為什麼就正常了?
for (let i = 0; i < 3; i++) { //只把var改成let
setTimeout(function () {
console.log(i);
}, 0);
}輸出:
0
1
2因為 let 在 for 迴圈中,會為每次迭代建立新的變數綁定。
可以暫時想成:
{
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
這不是單純因為 let 比 var 新,而是因為兩者的作用域和迴圈綁定方式不同。
---
Closure 一定要等外層函式結束後才存在嗎?
不一定。
廣義來說,函式建立時,就會保留與建立位置之外部詞法環境的連結。
只是當內層函式被回傳出去,並且在外層函式執行結束後,仍然可以存取原本的外部變數時,Closure 的效果會變得特別明顯。
例如:
function outer() {
const message = "Hello";
function inner() {
console.log(message);
}
inner();
return inner;
}
const savedInner = outer();
// Hello
savedInner();
// Hello第一次執行:
inner();是在 outer() 還沒結束時發生的。
因為 inner() 建立在 outer() 裡面,所以可以透過詞法作用域取得外面的 message。
接著,outer() 將 inner 函式回傳出去:
return inner;並由外面的 savedInner 保存:
const savedInner = outer();這時 outer() 的這次呼叫已經執行完畢,但當我們再次呼叫:
savedInner();它仍然可以取得原本的 message,所以再次輸出:
Hello這表示 inner 函式保留了與這次 outer() 呼叫所建立之詞法環境的連結。
Tips:廣義說法和常見說法
廣義來說,函式建立時就已經保留了建立位置的外部詞法環境,因此具備 Closure 的特性。
不過在一般狀況中,大家通常會特別用下面這種情況解釋 Closure:
外層函式已經執行完畢,但被回傳出去的內層函式,仍然可以存取外層函式原本的變數。
這是 Closure 最經典、也最容易觀察的表現,但不是說一定要等外層函式結束後,Closure 才突然出現。
因此,不要只把 Closure 記成:
外層函式結束後,內層函式還能使用外部變數。
更完整的理解是:
函式會保留自己建立時與外部詞法環境的連結;當函式離開原本的位置後仍然可以使用外部變數時,這個特性就會特別明顯。
---
Closure 會造成 Memory Leak 嗎?
Closure 本身不等於 Memory Leak。
不過,如果一個函式長期存在,它所參照的外部資料也可能因此繼續被保留。
例如:
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 可能讀到新的內容:
function outer() {
let message = "晚安";
const showMessage = function () {
console.log(message);
};
message = "早安";
return showMessage;
}
const showMessage = outer();
showMessage(); // 早安如果 Closure 保存的是 "晚安" 的快照,結果就應該是晚安。
但實際輸出是:
早安因為它存取的是同一個 message 綁定,而該變數後來已經改成 "早安"。
---
誤解三:Closure 一定會造成記憶體洩漏
Closure 只是讓需要的外部環境繼續存在。
只要這些資料確實還有用途,就不是記憶體洩漏。
只有當不再需要的資料,因為錯誤的長期參照而無法被回收時,才可能形成 Memory Leak。
---
誤解四:每個 Closure 都共用同一份資料
不一定。
如果它們來自不同次外層函式呼叫,就會各自擁有獨立環境:
const counterA = createCounter();
const counterB = createCounter();但如果多個函式是在同一次外層函式呼叫中建立,它們也可能共享同一個外部變數:
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 時,多個函式可能存取同一個變數。let在for迴圈中會為不同迭代建立各自的變數綁定。
短短懶人包:
Closure 就像函式離開自己的領域後,身上仍然保留著回去尋找外部變數的通行證。
下一篇,我們可以繼續處理另一個很容易和作用域混在一起的概念:
JavaScript 的 this 到底指向誰?作用域與 Closure 解決的是:
函式要去哪裡尋找變數?
而 this 處理的則是:
這個函式這次被呼叫時,this 代表的是誰?兩者看起來有點像,但判斷方式完全不同。