在昨天我們搞懂了 JS 的記憶體模型與不可變性。今天我們要第二個 JS 觀念:閉包 (Closure)。
簡單來說:「當一個內部函數被定義時,它會『封裝』並記住其外層作用域 (Scope) 的變數,即使外層函數已經執行完畢,這些變數依然會被保留在記憶體中。」
看一個 JS 範例:
function createCounter() {
let count = 0; // 外層變數
return function increment() {
count++; // 內部函數存取外層變數
console.log(count);
};
}
const counter = createCounter(); // createCounter 執行完了
counter(); // 1
counter(); // 2 (count 沒有被回收仍然被記憶)
記憶體拆解:
createCounter() 執行完畢後,照理說裡面的 count 應該被銷毀(GC 垃圾回收)。
但因為回傳的 increment 函數引用了外層的 count,形成了一個「閉包包囊 (Closure Environment)」。
只要 counter 函數還存在,count 變數就會一直留在記憶體中。
React 組件本質上只是一個函數,每次 Re-render 都是重新呼叫該函數。為什麼裏頭的變數不會被重置?
我們寫一個極簡 useState 模擬:
// 模擬 React 的全域/模組作用域
let _state;
function useState(initialValue) {
// 如果是第一次呼叫,就用 initialValue,否則使用保留的 _state
_state = _state === undefined ? initialValue : _state;
function setState(newValue) {
_state = newValue;
render(); // 觸發重新渲染
}
return [_state, setState]; // setState 透過閉包記住了 _state
}
當組件呼叫 setState 時,setState 透過閉包存取並修改了外層的 _state 變數。這就是 React 能在多次渲染間「記錄歷史 State」的底層邏輯。
閉包能保留變數是優點,但如果用錯,就會變成最可怕的 Bug:過時閉包 (Stale Closure)。
看這段常見的錯誤寫法:
function Counter() {
const [count, setCount] = useState(0);
const handleAlert = () => {
setTimeout(() => {
// 3 秒後印出 count
alert(`Count is: ${count}`);
}, 3000);
};
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>加 1</button>
<button onClick={handleAlert}>3秒後 Alert</button>
</div>
);
}
實驗步驟:
1. 點擊「3秒後 Alert」。
2. 在 3 秒內快速點擊「加 1」三次(此時畫面上的 Count 變成 3)。
3. 結果:3 秒到的時候,Alert 彈出的數字是 0!
為什麼會這樣?
React 的每一次渲染,都有它獨立的 State 快照與作用域。
當你按下「3秒後 Alert」時,handleAlert 閉包捕捉的是當下那一幀 (count = 0) 的作用域。
隨後你點擊加 1,React 觸發了全新的渲染(產生了新的 count 變數),但原本那個 timer 裡面的閉包依然抓著舊那一幀的 0 不放。
// 捕捉舊閉包的 count
setCount(count + 1);
// 直接從參數拿到最新的 state
setCount(prevCount => prevCount + 1);
const countRef = useRef(count);
countRef.current = count; // 每次渲染更新 current
setTimeout(() => {
alert(`Count is: ${countRef.current}`); // 永遠讀到最新值
}, 3000);