Closure 是什麼?
console.log(a)
會是 11
function test() {
var a = 10;
function inner() {
a++;
console.log(a); // 11
}
inner(); // 呼叫 inner function
}
test();
閉包就是:在一個 function 裡面,return 另一個 function
範例:
在 function test
裡面,return function inner
用一個變數 func
去接收「test()
所回傳的 inner function」
console.log(a)
一樣會是 11
function test() {
var a = 10;
function inner() {
a++;
console.log(a); // 11
}
return inner; // 回傳 inner function
}
var func = test(); // 接收 inner 這個 function
func(); // 等同於是「呼叫 inner function」
如果再次呼叫 func()
,console.log(a)
就依序會是 11, 12, 13
function test() {
var a = 10;
function inner() {
a++;
console.log(a);
}
return inner; // 回傳 inner function
}
var func = test(); // 接收 inner 這個 function
func(); // 等同於是「呼叫 inner function」
func();
func();
output:
11
12
13
為什麼會叫做「閉包」呢?
以上面範例為例:
當進入到 function test
時,會產生 test EC 並建立 test VO
test EC
test VO = {
a: 10,
inner: func
}
之前有說過,當 function 執行結束後,所有資源都會被釋放掉
照理來說,當執行到第七行 return inner
之後,function test
就執行完畢了,在 test VO 裡面的資源都會被清空
但是卻發現在 return function 時,return 的 function inner
不知道為什麼,可以把「在 test VO 裡面的 a
給記起來」(a
就像是一直被鎖在 function inner
裡面)
只有在 function inner
裡面可以存取到 a
的值,其他地方都存取不到。也可以透過 function inner
去改變 a
的值
Closure 應用的範例:避免一直重複計算
例如,有一個 function complex
每次都要做很複雜的計算
如果是按照以前的作法,我每 call 一次 function complex
,都要重新做一次複雜的計算才能得到結果
但是,我可以利用閉包的特性,來避免一直重複計算:
我把 function complex
傳進 function cache
後,cache(complex)
會 return 一個新的 function -> 所以,變數 cachedComplex
就是這個 return 的 function,所以就可以接收一個 num
的參數
利用 var ans = {}
這個物件,把值記起來
在 function cache
裡面 return 的 function,會把 ans
的值給記幾來,我就可以一直重複運用它
- 第一次 call
cachedComplex(20)
時:因為是第一次執行,所以會進行計算 - 第二次 call
cachedComplex(20)
時:因為前面已經計算過了,所以不用重新計算,可以直接輸出結果 ->cachedComplex
利用「閉包」的特性,把值給記起來
function complex(num) {
// 複雜的計算
console.log("calculate"); // 有出現 "calculate" 代表「真的有執行 function complex」
return num * num * num;
}
// 會回傳一個新的 function
function cache(func) {
var ans = {}; // 用這個物件,把值記起來
return function (num) {
if (ans[num]) {
// 如果 ans[num] 有東西的話,就直接回傳 ans[num] 的 value
return ans[num];
}
// 如果 ans[num] 沒東西的話
ans[num] = func(num); // 第一次執行時,會執行到這裡 ans[20] = complex(20)
return ans[num];
};
}
console.log(complex(20)); // 進行一次複雜的計算
console.log(complex(20)); // 進行一次複雜的計算
console.log(complex(20)); // 進行一次複雜的計算
const cachedComplex = cache(complex);
console.log(cachedComplex(20)); // 因為是第一次,所以會需要執行 complex(20)
console.log(cachedComplex(20)); // 利用閉包的特性,不需計算就可以直接回傳 ans[20] 的值
console.log(cachedComplex(20)); // 利用閉包的特性,不需計算就可以直接回傳結果 ans[20] 的值
每一次執行 cachedComplex(20)
,其實就是在執行下面這段:
會記住變數 ans
的值
return function (20) {
if (ans[20]) {
// 如果 ans[20] 有東西的話,就直接回傳 ans[20] 的 value
return ans[20];
}
// 如果 ans[20] 沒東西的話
ans[20] = func(20); // 第一次執行時,會執行到這裡 ans[20] = complex(20)
return ans[20];
};
從 ECMAScript 看作用域
每一個 EC 都有一個 scope chain。當進入一個 EC 時,scope chain 就會被建立
什麼是 AO (Activation Object)?
Activation Object 和 Variable Object 做的事情是一樣的,都是拿來存放相關的資訊,只是在 global EC 裡面稱作 VO,在 function EC 裡面稱作 AO
當進入到 global EC 後,VO 會被建立,裡面存放所有相關的資訊
當進入到一個 function EC 後,AO 會被新增,裡面也是存放所有相關的資訊(有一個預設的屬性是 arguments
)
再次 cosplay JS 引擎
var v1 = 10;
function test() {
var vTest = 20;
function inner() {
console.log(v1, vTest); // 10 20
}
return inner;
}
var inner = test();
inner();
用上面這段程式碼為例,我們假裝自己是 JS 的引擎,看看背後是怎麼實際執行的:
1. 首先,會進入 globalEC
在 globalEC 裡面:
- 初始化 VO:
- 變數
v1
被初始化成 undefined - 變數
inner
被初始化成 undefined test
是一個 function
- 變數
- 設定 globalEC 的
scopeChain
會是[globalEC.VO]
設定 function test
的 [[Scope]]
屬性,會把「上一層的 scopeChain
給複製進來」,也就是 [globalEC.VO]
globalEC: {
VO: {
v1: undefined,
inner: undefined,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = [globalEC.VO]
2. 接著,開始執行 globalEC 的程式碼
第 1 行 var v1 = 10;
,會把 globalEC.VO
裡面的 v1
變成 10
第 10 行 var inner = test();
,會執行 function test
,就進入了 testEC
3. 進入 testEC
在 testEC
裡面:
- 初始化 AO:
- 變數
vTest
被初始化成 undefined inner
是一個 function
- 變數
- 設定
testEC
的scopeChain
會是「自己的 AO」+「自己的[[Scope]]
屬性」,也就是[testEC.AO, globalEC.VO]
設定 inner
的 [[Scope]]
屬性,會把「上一層的 scopeChain
給複製進來」,也就是 [testEC.AO, globalEC.VO]
testEC: {
AO: {
vTest: undefined,
inner: func
},
scopeChain: [testEC.AO, globalEC.VO]
}
inner.[[Scope]] = [testEC.AO, globalEC.VO]
globalEC: {
VO: {
v1: 10,
inner: undefined,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = [globalEC.VO]
4. 開始執行 testEC 的程式碼
執行第 3 行 var vTest = 20;
把 vTest
的值更新成 20
當執行到第 7 行 return inner;
時,照理來說,testEC
執行完畢了,testEC
和 testAO
就要被清空
可是,因為 function inner
被 return 回去,在 inner.[[Scope]]
會需要引用到 testEC.AO
和 globalEC.VO
,所以 JS 底層的垃圾回收機制就不能把 testEC.AO
和 globalEC.VO
回收掉,testEC.AO
和 globalEC.VO
都會被存在 function inner
裡面
scopeChain
、[[Scope]]
和 Closure 之間的關係,就是 Closure 的原理:因為保留了 scopeChain
,所以在離開了前一個 function 後,還是可以存取的到前一個 function 的 AO 的值
因為 function 的 [[Scope]]
屬性會把「要存取的 scopeChain
」給記起來,當我進入 function 的 EC 時,再去初始化 scopeChain
。scopeChain
會需要用到前面幾層的 AO
和 VO
,所以那些 AO
和 VO
必須被保留起來
在 testEC
執行完畢後,testEC
就被 pop 掉了(testEC
可以回收),但 testEC.AO
要保留起來
inner.[[Scope]] = [testEC.AO, globalEC.VO]
globalEC: {
VO: {
v1: 10,
inner: func,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = [globalEC.VO]
testEC.AO: {
vTest: 20,
inner: func
}
5. 進入 innerEC
在 innerEC
裡面:
- 初始化 AO:
- AO 裡面沒有任何東西
- 設定
innerEC
的scopeChain
會是「自己的 AO」+「自己的[[Scope]]
屬性」,也就是[innerEC.AO, testEC.AO, globalEC.VO]
inner.[[Scope]] = [testEC.AO, globalEC.VO]
innerEC: {
AO: {
},
scopeChain: [innerEC.AO, testEC.AO, globalEC.VO]
}
globalEC: {
VO: {
v1: 10,
inner: func,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = [globalEC.VO]
testEC.AO: {
vTest: 20,
inner: func
}
6. 開始執行 innerEC 的程式碼
執行這行 console.log(v1, vTest);
時,會在自己的 scopeChain
裡面一層一層往上找:
先找
v1
- 在
innerEC.AO
裡面,找不到v1
- 在
testEC.AO
裡面,找不到v1
- 在
globalEC.VO
裡面,找到v1: 10
,所以就會輸出 10
- 在
再找
vTest
- 在
innerEC.AO
裡面,找不到vTest
- 在
testEC.AO
裡面,找到vTest: 20
,所以就會輸出 20
- 在
Closure 要注意的地方:有可能會保留到「我不會用到的值」
假設,我在 function test
裡面宣告了一個很巨大的物件 var obj = { huge object };
var v1 = 10;
function test() {
var vTest = 20;
var obj = { huge object }; // 一個很巨大的物件
function inner() {
console.log(v1, vTest); // 10 20
}
return inner;
}
var inner = test();
inner();
當 testEC
執行完畢後,這個很巨大的物件還是會隨著 testEC.AO
被帶到 innerEC
的 scopeChain
裡面
雖然我在 function inner
裡面完全不會用到這個巨大的物件,但它還是沒辦法被回收掉,因為 obj: { huge object }
就是屬於 testEC.AO
的一部分
inner.[[Scope]] = [testEC.AO, globalEC.VO]
innerEC: {
AO: {
},
scopeChain: [innerEC.AO, testEC.AO, globalEC.VO]
}
globalEC: {
VO: {
v1: 10,
inner: func,
test: func
},
scopeChain: [globalEC.VO]
}
test.[[Scope]] = [globalEC.VO]
testEC.AO: {
vTest: 20,
inner: func,
obj: { huge object } // 這個很巨大的物件還是會存在
}
日常生活中的作用域陷阱
常見陷阱一
var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = function () {
console.log(i);
};
}
arr[0](); // 呼叫 arr[0] 這個 function,會印出 5
當我呼叫 arr[0]()
時,我以為會輸出的是 0,但結果是輸出 5
原因為:
因為是在 global 宣告 var i = 0
,所以變數 i
就是一個全域變數(等同於是下面這樣寫)
var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
arr[i] = function () {
console.log(i);
};
}
arr[0](); // 呼叫 arr[0] 這個 function,會印出 5
迴圈的每一圈都會建立一個 function,只是還沒執行:
// 當迴圈跑第一圈時
arr[0] = function () {
console.log(i);
};
// 當迴圈跑第二圈時
arr[1] = function () {
console.log(i);
};
... 以此類推
當執行到第 9 行 arr[0]()
時,就是執行 arr[0]
這個 function,那在 function 裡面的 console.log(i)
,要怎麼找到這個 i
呢?
因為在自己的 function scope 沒有 i
,所以會往上層的 scope(也就是 global scope)去找到這個 i
在執行第 9 行 arr[0]()
時,for 迴圈已經跑完了,這時候的全域變數 i
的值已經是 5 了,這就是為什麼最後 console.log(i)
的結果會是 5
解決方法一
利用閉包的原理,寫一個這樣的 function:
在 logN()
裡面傳入什麼值,就會印出什麼值
// 利用閉包的原理
function logN(n) {
return function () {
console.log(n);
};
}
const log2 = logN(2);
log2(); // 2
就可以把 for 迴圈裡面的程式碼改成 arr[i] = logN(i);
var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
arr[i] = logN(i);
}
// 利用閉包的原理
function logN(n) {
return function () {
console.log(n);
};
}
arr[0](); // 呼叫 arr[0] 這個 function,會印出 0
因為 logN(i)
會回傳一個新的 function,所以就會有一個新的作用域可以去記住當時的 i
的值
因為 i
是 logN(i)
所傳進去的參數,所以這個 i
會被記在 function logN
的 Activation Object 裡面
改成這樣之後,就會是我想要的結果了:
執行 arr[0]()
就輸出 0,執行 arr[1]()
就輸出 1
用 IIFE 立即執行函式(通常是給匿名函式用的)
一般我要呼叫一個 function,就是用 test()
這種方式
function test() {
console.log("hello");
}
test(); // 呼叫 function test
那要怎麼呼叫一個匿名的 function 呢?
在 JS 裡面,如果要呼叫一個「匿名的 function」,可以用 IIFE (immediately-invoked function expression)這個方法,中文翻作「立即呼叫函式表達式」,範例如下:
把 function 用一個小括號包起來,後面再加上一個小括號(裡面可以傳參數進去)
(function (num) {
console.log(num);
})(123); // 立即呼叫函式,會輸出 123
用 IIFE 也是可以呼叫一個有名字的函式:
(function test() {
console.log("hello");
})(); // 立即呼叫函式,會輸出 hello
解決方法二
所以上面的 for 迴圈範例,就可以改成這樣:
改成用 IIFE 的好處是「不用再額外宣告一個 logN
函式」
var arr = [];
var i; // i 是全域變數
for (i = 0; i < 5; i++) {
arr[i] = (function (number) {
return function () {
console.log(number);
};
})(i);
}
arr[1](); // 呼叫 arr[0] 這個 function,會印出 1
解決方法三
在 for 迴圈裡面,改為用 let
宣告 i
,就可以了
var arr = [];
// 改為用 let 宣告 i
for (let i = 0; i < 5; i++) {
arr[i] = function () {
console.log(i);
};
}
arr[1](); // 呼叫 arr[0] 這個 function,會印出 1
arr[3](); // 呼叫 arr[0] 這個 function,會印出 3
在 for 迴圈裡面,let
的表現不太一樣
用 let
宣告的變數,作用域會是一個 block
for 迴圈跑 5 圈,可以想成是「會產生 5 個 block」,每一圈都會有一個「自己的作用域」,像是這樣:
所以,每一圈的 i
,就會跟 arr[i]
的 i
有相同的值
// 迴圈第 1 圈
{
let i = 0;
arr[0] = function() {
console.log(i)
}
}
// 迴圈第 2 圈
{
let i = 1;
arr[1] = function() {
console.log(i)
}
}
// 迴圈第 3 圈
{
let i = 2;
arr[2] = function() {
console.log(i)
}
}
... 以此類推
Closure 可以應用在哪裡?
用 Closure 來隱藏某些資訊
舉一個例子:
雖然我寫了兩個 function 去操控 money
但是,我會碰到的問題是:如果有其他同事跟我協作,他完全可以不透過這兩個 function,就任意把 money
的值改掉,這是我完全沒辦法預防的
var money = 99;
function add(num) {
money += num;
}
function deduct(num) {
if (num >= 10) {
money -= 10;
} else {
money -= num;
}
}
add(1); // money = 100
deduct(100); // money = 90
money = -1; // 被同事亂改成 -1
console.log(money); // -1
這時,就可以用閉包來解決這個問題:
利用閉包的特性,把這些 function 給封裝起來,會比較安全
function createWallet(initMoney) {
var money = initMoney;
return {
add: function (num) {
money += num;
},
deduct: function (num) {
if (num >= 10) {
money -= 10;
} else {
money -= num;
}
},
getMoney: function () {
return money;
},
};
}
let myWallet = createWallet(99);
myWallet.add(1);
myWallet.deduct(100);
console.log(myWallet.getMoney()); // 90
改成用這樣寫之後,其他人就沒辦法用什麼 myWallet.money = -1;
來把我的 money
亂改值了