無法理解的 JS 閉包原理

Kion
17 min readJan 4, 2020

--

https://pixabay.com/photos/chest-luggage-send-mail-go-away-1649299/

目次

1. 詞法作用域 Lexical Scoping
2. 執行環境 Execution Context
3. 變量物件 Variable Object 與提升 Hoisting
4. 作用域鍊 Scope Chain 與外部環境 Outer Environment
5. 閉包 Closure

在學習 JS 時第一個遇到的坑,大概就是閉包了
其實關於閉包有很多文章
但看完都有一種:
道理我好像都懂,但我就是不懂閉包到底能幹嘛?做什麼?

閉包為什麼是這樣?

想著或許閉包就是一個現象吧?知道就好
就這樣帶著對閉包的疑惑,渾渾噩噩地活到現在

直到前幾個月,我才明白閉包在 JS 基礎中屬於中級概念
很多高級運用會牽扯到他
我才痛定思痛,決定認真了解閉包到底是什麼?

函數創建與執行

一般所探討的閉包現象通常是指:

當外層函數執行完消逝,但內部函數卻依然保留了已消逝的外部環境變量
使得在執行內部函數時,完整記錄了他的值

聽起來很玄、很抽象,這也就是為什麼他會是第一個坑的原因

經過我的一番研究,我終於發現先前自己學習閉包的盲點
不是我理解力太差,問題就在於:

我對函數的創建與執行根本不熟

而這就是閉包,這個現象的源頭啊!

讓我們先從函數創建與執行認識起,再慢慢了解閉包

詞法作用域 Lexical Scoping

要了解函數,就比須從作用域開始

作用域亦即在 程式原始碼中定義變數的區域,也就是規範當前的程式如何找到所需的變數的範圍
作用域共有兩種,分為:

  1. 詞法作用域(靜態作用域)
    函數的作用域在 函數正要執行前 就已被決定了
  2. 動態作用域
    函數的作用域在 呼叫函數時 才決定

而JavaScript採用的正是詞法作用域!

用文字來看,可能沒有太大的感覺
舉個例子或許會更好了解

p.s. 以下代碼皆為虛構

詞法作用域

function start() {
alert(args); // 真正執行的作用域
}
function server() {
var args = “parameter here.”;
start(); // 這裡只是調用點
}
server(); // ReferenceError: args is not defined

動態作用域

function start() {
alert(args);
}
function server() {
var args = “parameter here.”;
start(); // 在呼叫函數時決定可訪問的變數
}
server(); // parameter here.

了解 JS 作用域的特色後
接下來就來一一拆解詞法作用域的運作細節

執行環境 Execution Context(EC)

所有程式碼都是在執行環境中執行的
根據詞法作用域的特性
我們可以明白 JS 的 EC 是在 正要執行時 建立的

JS 的執行環境分為兩種:

  1. 全域環境(global context)
    全域環境為最底層的環境,只有一個
  2. 函式環境(function context)
    可以同時擁有多個,一個函式在正要執行時就會創建一個函式環境
    就算是自己呼叫自己只要 call function 就會建立新的環境

EC 聽起來有點抽象
簡單來說,每當程式要開始執行一個函數的時候
在開始前就會產生一個 EC
而他就是一個儲存該函數相關資訊的地方
JS 引擎就會緊接著把這個 EC 放到執行緒裡面,當函數執行完以後,就會把 EC 給 pop 出來
這也就是:

執行環境堆疊(execution context stack)或 呼叫堆疊(call stack)

為什麼叫做 stack 呢?

實際上 stack 是一種 先進後出(FILO First In Last Out)的資料結構,像是一種由下往上堆疊的文件
他只能由上往下處理文件,也只能從最上面疊加新的文件

執行環境堆疊 execution context stack

其實 EC 是一個很廣的概念,涵蓋了很多觀念在裡頭
像是:變數物件(VO)、提升、作用域鍊…….
他們彼此也相互有關係
而變數物件(VO)是其中的基本

變數物件 Variable Object 與提升 Hoisting

每一個 EC 內皆有一個與相關連的變數物件(VO)
這個物件其實就是負責記錄該執行環境中所定義的變數和函式
因此在宣告時,是在當下的 執行環境變量物件 增添一個屬性
例如:

var a = 0;

就是在全域環境的變數物件中建立了一個屬性 a
在開始執行前,他的 VO 就是長這樣

globalScope: {
a: undefined
}

先建立 a 這個屬性在 VO ,並暫時宣告他 undefined ,這就是 提升(Hoisting 的概念

要特別注意,JS 是先宣告

等到 開始逐行執行程式 才賦值

然而提升至 VO 並非按照程式逐行順序進 VO ,他是有順序的!

參數 > 函式 > 變數

開始執行程式後, JS 會開始進行 LHS(Left hand side)賦值
尋找變數的位置,對其 賦值

globalScope: {
a: 0
}

LHS(Left hand side)與 RHS(Right hand side)

與 LHS(Left hand side)相反的為 RHS(Right hand side)
用於尋找變數的位置查詢變數的值

用途區分:
LHS(Left hand side):用於賦值
RHS(Right hand side):用於查值

例如:

var a = 0;
var d = 2;
function b(d){
var c = 1;
console.log(d);
console.log(a);
}
b(d);
  1. 全域 EC 建立,宣告提升,加入屬性至 VO
globalScope: {
b: func,
a: undefined,
d: undefined
}

函數在 VO 的值

b 的值將會是建立函式完之後回傳的一個指向函數的指標

2. 開始執行程式

第一行: var a = 0; 要將 0 賦值給 a
LHS 查詢 a 是否有被宣告,有的話將 0 賦予至 a 的記憶體位置
沒有的話,宣告a ,將 0 賦予至 a 的記憶體位置

globalScope: {
b: func,
a: 0,
d: 2
}

第十行: b(); 呼叫函式
進入第四行,傳入參數,開始執行函式

3. 函數 EC 建立,參數建立,宣告提升,加入屬性至 Activation Object(AO)

bScope: {
arguments,
d: 2,
c: undefined
}
globalScope: {
b: func,
a: 0,
d: 2
}

AO v.s. VO

兩者並無太大的差別
只是函數會多一個屬性叫 arguments 因此叫做 AO

4. 開始執行程式

第五行: var c = 1; 要將 1 賦值給 c
LHS 查詢 c 是否有被宣告,有的話將 1 賦予至 c 的記憶體位置

bScope: {
arguments,
d: 2,
c: 1
}
globalScope: {
b: func,
a: 0,
d: 2
}

第六行: console.log(d); 要印出 d
RHS 查詢 d 的記憶體位置的值,印出來

第七行: console.log(a); 要印出 a
RHS 查詢 a 的記憶體位置的值,但 a 不在 bScope 裡該怎麼辦?!!

JS 有一個機制叫做 作用域鍊(Scope Chain)
循著他就能找到全域環境宣告的 a 順利印出 a 的值了

此時的 stack

作用域鍊 Scope Chain 與外部環境 Outer Environment

再複習一次剛剛舉的例子:

var a = 0;
var d = 2;
function b(d){
var c = 1;
console.log(d);
console.log(a);
}
b();

在 b function 中 a 很顯然地不在 b function 環境的 AO 中
但為什麼他可以取到全域 VO 的值呢?
這就要講到另一個存在 —— 作用域鍊

很多人會把找變項的過程中,從內層找到外層(Outer Environment),直到最外層的全域環境的這種順序,來描述作用域鍊
透過比喻,將其具象化成一條鏈,來形容他的順序與關聯性
但他並沒有這麼抽象,他是有道理的存在

作用域鍊就是用來保存該環境 VO 的空間
聽起來跟 VO 身份重疊,但卻是完全不一樣的存在!

就像 VO/AO 一樣,每個環境(包含全域)都有自己的作用域鍊
進入環境的時候,該環境的作用域鍊就會被建立,並初始化為 AO 並加上[[Scope]] 屬性
也就是說

scope chain = activation object + [[Scope]]

AO 我們之前已經帶過了,但什麼是 [[Scope]]

這裡我目前理解不深,我在這擷取他人整理 ES3 13.2 Creating Function Objects 的片段

Given an optional parameter list specified by FormalParameterList, a body specified by FunctionBody, and a scope chain specified by Scope, a Function object is constructed as follows
(中間省略)
7.Set the [[Scope]] property of F to a new scope chain (10.1.4) that contains the same objects as Scope.

[[Scope]] 就是在建立函數的時候會給一個 Scope,而這一個 Scope 會被設定到[[Scope]]

不得不說我對於文中的 Scope 的理解沒有太大的把握,目前是看作當前環境的 AO 啦
歡迎大大解惑 QQ

舉個例子:

var a = 0;function b(){}b();
  1. 全域環境建立,VO 建立,初始化 VO 加入 [[Scope]] 屬性建立作用域鍊
globalEC = {
VO: {
b: func,
a: undefined
},
scopeChain: globalEC.VO
}

2. 開始賦值,呼叫函式,準備進入函式 b

globalEC = {
VO: {
b: func,
a: 0
},
scopeChain: globalEC.VO
}

bEC.[[Scope]] = globalEC.scopeChain

3. 進入函式 b ,環境建立, AO 建立,初始化 AO,加入 [[Scope]] 屬性建立作用域鍊

bEC = {
AO: {
arguments,
....
},
scopeChain:
[bEC.AO, bEC.[[Scope]]]
// bEC.[[Scope]] = globalEC.scopeChain = globalEC.VO
}
globalEC = {
VO: {
b: func,
a: 0
},
scopeChain: globalEC.VO
}

bEC.[[Scope]] = globalEC.scopeChain

由此可以發現,當程式要執行函式 b 時
b 的作用域鍊不僅涵蓋自己的 AO 還有上一層全域環境的 VO
這也就是為什麼函式可以查詢外部環境的變量
也是閉包這個現象的重點所在!

閉包的核心

其實了解以上內容,我們就可以明白整個閉包的現象了
我一直講閉包的現象
是因為目前所有談到閉包的文章都是我們刻意想應用閉包的某種特性,可說是狹義的閉包現象

在維基上,對於閉包的解釋是這樣的:

在電腦科學中,閉包(英語:Closure),又稱詞法閉包(Lexical Closure)或函式閉包(function closures),是參照了自由變數的函式。
這個被參照的自由變數將和這個函式一同存在,即使已經離開了創造它的環境也不例外。所以,有另一種說法認為閉包是由函式和與其相關的參照環境組合而成的實體。

MDN 對於閉包的解釋是這樣的:

閉包(Closure)是函式以及該函式被宣告時所在的作用域環境(lexical environment)的組合。

可見,閉包存在於所有函式之中,只是我們想刻意運用他的特性
若想深入了解可以看 這篇最底下

此時此刻的我認定你應該已經看過很多篇閉包的例子了
不然也不會如此苦惱不是嗎 XD

我們就來直接應用我們上述習得的知識拆解閉包吧!
這裡,我們借用 他人的案例
我覺得寫得蠻好的,較貼近日常使用的方式

題目:讓三個按鈕被點擊的時候可以回傳按鈕的文字到 console 上

<button id="first">First</button>
<button id="second">Second</button>
<button id="third">Third</button>

如果是下面這樣寫的話,綁定會是有問題的!
我們要怎麼改呢?

var buttons = document.getElementsByTagName('button')

for (var i = 0; i < buttons.length; i ++) {
var value = buttons[i].innerHTML
buttons[i].addEventListener('click', function () {
console.log(value)
})
}

首先,來看一下未更改前發生什麼事

不管按什麼結果都是 Third

問題就是出在,迴圈宣告在全域環境,但 console.log(value) 在一個 callback 函數內
他必須等到 click 事件發生,才能執行 callback 函數
但那時迴圈早已做完了!
所以 value 永遠都是 Third
這時我們就可以使用閉包記錄先前的變量,達到我們的目的

首先,先建立一個函數,讓迴圈與全域環境分隔

var buttons = document.getElementsByTagName('button')function f() {
for (var i = 0; i < buttons.length; i ++) {
var value = buttons[i].innerHTML;
buttons[i].addEventListener('click', function () {
console.log(value);
})
}
}
f();

但這並沒有改善我們的現況

分隔環境但結果一樣

因為我們的重點不是迴圈,而是要記錄裡面的 i
因此我們要在迴圈內再建立一個環境建立 f1 函式,這是為了做一個鋪墊
當 f 消逝才能把 Scope Chain 的值鎖在 f1 函式內

var buttons = document.getElementsByTagName('button')

function f() {
for (var i = 0; i < buttons.length; i ++) {
function f1() {
var value = buttons[i].innerHTML;
buttons[i].addEventListener('click', function () {
console.log(value);
})
}
f1();
}
}
f();
成功了!!!!

讓我們圖解一下閉包運作的過程:

  1. 建立全域
globalEC = {
VO: {
f: func,
buttons: ....
},
scopeChain: globalEC.VO
}
fEC.[[Scope]] = globalEC.scopeChain

2. 進入 f 函數

fEC = {
AO: {
arguments,
f1: func,
i: 0,
},
scopeChain:
[fEC.AO, fEC.[[Scope]]] // fEC.AO + globalEC.VO
}
globalEC = {
VO: {
f: func,
buttons: ....
},
scopeChain: globalEC.VO
}
fEC.[[Scope]] = globalEC.scopeChain

3. 進入 f1 函數

f1EC = {
AO: {
arguments,
value: ...
},
scopeChain:
[f1EC.AO, f1EC.[[Scope]]] // f1EC.AO + fEC.AO + globalEC.VO
}
fEC = {
AO: {
arguments,
f1: func,
i: 0,
},
scopeChain:
[fEC.AO, fEC.[[Scope]]] // fEC.AO + globalEC.VO
}
globalEC = {
VO: {
f: func,
buttons: ....
},
scopeChain: globalEC.VO
}
fEC.[[Scope]] = globalEC.scopeChain

此時的 f1 中的 i 已經是

參考當時進來的 f1EC.[[Scope]]fEC.AO)的 i

此為示意圖,並非真實情形(我之後修改一下,誤導成份大)

再也不會因為迴圈進行而改變 i的值

繼續迴圈 ——小動畫示意

此為示意動畫,並非真實情形(我之後修改一下,誤導成份大)

拉長一點看的話,整個過程是長這樣的

此為示意動畫,並非真實情形(我之後修改一下,誤導成份大)

希望透過這樣的方式可以更清楚了解閉包的運作原理

題外話,我有用一位開發者的示意工具
模擬類似這次例題的 Event Loop 過程,更貼近實際的運作過程
但也只是簡單示意而已,可以參考 網址

此為示意動畫,並非真實情形

這次真的花了很多時間整理
也發現自己的基礎知識不足,重新補了很多基礎概念
不過終於破解多年的迷思,也是小有收穫
希望可以幫助更多人,有一個方向理解閉包

拍個手讓我知道,這個文章對你們有幫助 ♥(´∀` )人
可以拍五次喔!快來瘋狂擊掌!

參考資料

  1. 秒懂!JavaSript 執行環境與堆疊
  2. 深入 Javascript 執行環境
  3. [筆記] JavaScript中Scope Chain和outer environment的概念
  4. [筆記] 不同execution context的變項不會互相影響─了解function背後運作的邏輯
  5. 標識符解析與閉包
  6. 我知道你懂 hoisting,可是你了解到多深?
  7. 所有的函式都是閉包:談 JS 中的作用域與 Closure
  8. 深入淺出瞭解 JavaScript 閉包(closure)

--

--

Kion

程式就是利用自動化與排程的特性解決問題 文章分類總覽: https://hackmd.io/@Kion/SyvyEks0L