cover

結論先講

Closure 不是面試題。你寫 React Hook、setInterval、事件處理器、資料快取每天都在用 closure,只是你不知道。

Closure 的本質一句話:函式「記得」它被定義時的外部變數,即使函式被搬到別的地方執行

這句話不懂?看下去。


Scope:變數能被看到的範圍

JavaScript 有三種 scope:

const globalVar = 'global';  // Global Scope
 
function outer() {
  const functionVar = 'function';  // Function Scope
 
  if (true) {
    const blockVar = 'block';  // Block Scope(let/const)
    console.log(globalVar, functionVar, blockVar);  // 全看得到
  }
 
  console.log(blockVar);  // ❌ ReferenceError
}

關鍵概念:Lexical Scope(詞法作用域)

函式能看到什麼變數,是根據它「寫在哪」決定的,不是它「在哪被執行」。

function outer() {
  const x = 10;
 
  function inner() {
    console.log(x);  // inner 看得到 x,因為 inner 寫在 outer 裡面
  }
 
  return inner;
}
 
const fn = outer();
fn();  // 印出 10 — 即使 outer 已經執行完,inner 仍記得 x

就算 outer 已經跑完,inner 還是記得 x = 10這就是 closure


Closure 的三個實用場景

場景 1:資料私有化

JS 沒有 private 關鍵字(class private field 是新東西)。用 closure 做:

function createCounter() {
  let count = 0;  // 外部看不到
  return {
    increment: () => ++count,
    get: () => count,
  };
}
 
const counter = createCounter();
counter.increment();  // 1
counter.increment();  // 2
counter.get();        // 2
counter.count;        // undefined(外部存取不到)

count 被 closure「包住」。

場景 2:React Hook 的本質

function useCounter() {
  const [count, setCount] = useState(0);
 
  const increment = () => setCount(count + 1);
  //       ↑ 這個 closure 記得 count
 
  return { count, increment };
}

每次 re-render,useCounter 重跑,產生新的 count、新的 increment closure。舊的 closure 還「記得」舊的 count

這就是為什麼 hook 有「stale closure」問題(下面會講)。

場景 3:事件處理器延遲執行

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 印 0, 1, 2(用 let)
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 印 3, 3, 3(用 var)

差在哪?let 是 block scope,每次迴圈產生新的 i,每個 closure 記得自己的 ivar 是 function scope,三個 closure 共用同一個 i,迴圈跑完 i 變 3,三個都印 3。

這題是面試常考題,現在你知道背後機制了。


Stale Closure:React 最常見的 bug

function Timer() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count);  // 永遠印 0
      setCount(count + 1); // 永遠設成 1
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依賴陣列
 
  return <div>{count}</div>;
}

count 永遠是 0setCount 每次都設成 1。為什麼?

useEffect 只在 mount 時跑一次(空依賴),裡面的 closure 記得那次的 count = 0。往後即使 state 更新,closure 還是抓舊的 count

解法 1:用 functional updater

setCount(c => c + 1);  // 用上次的值,不依賴 closure

解法 2:把 count 放進依賴

useEffect(() => {
  const id = setInterval(() => {
    console.log(count);
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);  // 每次 count 變了,重建 interval

但這會每秒重建 interval,效能不好。

解法 3:用 ref 避開 closure

const countRef = useRef(count);
countRef.current = count;
 
useEffect(() => {
  const id = setInterval(() => {
    console.log(countRef.current);  // ref 永遠拿最新值
  }, 1000);
  return () => clearInterval(id);
}, []);

這三招你都該會。


Closure 造成記憶體洩漏

Closure 會「抓住」外部變數,JS 的 GC 不會回收被抓住的變數。

洩漏例子

function setupListener() {
  const hugeData = new Array(1000000).fill('data');  // 很大的資料
 
  document.getElementById('btn').addEventListener('click', () => {
    console.log('clicked');
    // closure 記得 hugeData,就算沒用到
  });
}
 
setupListener();
// hugeData 永遠不會被 GC,因為 listener 還活著

解法:明確釋放

let hugeData = new Array(1000000).fill('data');
 
const handler = () => console.log('clicked');
button.addEventListener('click', handler);
 
// 用完:
button.removeEventListener('click', handler);
hugeData = null;  // 讓 GC 能回收

React 裡的洩漏模式

useEffect(() => {
  const bigData = fetchBigData();
 
  const handler = () => {
    // 用到 bigData
  };
 
  window.addEventListener('scroll', handler);
 
  return () => {
    window.removeEventListener('scroll', handler);
    // 不 remove,組件卸載後 bigData 還被 handler 抓著
  };
}, []);

cleanup function 必寫。


Module Scope(ESM)

ES Module 有自己的 module scope,不是 global:

// utils.js
const secret = 'hidden';
export function getSecret() { return secret; }

secret 只在 utils.js 裡看得見。其他檔案要取得必須透過 getSecret()

這也算一種 closure — module 內的函式記得 module scope 的變數。


IIFE(立即執行函式):Closure 的古老用法

ES6 之前沒有 let,用 IIFE 造 block scope:

(function () {
  const privateVar = 'hidden';
  // 外面看不到 privateVar
})();

現代 JS 用 { ... }let 就好,IIFE 是歷史產物。知道就好,不用寫。


實戰 Checklist

  • 搞懂 Lexical Scope:函式記得「被寫在哪」的外部變數
  • React Hook 的依賴陣列搞錯 → stale closure
  • setCount(c => c + 1) 避免 stale closure
  • useRef 存「永遠最新值」的東西
  • Event listener 用完要 removeEventListener
  • useEffect 的 cleanup function 必寫
  • 迴圈裡的 async callback 用 let,不要用 var

相關文章