JavaScript 閉包與作用域簡單解析
你正在寫一個 JavaScript 函式,它會回傳另一個函式。內部函式使用了外部函式的變數,但當你稍後呼叫它時,外部函式早已執行完畢。然而那個變數仍然存在。這是怎麼辦到的?這就是閉包與作用域的魔法。許多開發者對這個概念感到困惑,因而產生像是意外的變數值或記憶體洩漏等錯誤。在本文中,我們將用簡單的方式並搭配實用範例,拆解 JavaScript 閉包與作用域。
JavaScript 中的作用域是什麼?
作用域決定變數在程式碼中哪些地方可以被存取。JavaScript 主要有三種作用域:
- 全域作用域:在任何函式或區塊之外宣告的變數,可全域存取。
- 函式作用域:在函式內宣告的變數,只能在該函式內存取。
- 區塊作用域:使用
let和const在區塊{}內宣告的變數,只能在該區塊內存取。
這裡有個快速範例:
let globalVar = 'I am global';
function myFunc() {
let functionVar = 'I am in function scope';
if (true) {
let blockVar = 'I am in block scope';
console.log(globalVar); // accessible
console.log(functionVar); // accessible
console.log(blockVar); // accessible
}
console.log(blockVar); // ReferenceError: blockVar is not defined
}
myFunc();
console.log(functionVar); // ReferenceError: functionVar is not defined
理解作用域至關重要,因為它能防止變數衝突並協助管理記憶體。
詞法作用域:閉包的基礎
JavaScript 使用詞法作用域,意思是變數的作用域由其原始碼中的位置決定。內部函式可以存取其外部(封閉)作用域中的變數。這也稱為靜態作用域。
考慮以下範例:
function outer() {
let outerVar = 'outer';
function inner() {
console.log(outerVar); // 'outer'
}
inner();
}
outer();
inner 函式可以存取 outerVar,因為它在詞法上位於 outer 內部。這不是動態的;它不取決於函式如何被呼叫。
什麼是閉包?
閉包是一個函式,即使該函式在其詞法作用域之外執行,它仍記得自己的詞法作用域。換句話說,閉包讓你從內部函式存取外部函式的作用域。閉包會在每次建立函式時建立,也就是在函式建立時。
範例:
function makeCounter() {
let count = 0;
return function() {
count++;
return count;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
在這裡,makeCounter 回傳一個會遞增 count 的內部函式。即使 makeCounter 已經執行完畢,回傳的函式仍然可以存取 count。這就是閉包。
閉包在底層如何運作
當函式被建立時,它會儲存對其詞法環境(作用域鏈)的參照。當函式被呼叫時,它會使用該環境來解析變數。只要閉包存在,該環境就會一直保持存活。
閉包的實務用途
閉包不只是理論;它們在 JavaScript 中無處不在。
1. 資料隱私(模組模式)
在 ES6 模組之前,閉包被用來建立私有變數:
const counterModule = (function() {
let count = 0;
return {
increment: function() { count++; },
getCount: function() { return count; }
};
})();
counterModule.increment();
console.log(counterModule.getCount()); // 1
console.log(counterModule.count); // undefined
在這裡,count 是私有的,只能透過回傳的方法存取。
2. 事件處理常式與回呼
閉包常用於事件處理常式中,以存取外部作用域的變數:
function setupButton() {
let clickCount = 0;
document.getElementById('myButton').addEventListener('click', function() {
clickCount++;
console.log(`Button clicked ${clickCount} times`);
});
}
事件處理常式閉包會記住 clickCount。
3. 函式工廠
你可以建立帶有預設參數的函式:
function multiplyBy(factor) {
return function(number) {
return number * factor;
};
}
const double = multiplyBy(2);
console.log(double(5)); // 10
常見陷阱與如何避免
如果不理解閉包,可能會導致意外行為。
迴圈變數捕獲
在迴圈中使用 var 會建立單一共用綁定:
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // prints 3, 3, 3
}, 100);
}
所有回呼共用同一個 i。要修正,請使用 let(區塊作用域)或 IIFE:
for (let i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // prints 0, 1, 2
}, 100);
}
記憶體洩漏
閉包會讓變數保持存活。如果閉包被附加到長生命週期的物件(例如 DOM 元素),它可能會阻止垃圾回收。當不再需要時,請務必移除事件監聽器。
作用域鏈與變數解析
當你存取變數時,JavaScript 會沿著作用域鏈查找:先找目前作用域,再找外部作用域,一直到全域作用域。如果找不到,就會拋出 ReferenceError。
範例:
let a = 'global';
function outer() {
let b = 'outer';
function inner() {
let c = 'inner';
console.log(a, b, c); // all accessible
}
inner();
}
outer();
inner 的作用域鏈是:inner -> outer -> global。
比較:var、let 和 const
| 關鍵字 | 作用域 | 提升 | 可重新賦值 |
|---|---|---|---|
| var | 函式 | 是(初始化為 undefined) | 是 |
| let | 區塊 | 是(但未初始化,TDZ) | 是 |
| const | 區塊 | 是(但未初始化,TDZ) | 否 |
理解這些差異是避免作用域相關錯誤的關鍵。
常見問題
JavaScript 中的閉包是什麼?
閉包是一個函式,即使在其詞法作用域之外執行,仍保留對該作用域的存取權。它讓函式能夠「記住」定義它的位置中的變數。
為什麼閉包很重要?
閉包能實現資料隱私、函式工廠,以及維護狀態的事件處理常式。它們是許多 JavaScript 模式的基礎。
如何避免閉包相關的錯誤?
使用 let 和 const 來達到區塊作用域,注意迴圈,並清理事件監聽器以防止記憶體洩漏。
準備好測試你的理解了?試試我們的 JSON Formatter 來實驗程式碼片段,並即時觀察閉包如何運作。