Photo by Michal Matlon on Unsplash
這一系列其實是為了閉包這個主題而生的。要先理解作用域、作用域鏈,才能進一步認識閉包;而要理解作用域,就需要認識詞彙環境(Lexical Environment)的架構。前面花了兩個篇幅建立基本知識,現在終於可以進入正題了。
既然前兩篇圖解了執行環境、作用域、作用域鏈之間的關係,本篇會著重在作用域鏈與閉包的關係。建議先看過前篇再往下閱讀,因為本篇不會深入解釋作用域鏈。
執行環境 Execution Context
JavaScript底層要執行的時候,第一步會先建立一個「執行環境」(Execution Context)。
執行環境內部有許多物件,其中最重要的是「詞彙環境」Lexical Environment(也有翻譯成「詞法環境」)。 詞彙環境主要儲存了:
- 全域物件,在有瀏覽器的環境下,指的是window內的物件
- 登記這個環境所宣告的變數和function(函式)
- 外部環境,也就是Scope Chain,台灣多半翻譯成「作用域(鏈)」,中國則翻譯成「範疇」
- 這個環境的”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)回傳的物件,包含deposit、withdraw兩個函式。

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

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

關鍵在,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,裡面包了deposit、withdraw兩個內層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: () => {}
}物件的問題是,它的值太好改了。甚至不需要透過deposit、withdraw兩個函式,直接改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種需求可以使用閉包:
- 資料的修改需要遵循一定的規範(如Redux store)
- 某個功能在很多地方會用到,集中管理,統一維護(如React的小元件)
- 運行前要做初始化設定(如Express.js的相關套件)
Summary
- 廣義閉包:每建立一個function,function與其父層就會形成作用域鏈,作用域鏈就是閉包。
- 狹義閉包:內層function指定要保留外層function環境。
- 一般的function執行完畢時,其佔據的記憶體就會被釋放。但有其例外:若與其它尚在存活的function形成作用域鏈,其佔據的記憶體就不會被清除。
- 對JS來說,只要呼叫函式,就會創造全新的執行環境。即便是同一個function,在不同的地方呼叫它,就會創造不同的執行環境。
- 使用閉包的時機一:資料的修改需要遵循一定的規範(如Redux store)
- 使用閉包的時機二:某個功能在很多地方會用到,集中管理,統一維護(如React的小元件)
- 使用閉包的時機三:運行前要做初始化設定(如Express.js的相關套件)
Reference
- 為什麼我們需要閉包(Closure)?它是冷知識還是真有用途? 作者 - Nissen Yeh
- Closure in javaScript 作者 - Abhijeet Mishra
- JavaScript Closure (閉包)、函式與語彙環境 作者 - 勗博 (Shubo)
- How JavaScript Closure is used in real life project ? 作者 - Trung Nguyen