JavaScript: 圖解JavaScript規則 - 閉包

21.06.01

Photo by Michal Matlon on Unsplash



這一系列其實是為了閉包這個主題而生的。要先理解作用域作用域鏈,才能進一步認識閉包;而要理解作用域,就需要認識詞彙環境(Lexical Environment)的架構。前面花了兩個篇幅建立基本知識,現在終於可以進入正題了。

既然前兩篇圖解了執行環境作用域作用域鏈之間的關係,本篇會著重在作用域鏈與閉包的關係。建議先看過前篇再往下閱讀,因為本篇不會深入解釋作用域鏈。

執行環境 Execution Context

JavaScript底層要執行的時候,第一步會先建立一個「執行環境」(Execution Context)。

執行環境內部有許多物件,其中最重要的是「詞彙環境」Lexical Environment(也有翻譯成「詞法環境」)。 詞彙環境主要儲存了:

  1. 全域物件,在有瀏覽器的環境下,指的是window內的物件
  2. 登記這個環境所宣告的變數和function(函式)
  3. 外部環境,也就是Scope Chain,台灣多半翻譯成「作用域(鏈)」,中國則翻譯成「範疇」
  4. 這個環境的”this”是什麼

作用域鏈

作用域「鏈」,指的是「除了我當下的的Declarative Environment Record,還可以去哪裏找變數?」

外部環境(Outer Envirnment)會標記可以查訪的Lexical Environment。假設有一條隱形的線將所有可查訪的Lexical Environment串起來,這條線可視為「尋找變數的搜索鏈」,即作用域「鏈」。

Closure閉包

閉包的定義可分為「廣義」及「狹義」。由於個人對實作的部分比較感興趣,本篇的焦點先會放在「狹義」的定義上,再討論實務上閉包有哪些使用情境。不過先從廣義閉包來做個小小的開場吧!

廣義 - 所有的函式都是閉包

從學理的角度,作用域鏈的機制,就是閉包。

MDN社群的文件寫道:

A closure is the combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.

閉包是function本身,與這個function被宣告時所在的環境(詞彙環境 the lexical environment)之間的連結。換句話說,在function內部,可以透過閉包存取這個function之外的作用域當中的變數。在JavaScript這個語言,每宣告一個function,閉包就隨之而成。

說白話點,每建立一個function,function與其父層就會形成作用域鏈,作用域鏈就是閉包。

不過,面試之所以重視閉包,是因為JS作用域鏈的機制,在實務上能打造更好維護、能重複使用的工具。小至專案的utils,大至前端框架,都有閉包的身影。

從實作的角度,會更關注在大家常見的:function裡面包了另一個function,內層function return出去後,即便離開了外層function,內層function仍然能存取外層function所宣告的變數。

狹義 - return出去的inner function保存outer function的環境

一般的function執行完畢時,其佔據的記憶體就會被釋放。但有一個例外:若與其它尚在存活的function形成作用域鏈,其佔據的記憶體就不會被清除。

以下是一個解說型的範例,解說程式執行時,執行環境、作用域鏈與閉包三者之間的關係。

解說範例 - 銀行戶頭

function openAccount(x){
  let money = x;
  
  function deposit(n){
    money = x + n;
    console.log(`account balance: ${money}`);
  }
  
  function withdraw(n){
    money = money - n;
    console.log(`account balance: ${money}`);
    return n;
  }
  
  return {
    deposit: deposit,
    withdraw: withdraw
  }
}

const myAccount = openAccount(1000);
myAccount.deposit(100);
myAccount.withdraw(200);

全域執行環境開始執行後,首先來到第21行,準備openAccount(1000)的執行環境。待openAccount(1000)執行完畢後,會將執行結果賦值給myAccount,因此圖示先簡稱為myAccount執行環境

開始執行openAccount(1000),第2行賦予變數money1000的值。

接著來到尾端return夾帶函式的物件。

至此openAccount(1000)執行完畢,其執行環境從Execution Stack上移除。 第21行宣告的myAccount接收openAccount(1000)回傳的物件,包含depositwithdraw兩個函式。

程式來到第22行,準備myAccount.deposit(100)的執行環境。

開始執行myAccount.deposit(100),此時閉包的重頭戲來了,雖然myAccount執行環境已不在Execution Stack上面,其變數xmoney並沒有被記憶體所清除,它們的值仍然能被存取、更改。

關鍵在,myAccount.deposit(100)的外部環境outer指向myAccount,與myAccount之間形成作用域鏈,這代表「myAccount裡面的變數對我來說很重要,請不要清除它」。因此即便myAccount已執行完畢,記憶體也不會清除它的變數。

此時,變數money的值更改為1100。

執行完第6行的console.log,程式往下來到第23行,準備myAccount.withdraw(200)的執行環境。

開始執行myAccount.withdraw(200),同上面的例子,myAccount.withdraw(200)的outer指向myAccount,因此money以上次的1100,扣掉提款的200,剩下900。

openAccount作為外層function,裡面包了depositwithdraw兩個內層function。 由於內層function的outer指向openAccount,即便openAccount執行完畢,從Execution Stack上移除,openAccount的語彙環境(Lexical Environment)也不會被記憶體銷毀。

這種「內部function保留了外部function環境」的特性,是狹義閉包最重要的部分。在實務上特別適合打造util工具及函式庫。

閉包能創造客製化的環境

上面的例子只有myAccount我的帳戶,如果爸爸也開了一個戶頭呢?

function openAccount(x){
  let money = x;
  
  function deposit(n){
    money = x + n;
    console.log(`account balance: ${money}`);
  }
  
  function withdraw(n){
    money = money - n;
    console.log(`account balance: ${money}`);
    return n;
  }
  
  return {
    deposit: deposit,
    withdraw: withdraw
  }
}

const myAccount = openAccount(1000);
myAccount.deposit(100);
myAccount.withdraw(200);

// 新增爸爸的戶頭
const myDadsAccount = openAccount(1000);myDadsAccount.deposit(1000000);

爸爸同樣用一千塊開戶,開戶後立刻存了一百萬進戶頭。

在這裡要注意的是,同樣是呼叫openAccount(1000),JS會分別創造兩個執行環境。對JS來說,只要呼叫函式,就會創造全新的執行環境,直到函式執行完畢才會銷毀,除了那些被其它「outer」指名不能銷毀的,才會保留下來。

// 建立一個openAccount(1000)的執行環境,並賦值給myAccount
const myAccount = openAccount(1000);

// 建立另一個openAccount(1000)的執行環境,並賦值給myDadsAccount
const myDadsAccount = openAccount(1000);

正是這個特性,讓「我」的帳戶與「爸爸」的帳戶能各自獨立存在。我的money,和爸爸的money,存在於不同的記憶體。

此外,每當只要有人開戶,都可以呼叫openAccount,不必每個人開戶都要建立一個新的物件或function。

為什麼不使用物件來做客製化呢?

上面開戶的例子,可能有人會覺得,一個人的帳戶一個物件,不就得了嗎?為什麼非得用閉包不可呢?

// 一個人的帳戶一個物件
const myAccount = {
  money: 1000,
  deposit: () => {}
  withdraw: () => {}
}

const myDadsAccount = {
  money: 1000,
  deposit: () => {}
  withdraw: () => {}
}

物件的問題是,它的值太好改了。甚至不需要透過depositwithdraw兩個函式,直接改money就行了:

myAccount.money = 900

就是因為太好改了,難以看出來金額的減少是為了提款還是轉帳。如果程式的哪個環節出錯了,trace code的難度會很高。

而閉包強迫你一定要透過它的function,才能改money的值:

function openAccount(x){
  let money = x;
  
  function deposit(n){    money = x + n;    // console.log(`account balance: ${money}`);
  }
  
  function withdraw(n){    money = money - n;    // console.log(`account balance: ${money}`);
    return n;
  }
  
  return {
    deposit: deposit,
    withdraw: withdraw
  }
}

因此,關於為什麼要使用閉包,第一個好處是:讓團隊開發功能時有一定的規範

除此之外,閉包也有「方便維護」的優點。例如明年每個帳戶都要新增貸款的功能,只要在openAccount新增方法,不論現在有幾個戶頭,所有帳戶都能直接呼叫新的方法:

function openAccount(x){
  // ...
  
  // new feature
  function credit(n){  
  }
  
  return {
    // ...
    credit: credit  }
}

如果是一個帳戶一個物件,功能的升級就會非常麻煩,專案裡有多少個帳戶,就要複製貼上多少次:

// 一個帳戶一個物件
const myAccount = {
  money: 1000,
  deposit: () => {}
  withdraw: () => {}
  // new feature
  credit: () => {}}

const myDadsAccount = {
  money: 1000,
  deposit: () => {}
  withdraw: () => {}
  // new feature
  credit: () => {}}

從方便升級的例子來看,閉包的第二個好處是:影響範圍大,又時常變動的功能,能集中管理

實務上的應用

如果是剛寫前端不到1~2年,不一定有機會為專案寫utils,這時很難體會閉包到底能幹嘛,能吃嗎?

對,新手不一定有機會自己寫閉包,但肯定有用過別人寫好的閉包。例如React的redux、Vue的Vuex,就是用閉包的原理打造而成。

Redux的閉包

凡是稍微大型的專案,都需要一個全域的狀態管理器。而Redux的createStore正是一個經典的閉包範例。為了聚焦在閉包的部分,這裡只會節錄部分程式碼。

// createStore.js

function createStore(reducer, initialState) {
  let currentReducer = reducer;
  let currentState = initialState;
  let listeners = [];
  let isDispatching = false;
  
  function getState() {
    return currentState;
  }

  function subscribe(listener) {
    return function unsubscribe() {
      // ...
    };
  }

  function dispatch(action) {    // ...    return action;  }
  return {
    dispatch,
    subscribe,
    getState,
		// ...
  }
}

const store = createStore(...);

Redux store掌握了整個專案的全局state,全局state的修改勢必要照規矩來的,dispatch是整個createStore唯一讓user更新全局state的方法,外界無法直接修改state。

如果createStore不用閉包來寫,那會是怎樣一番風景呢?

function createStore(reducer, initialState){
  // object
  const store = {};  store.currentReducer = reducer;
  store.currentState = initialState;
  store.listeners = [];
  store.getState = function() {
    // ...
  };
  store.dispatch = function(action) {
    // ...
  };
  return store;
}

const store = createStore(...);

// 如果有人偷懶直接修改store,可能會造成大災難
store.currentState = { // ...}

如果store可以直接修改,那麼store究竟在什麼時候修改?有沒有被任意增加屬性?整個專案將會變得很難維護,改錯了牽一髮而動全身。

Express.js生態系的閉包

Express常用的套件幾乎是用閉包的形式打造的。以compression這個response的壓縮套件為例。

以下是套件的使用方式:

const express = require('express')
const cors = require('cors')
const app = express()

app.use(compression({  // @TODO: Configure options here}))...

以下是compression套件本身:

function compression (options) {
  var opts = options || {}

  // options
  var filter = opts.filter || shouldCompress
  var threshold = bytes.parse(opts.threshold)

  if (threshold == null) {
    threshold = 1024
  }

  return (req, res, next) => {    // ....  }}

為什麼compression非得用閉包來做套件呢?

理由一:在express剛開始運作的時候,要先跑一次function compression,設定config,再return內層函式。 理由二:express會依照自己的生命週期,執行compression內層函式。內層函式需依照一開始設定好的config執行程式。

最後:實務上什麼時候會用到閉包呢?

總結以上的範例,以下3種需求可以使用閉包:

  1. 資料的修改需要遵循一定的規範(如Redux store)
  2. 某個功能在很多地方會用到,集中管理,統一維護(如React的小元件)
  3. 運行前要做初始化設定(如Express.js的相關套件)

Summary

  • 廣義閉包:每建立一個function,function與其父層就會形成作用域鏈,作用域鏈就是閉包。
  • 狹義閉包:內層function指定要保留外層function環境。
  • 一般的function執行完畢時,其佔據的記憶體就會被釋放。但有其例外:若與其它尚在存活的function形成作用域鏈,其佔據的記憶體就不會被清除。
  • 對JS來說,只要呼叫函式,就會創造全新的執行環境。即便是同一個function,在不同的地方呼叫它,就會創造不同的執行環境。
  • 使用閉包的時機一:資料的修改需要遵循一定的規範(如Redux store)
  • 使用閉包的時機二:某個功能在很多地方會用到,集中管理,統一維護(如React的小元件)
  • 使用閉包的時機三:運行前要做初始化設定(如Express.js的相關套件)

Reference