上篇文章有稍微提過過執行環境(Execution Context)的模型來解釋 Hoisting 原理,這次我們要把模型建構地更詳細完整一點,並用執行環境的模型來進一步了解什麼是閉包(Closure)。
真正在執行 JavaScript 時,可分為編譯與執行兩個階段,在編譯時會把需要的資源準備好,在執行階段時再執行程式碼。
Global EC & VO
JavaScript 在編譯階段時,首先會建立全域執行(global EC),在 global EC 內會有一個變數物件(VO),宣告過的變數、函式都會被加到這個 VO 內,並採下列這種方始初始化:
函式宣告:
- 把 function 宣告放進 VO
- 如果 VO 已經有個同名的 property,則取代之
變數宣告:
- 把變數宣告放進 VO,並初始化成 undefined
- 如果 VO 已經有重複的同名 property,該 property 所指向的值和屬性都不會改變
- 如果變數宣告和 function 宣告或函式同名,則不改變先前的 property
scopeChain & [[Scope]]
上面是我們已經知道的部分,但除了把變數和函式放進 VO 之外,global EC 還會有一個作用域鏈(scopeChain),初始化的時候會把 VO 放進 scopeChain 內:
scopeChain = [globalEC.VO]
global EC 內的每個函式都有一個 [[Scope]] 屬性,global EC 遇到函式時,會將它初始化為 global EC 的 scopeChain:
function.[[Scope]] = globalEC.scopeChain
函式的 local EC & AO
當一個函式被宣告、呼叫或執行,也都會建立 EC,被稱為 local EC,並放進 JavaScript 的執行堆疊(Call Stack)裡,最新被宣告的函式就會在執行堆疊最上面。JavaScript 程式碼稍後在執行階段執行程式式,就會由上往下來逐一完成 Call Stack 裡的 EC,這種執行方式可以理解成 Last In, First Out。
不只 Global EC 有 VO,函式的 local EC 也會有 VO,在這裡被稱為 Activation Object(AO),在函式內除了宣告的變數和函式會被加入 AO 之外,外面傳進來的參數也會被加到這個 AO 裡。
參數:
- 把參數放進 VO
- 沒有傳的參數會被初始化成 undefined
這裡的重點是,函式的 local EC 也會有自己的 scopeChain,它的 scopeChain 會參考「自己的 AO 加上自己的 [[Scope]]」,這個 [[Scope]] 是我們在進入 Global EC 時,這個函式就有的:
function.[[Scope]] = globalEC.scopeChain
。而 globalEC.scopeChain
就是 [globalEC.VO]
。
所以函式的 scopeChain 會像這樣:
function.scopeChain = [function.AO, function.[[Scope]]]
除了建立自己的 scopeChain 之外,也像在 global EC 一樣,所有在裡面宣告的函式, [[Scope]] 都會被初始化為 local EC 的 scopeChain:
inner.[[Scope]] = function.scopeChain
實際範例跑一遍
以下列程式碼為範例:
var a = 1;
function test() {
var b = 2;
function inner() {
var c = 3;
console.log(b);
console.log(a);
}
inner();
}
test();
globalEC 有 VO,並建立一個 scopeChain,這個 scopeChain 是 globalEC 的 VO:
globalEC: {
VO: {
a: undefined,
test: function
},
scopeChain = [globalEC.VO]
}
並把 VO 裡的 test 函式的 [[Scope]] 初始化:
test.[[Scope]] = globalEC.scopeChain
testEC 也有自己的 AO,進入 testEC 後,把宣告的 b 變數放進 AO,初始化為 undefined,inner 函式也會被放進來:
testEC {
AO: {
b: undefined,
inner: function
}
}
testEC 也會有 scopeChain,它的 scopeChain 是 AO 加上 test 的 [[Scope]],test.[[Scope]]
就是 globalEC.scopeChain
,即是 globalEC.VO
,所以 testEC 的 scopeChain 是 [testEC.AO, globalEC.VO]
:
testEC: {
AO: {
b: undefined,
inner: function
}
scopeChain: [testEC.AO, testEC.AO, globalEC.VO]
}
此外,testEC 也會把 AO 裡宣告的 inner 函式的 [[Scope]] 給初始化:
inner.[[Scope]] = testEC.scopeChain
inner 也有自己的 innerEC 和 AO,並把宣告的變數 c 放進來,初始化成 undefined:
innerEC {
AO: {
c: undefined
},
}
innerEC 的 scopeChain 是 AO 加上 inner 的 [[Scope]],而我們知道 inner.[[Scope]] = testEC.scopeChain
,testEC.scopeChain 又等於 [testEC.AO, globalEC.VO],所以可以得到 innerEC 的 scopeChain:
innerEC {
AO: {
c: undefined
},
scopeChain: [innerEC.AO, testEC.AO, globalEC.VO]
}
這樣就可以得到程式編譯完成時的 EC 模型了:
innerEC: {
AO: {
c: undefined
},
scopeChain : [innerEC.AO, testEC.AO, globalEC.VO]
}
testEC: {
AO: {
b: undefined,
inner: function
}
scopeChain: [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = [testEC.AO, globalEC.VO]
globalEC: {
VO: {
a: undefined,
test: function
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
執行 JavaScript
剛才有提到,越新 的 EC 會被放進 Call Stack 的越上面,當開始執行時,從最上面往下執行,當一個 EC 被執行完,就會從 Call Stack 中抽離,AO 和 scopeChain 也一起消失。
程式開始執行後的 EC 模型
innerEC: { // 第四步,進入 inner 函式
AO: {
c: 3 // 第五步,將 c 賦值為 3
},
scopeChain : [innerEC.AO, testEC.AO, globalEC.VO]
}
testEC: { // 第二步,進入 test 函式
AO: {
b: undefined, // 第三步,將 b 賦值為 2
inner: function
}
scopeChain: [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = [testEC.AO, globalEC.VO]
globalEC: {
VO: {
a: 1 // 第一步先讓 a 賦值為 1
test: function
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = globalEC.scopeChain
透過上面這個模型,我們可以知道程式在執行時,是怎麼在 AO 或 VO 裡面找到宣告過的變數,若該作用域沒有該變數,就會沿著 scopeChain 不斷會往上一層找。
程式碼:
var a = 1;
function test() {
var b = 2;
function inner() {
var c = 3;
console.log(c);
// 在 innerEC.AO 裡面可以找到 C
console.log(b);
// 在 innerEC.AO 裡找不到,沿著 scopeChain 往上找到 testEC.AO 裡面有 b
console.log(a);
// 在 innerEC.AO 裡找不到,沿著 scopeChain 往上找到 globalEC.AO 裡面有 c
}
inner();
}
test();
- 當 inner() 被呼叫且被執行完之後, innerEC 就被從 Call Stack 中抽離,AO 和 innerEC.scopeChain 也會消失。
- 當 test() 被呼叫執行完之後,testEC 也從 Call Stack 中抽離,AO 和 testEC.scopeChain 也會消失。
- 當全部程式碼被執行完,global EC 也從 Call Stack 中抽離,VO 和 scopeChain 消失,至此程式碼全部跑完。
Closure 初探
Closure is when a function "remembers" the variables outside of it, even if you pass that function elsewhere." -- Kyle Simpson
Closure 可以理解成,當一個 function 記住某個外部的變數,而且就算把這個 function 移到別的地方,它還能繼續記住這個變數。
在下面的例子中,呼叫 ask() 執行完畢之後,照理來說就會消失,但在 ask 裡,setTimeout() 的 callback function 竟然還是可以存取到 question,這就是一種 closure:
function ask(question) {
setTimeout(function wait() {
console.log(question);
}, 1000);
}
ask("What is closure?"); // What is closure?
一般看到實現 Closure 的方法是透過在一個 outter function 裡面 return 一個 inner function,讓 inner function 存取 outter function 作用域的變數。
而在下面這個簡單的範例中,其實只有第一次呼叫 cachedComplex function 時有運算,其他則都是直接取用存在 cache function 李的計算結果:
function complex(num) {
console.log('calculate');
return num * num * num;
}
function cache(func) {
var ans = {};
return function (num) {
if (ans[num]) {
return ans[num];
}
ans[num] = func(num);
return ans[num];
}
}
const cachedComplex = cache(complex);
console.log(cachedComplex(20));
console.log(cachedComplex(20));
console.log(cachedComplex(20));
從 EC 模型來看 Closure 在幹嘛
為什麼可以存取一個已經消失的 function 的變數?要搞懂 Closure 背後發生的事,就要回頭看看我們的 EC 模型和作用域鏈。
程式碼範例:
var v1 = 10
function test() {
var vTest = 20;
function inner() {
console.log(v1, vTest);
}
return inner;
}
var inner = test();
inner();
EC 模型(忘記怎麼畫可以重看前面):
innerEC: {
AO: {
}
scopeChain: [innerEC.AO, testEC.AO, globalEC.VO];
}
testEC: {
AO: {
vTest: undefined, // 執行時變成 20
inner: func
},
scopeChain: [testEC.AO, globalEC.VO];
}
inner.[[Scope]] = [testEC.AO, globalEC.VO];
globalEC: {
VO: {
v1: undefined, // 執行時變成 10
inner: undefined, // 執行時變成 func
test: func
},
scopeChain: [globalEC.VO];
}
test.[[Scope]] = [globalEC.VO];
雖然我們在 var inner = test();
時已經執行完 test() 了,照理說 testEC 應該會從 Call Stack 中抽離,但當我們執行 inner 的時候,還會需要參考 testEC.AO 來拿到 vTest 和 globalEC.AO 來拿到 v1,所以 testEC 的 AO 會先被保留起來。這就是閉包的原理。
從日常生活中的作用域陷阱看 closure
下列程式碼, 在執行 arr0 的時候,i 已經變成 5 了:
var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = function() {
console.log(i);
}
}
arr[0]();
// 5
解決方法
var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = logN();
}
}
function logN(n) {
return function() {
console.log(n);
}
}
arr[0]();
// 0
IIFE 立即函式:
var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] =
(function(n) {
return function() {
console.log(n);
};
})(i)
}
arr[0]();
// 0
使用 let 限定變數的作用域:
var arr = [];
for (let i = 0; i < 5; i++) {
arr[i] = function() {
console.log(i);
}
}
arr[0]();
// 0
Closure 實際應用
通常 closure 的使用時機是想要隱藏住某些資訊,
例如下面的例子中,就算不是經過 deduct 函式,還是可以隨便在外部改到 my_balance:
var my_balance = 999
function deduct(n) {
my_balance -= (n > 10 ? 10 : n) // 超過 10 塊只扣 10 塊
}
deduct(13) // 只被扣 10 塊
my_balance -= 999 // 還是被扣了 999 塊
為了保證 my_balance 不會隨便被 function 外面的程式碼重新賦值,所以可以用閉包的方式,這樣就只有透過 getWallet 裡面的 deduct 才能更改到 getWallet 的值:
function getWallet() {
var my_balance = 999
return {
deduct: function(n) {
my_balance -= (n > 10 ? 10 : n) // 超過 10 塊只扣 10 塊
}
}
}
var wallet = getWallet()
wallet.deduct(13) // 只被扣 10 塊
my_balance -= 999 // Uncaught ReferenceError: my_balance is not defined