什麼是閉包 (Closure)?

2023年1月7日

💎 加入 E+ 成長計畫 與超過 300+ 位軟體工程師一同在社群中成長,並且獲得更多的軟體工程學習資源

閉包 (closure) 是程式語言的一種特性,在 JavaScript 中也扮演相當重要的角色,被廣泛應用在 JavaScript 程式庫中。許多被開發者大量使用的重要功能,也都看得到閉包的身影,舉例來說最熱門的 JavaScript 函式庫 React 中的 useState 就是透過閉包來實作。

以面試的角度來說,不僅僅要了解什麼是閉包,同時也要知道閉包會有什麼應用的情景。假如你目前仍不熟閉包的概念,或不確定可以怎麼應用,千萬要在面試前弄熟。

什麼是閉包?

MDN 文件中,閉包被定義為函式以及該函式被宣告時所在的作用域環境 (lexical environment) 的組合。白話一點說,閉包就是內部函式能夠取得函式外部的變數,並且記住這個變數。因為能夠記住這個外部變數,閉包很常被用來做狀態保存。

以下是一個最簡單的例子,在下方程式碼中的 inner 函式,能拿到外部函式 outer 的 a 變數,並將其保存在記憶體中。當我們呼叫 inner 時,之所以不是每次都回傳 1 ,而是回傳 123 不斷加上去,正是因為之前的 a 的狀態被記住了。

function outer() {
  let a = 0;
  function inner() {
    a += 1;
    console.log(a);
  }
  return inner;
}

const inner = outer();

inner(); // 1
inner(); // 2
inner(); // 3

我們可以理解成:閉包這種特性,可以讓我們在一個內層函式中 (這邊的 inner),訪問到外部函式的作用域 (這邊的 outer),並且會記住外部函式的變數(這邊的 a)。

在了解閉包是什麼後,接著我們來看看閉包的實際用途。

閉包的應用 1 — 狀態保存

在寫程式時,我們很常會需要記住某個狀態,JavaScript 的熱門函式庫 React 就有提供一個 useState 讓開發者來管理狀態。以下我們模擬一個簡化版的 useState ,可以在下方的程式碼看到,getStatesetState 作為內部函式,可以取得外部函式當中的 state,在實際呼叫後,如果這個 state 有改變,getState 可以持續取得最新改變的值。

// 因為閉包的關係,getState 與 setState 可以取得與記得 state
function useState(initialState) {
  let state = initialState;

  function getState() {
    return state;
  }

  function setState(updatedState) {
    state = updatedState;
  }
  return [getState, setState];
}

const [count, setCount] = useState(0);

count(); // 0
setCount(1);
count(); // 1
setCount(500);
count(); // 500

又或者先前 React 核心團隊成員 Sebastian Markbåge 分享的一段程式碼,在說 React 的 Server Actions 中,可以運用閉包來做版本檢查。下面這段程式碼 verifiedVersion 記住的是在首次渲染時拿到的版本,因為閉包的關係,內部的函式 publish 函式,能取得 verifiedVersion

這時,如果要做版本檢查,可以在 publish 裡面再呼叫一次 await getVersion,拿到當下的版本。這時就可以比較首次渲染時的版本,以及當下的版本,並當版本不同時,可以做處理 (這邊是返回一個錯誤訊息)

image

閉包的應用 2:緩存機制

因為閉包可以讓內部函式記住外部的變數,我們可以依照這個特性,透過閉包來實現緩存機制。以下面的例子來說,因為閉包原理,cache 變數可以被回傳的箭頭函式取得與記得,所以我們能夠重複用 cache 來放想要緩存的東西。關於這題的詳細解說可以見《手寫函式緩存 (cache function)》

function cached(fn) {
  const cache = {};

  // 被回傳的箭頭函式,可以取得外面的 cache 變數,同時記住這個變數
  // 因此可以把這個 cache 拿來存已經計算的結果
  return (...args) => {
    // 把輸入字符串化並當成 key
    const key = JSON.stringify(args);

    // 如果 key 已經在 cache 中,則不用重複算,直接回傳之前存的運算結果
    // 如果 key 還不在,則運算完後,把 key 與運算結果放到 cache 中,未來可以避免重複運算
    if (key in cache) {
      return cache[key];
    } else {
      const val = fn(...args);
      cache[key] = val;
      return val;
    }
  };
}

閉包的應用 3:模擬私有變數

許多程式語言有宣告私有方法的語法,這些私有變數對於外部來講是隱藏的,這是一項很重要的特性,因為有時候我們在開發的程式碼內部細節,並不想讓外部來獲取。JavaScript 並不支援私有變數,但我們可以透過閉包做出類似的功能。如下方程式碼範例:

// privateCounter 沒被法被外部修改,
// 因為閉包的關係 increment 與 decrement 可以存取到 privateCounter
// 因此 privateCounter 只能夠透過 increment 與 decrement 來改,這能有效避免被誤觸到
var counter = (function () {
  var privateCounter = 0;
  function changeBy(val) {
    privateCounter += val;
  }
  return {
    increment: function () {
      changeBy(1);
    },
    decrement: function () {
      changeBy(-1);
    },
    value: function () {
      return privateCounter;
    },
  };
})();

console.log(counter.value()); // logs 0
counter.increment();
counter.increment();
console.log(counter.value()); // logs 2
counter.decrement();
console.log(counter.value()); // logs 1

閉包缺點 — 內存洩漏

雖然說閉包很好用,但也不是沒有缺點。從記憶體的角度來看,閉包的缺點是顯而易見的,由於閉包會讓內部函式記得外部的變數,這可能會造成變數常駐在記憶體當中,如果使用過多可能會造成內存泄露 (memory leak),需要小心使用。

以下面的例子來說,longArray 沒有被使用到,但是因為閉包的原因會一直被 addNumbers 記住。假如今天 longArray 有被使用,那就沒問題,但因為它沒有被用到但仍存在於記憶體中沒被清除,這種情況就是典型的內存洩漏。

function outer() {
  const longArray = [];
  return function inner(num) {
    longArray.push(num);
  };
}
const addNumbers = outer();

for (let i = 0; i < 100000000; i++) {
  addNumbers(i);
}
🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們