Skip to content
坑坑洞洞
Github

給自己看的 JavaScript 進階 - closure

程式導師計劃, coding 筆記, JavaScript6 min read

給自己看的 JS 進階:(建議按照順序看) 給自己看的 JS 進階-變數 給自己看的 JS 進階-Hoisting 給自己看的 JS 進階-Closure 給自己看的 JS 進階-物件導向

Closure 閉包

先看一個例子:

function test() {
var a = 10
function inner() {
a++
console.log(a)
}
return inner // 回傳 inner 這個函數
}
var func = test()
fanc() // 也就是 inner(), 11
fanc() 12
fanc() 13

所謂 Closure 閉包 就是像這樣,在一個 function 中 return 一個 function 。當我們呼叫裡面的 function 時,裡面的 function 會將外面 finction 的值記起來並鎖在裡面,因此稱為閉包。

如果我們希望能記住上次計算的值,不用再算一次,就可以使用閉包,例如:

function complex(n) {
// 複雜計算
console.log('caculate');
return n * n;
}
function cache(func) {
var ans = {};
return function (num) {
if (ans[num]) {
// 如果有紀錄就直接回傳值
return ans[num];
}
ans[num] = func(num);
return ans[num];
};
}
const cacheComplex = cache(complex);
console.log(cacheComplex(20)); // caculate 400
console.log(cacheComplex(20)); // 400
console.log(cacheComplex(20)); // 400

只要算過一次 ans[num] 就會被記起來,之後就都不用再跑一次 complex 了。

Closure ㄉ原理

ECMAScript ES3 版本中有提到,每個 CE 都有一個 Scope Chain ,進入 EC 時, Scope Chain 被初始化為 Activation Object (其實就是 function 中的 VO) 和 [[scope]]

看一個簡單的例子:

var a = 1;
function test() {
var b = 2;
function inner() {
var c = 3;
console.log(a); // 1
console.log(b); // 2
}
inner();
}
test();

此時底層的狀態是:

Global EC: {
VO: {
a: undefined,
test:func
},
scopeChain: [Global VO]
}
// 初始化一下
test.[[Scope]] = globalEC.scopeChain // [Global.VO]

進到 testEC 之後如下:

test EC: {
AO: {
b: undefined,
inner: func
},
scopeChain: [testEC.AO, test.[[Scope]]]
// 看上一格,也就是 [testEC.AO, globalEC.scopeChain]
// 也就是 [testEC.AO, Global.VO]
}
// 初始化一下
INNER.[[Scope]] = testEC.scopeChain // [testEC.AO, Global.VO]
Global EC: {
VO: {
a: 1,
test: func
},
scopeChain: [Global.VO]
}

最後進入 innerEC:

inner EC: {
AO: {
c: undefined,
},
scopeChain: [innerEC.AO, inner.[[Scope]]]
// 也就是 [innerEC.AO, testEC.AO, Global.VO]
}
test EC: {
AO: {
b: 2,
inner: func
},
scopeChain: [testEC.AO, test.[[Scope]]]
// 也就是 [testEC.AO, Global.VO]
}
Global EC: {
VO: {
a: 1,
test: func
},
scopeChain: [Global.VO]
}

此時回到一開始的例子:

function test() {
var a = 10;
function inner() {
a++;
console.log(a);
}
return inner; // 回傳 inner 這個函數
}
var func = test();
fanc(); // 也就是 inner()

最後一行執行時 test EC 已經結束,本來底層機制應該要全部拿掉,但因為 innerEC.scopeChain[innerEC.AO, testEC.AO, Global.VO] ,因此 testEC.AO 還不能那麼快退場。這就是為什麼 inner 可以拿到上一層變數值並儲存更改的原因。

不過偶爾閉包也會產生一些問題,例如外層包了超大的物件,就算之後只使用內層,因為關聯外層的 AO ,那個超大物件就無法被回收。

Closure 的小陷阱

var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = function () {
console.log(i);
};
}
arr[0]();

本來預期會得到一到五,結果出來卻只有 5 。

此處的 i 是一個 global 的變數,當我們呼叫 arr[0]() 時,是進到 for 迴圈中拿函數,因此函數中的 scope chain 會連動到 global 的 AO ,呼叫時 for 迴圈已經跑完,所以 global.VO 中 i 的值是 5 ,延用該 VO 的函數自然而然會輸出 5 。

解決方法:

function logN(n) {
// 閉包的概念
return function () {
console.log(n);
};
}
const log2 = logN(2);
log2(); // 2
var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = logN(i);
}
arr[0](); // 0

因為 arr[i] 會迴傳一個新的 function ,因此會產生新的作用域去記住傳入的值。

也可以使用 IIFE ,也就是立即呼叫函式,例如:

(() => {
console.log(123);
})(); // 立刻執行,輸出 123

回到剛剛的問題,我們也可以把剛剛的函式 logN 放進去:

var arr = [];
for (var i = 0; i < 5; i++) {
arr[i] = (function (num) {
return function () {
console.log(num);
};
})(i);
}
arr[0](); // 0

也可以直接這樣寫:

var arr = [];
for (let i = 0; i < 5; i++) {
arr[i] = function () {
console.log(i);
};
}
arr[0]();

因為 let 的作用域只存在 block 中,迴圈等於是跑了五個 block arr[i] = function() { console.log(i) } ,每一圈都有自己的作用域,所以呼叫 arr[0] 時自然就找到印出 0 的函數。

Closure 的範例

Closure 隱藏資訊不被額外操控的時候很好用,例如:

var money = 99;
function add(num) {
money += num;
}
function deduct(mun) {
money -= num;
}
add(1);
deduct(10);
console.log(money); // 90
// 這時你同事很壞,加了一行
money -= 90;
// 就算繞過任何操作還是可以改變 money 的值

此時就可以使用 Closure:

function createWallet(init) {
var money = init
return {
add: function(num) {
money += num
},
deduct: function(num) {
money -= num
}
}, getMoney() {
return money
}
}
var myWallet = createWallet(99)
myWallet.add(1)
myWallet.deduct(10)
console.log(myWallet.getMoney()) // 90