魯斯前端布魯斯前端

文章中英模式

布魯斯前端JS面試題目 - JavaScript 閉包(Closure)

從基礎理解 JavaScript 閉包(Closure)與 Lexical Scope,並搭配 useState 模型理解 React 如何記住 state,搭配記憶體洩漏案例與常見面試題,全面掌握閉包原理與陷阱。

影片縮圖

懶得看文章?那就來看影片吧

什麼是閉包?JavaScript 為什麼需要它?

閉包(Closure)是指一個函式和它定義時的外部變數的組合。即使函式被傳遞到不同的作用域執行,它仍然可以存取當時建立時的變數。

  • 保存函式中的中間狀態(例如計數器)
  • 封裝資料,實作私有變數
  • 提供記憶性(如 memoization)
  • 處理非同步流程與事件回呼
function createCounter() {
  let count = 0;
  return function () {
    count += 1;
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2

詞法作用域(Lexical Scope)是什麼?和閉包有什麼關係?

JavaScript 採用詞法作用域(Lexical Scope),也就是「函式可以存取的是它定義當下的變數環境,而不是它執行當下的環境」。

閉包就是這個機制的延伸,讓函式在未來仍然能記得定義當下的變數。

function outer() {
  const name = 'Bruce';
  function greet() {
    console.log('Hello, ' + name);
  }
  return greet;
}

const sayHello = outer();
sayHello(); // Hello, Bruce

記憶體洩漏的真相:閉包用不好就變成地雷

閉包延長變數生命週期,若引用大型資料或DOM元素,又與長壽命結構綁定,會導致記憶體無法釋放。

範例 1:全域變數與 DOM 結合

let cache = {};

function bindElement(elementId) {
  const element = document.getElementById(elementId);
  cache[elementId] = () => {
    console.log(element.innerText);
  };
}

bindElement('myDiv');

DOM元素被閉包引用並存在全域cache中,即使元素被移除,GC也無法回收。解決:不需要時設置 cache[elementId] = null。

範例 2:setTimeout 長期引用變數

function setupLogger() {
  let bigData = new Array(1000000).fill('*');
  setTimeout(() => {
    console.log(bigData[0]);
  }, 10000);
}

計時器回調引用大型陣列,即使函式執行完畢,bigData仍保留在記憶體10秒。問題在於閉包讓setTimeout持續引用bigData,阻止垃圾回收。解決:清除計時器或只保留必要資料。

React 的 useState 為什麼能記住值?多次呼叫 Hook 為什麼需要閉包?

React 在每次 render 時會重新執行整個元件函式。但 useState 回傳的 getter/setter 是閉包,能記住對應狀態。

function simpleUseState(initialValue) {
  let value = initialValue;
  return [
    () => value,
    (newVal) => { value = newVal; }
  ];
}

const [getA, setA] = simpleUseState(1);
const [getB, setB] = simpleUseState(100);

setA(2);
console.log(getA()); // 2
console.log(getB()); // 100

🔥 常見面試題目

(一)什麼是閉包?JavaScript 為什麼需要它?

閉包是函式與其變數環境的組合,讓函式記住定義時的變數。JavaScript 用它來保存狀態、封裝資料和處理回調。

function createCounter() {
  let count = 0;  // 這個變數被閉包保存
  
  return function() {
    count += 1;
    return count;
  };
}

const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

(二)詞法作用域是什麼?與閉包的關係?

詞法作用域指變數範圍由定義位置決定,非執行位置。閉包基於此特性,讓函式帶著「出生環境」到處執行。

let name = 'Global';

function outer() {
  let name = 'Outer';
  
  function inner() {
    console.log(name); // 使用的是 outer 的 name
  }
  
  return inner;
}

const innerFn = outer();
innerFn(); // 'Outer' - 即使在全域執行,仍記得定義時的環境

(三)如何用閉包實作私有變數?

閉包可創建私有變數,外部只能透過指定方法存取,實現類似物件導向的封裝概念。

function createSecret() {
  let secret = '1234';
  return {
    get() { return secret; },
    set(val) { secret = val; }
  };
}

const user = createSecret();
console.log(user.get()); // '1234'
user.set('abcd');
console.log(user.get()); // 'abcd'
// console.log(secret); // 錯誤!外部無法直接存取

(四)React 的 useState 是怎麼利用閉包來記住值?多次調用 Hook 為什麼需要閉包?

每個 useState 創建獨立閉包,為每個狀態提供專屬「保險箱」,使狀態在重新渲染時保持獨立且不互相干擾。

function simpleUseState(initialValue) {
  let value = initialValue;
  return [
    () => value,
    (newVal) => { value = newVal; }
  ];
}

const [getA, setA] = simpleUseState(1);
const [getB, setB] = simpleUseState(100);

setA(2);
console.log(getA()); // 2
console.log(getB()); // 100

(五)閉包可能造成記憶體洩漏的情境有哪些?

閉包持續引用大型資料或DOM元素且未釋放時,會阻止垃圾回收,像忘關水龍頭般消耗資源。

  • 全域變數綁定 DOM(造成元素無法被 GC)
  • setTimeout 或事件監聽綁定變數未清除
// 記憶體洩漏範例
function setupEventHandler() {
  const largeData = new Array(10000).fill('data');
  
  document.getElementById('button').addEventListener('click', function() {
    console.log(largeData.length); // 持續引用大型資料
  });
  
  // 正確做法:保存引用並在適當時機移除
  // const handler = function() { console.log(largeData.length); };
  // document.getElementById('button').addEventListener('click', handler);
  // 之後可以用 removeEventListener('click', handler) 移除
}

(六)工廠函式與閉包的關聯是什麼?

工廠函式利用閉包批量生產功能相似但參數不同的函式,每個函式都記住自己的獨立環境。

function createMultiplier(factor) {
  return function(num) {
    return num * factor;
  };
}

const double = createMultiplier(2);
const triple = createMultiplier(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15