這篇要了解的是閉包以及它可以應用的地方,順便也分析和閉包相關的一題常見面試題目。
讀者可以先閱讀以下的範例程式碼:
function myDog() {
let name = 'puppy';
let age = 5;
let funcs = {
getAge() {
return age;
},
setAge(newAge) {
age = newAge;
}
}
return funcs;
}
const dog = myDog();
dog.setAge(6);
console.log(dog.getAge());
接著我們用前兩天學到的概念來說明這段程式碼的運作。
接著 myDog 函式執行完畢,Execution Context 被移除,全域的變數 dog 被賦值。
根據內部函式可以取用外部函式的變數或是全域變數的特性,所以 getAge & setAge 都有用到 age 變數,age 變數被保留了下來,而 name 變數被移除,因為沒有函式會引用到它。
所以即使 myDog 函式已經執行結束,它的 execution context 移除,但它的內部函式依然保留找到上層作用域的參照。也就是說好像能夠記憶函式創建時的環境,讓函式去取得作用域鏈上層的一些變數、參數值,這個就是所謂的閉包!
最後執行 dog.setAge(6);
這行程式碼時,將閉包內的 age 改成了 6,所以最後一行印出了 6。
要留意一點,若外層變數在外層函式有多次被運算、賦值,則內層函式取到的是最終計算的結果,例如以下範例程式碼,greeting 變數在 getGreetingWithUser 函式宣告後被重新賦值,則 getGreetingWithUser 函式取得到的是重新賦值後的結果。
const greetFactory = () => {
let greeting = 'Hello';
let getGreetingWithUser = user => {
return `${greeting}, ${user?.name}`;
}
greeting = 'Hey';
return getGreetingWithUser;
}
const userGreeting = greetFactory();
console.log(userGreeting({ name: 'Harry' }));
let greeting = 'Hello';
let getGreetingWithUser = user => {
return `${greeting}, ${user?.name}`;
}
greeting = 'Hey';
return getGreetingWithUser;
}
const userGreeting = greetFactory();
console.log(userGreeting({ name: 'Harry' })); // "Hey, Harry"
了解閉包是什麼後,來看看它是怎麼被應用。其實閉包的觀念在我們寫程式時就蠻常被用到了。
以下範例中,createCounter 會回傳一個函式,回傳的函式又會回傳 count 值,不過因為兩個 counter 分別重新產生各自的 function,所以不會彼此干擾,也就是說每一個閉包保存的都是一個獨立的環境。
function createCounter() {
let count = 0;
return function() {
count++;
return count;
}
}
const counter1 = createCounter();
console.log(counter1()); // 1
console.log(counter1()); // 2
const counter2 = createCounter();
console.log(counter2()); // 1
console.log(counter2()); // 2
console.log(counter2()); // 3
tip: 搭配 cache 使用
利用閉包封裝變數或是函式,如下範例中,利用了閉包的特性,count 的值會被記憶住,所以可以搭配 counter 物件的函式做使用。
const createCounter = function (init) {
let count = 0;
return {
increment() {
count++;
return init + count;
},
decrement() {
count--;
return init + count;
},
reset() {
count = 0;
return init;
},
};
};
/**
* const counter = createCounter(5)
* counter.increment(); // 6
* counter.reset(); // 5
* counter.decrement(); // 4
*/
其他像 柯里化(Currying) 與 Partial application 等 FP 相關觀念也都有使用到閉包。
總結來說,我們可以透過閉包去創造私有的變數、函式,避免變數的汙染,但相反的缺點是某些變數不會被 GC,使用不當可能造成 Memory Leak。
昨天和今天瞭解 Execution Context 後,來做個小練習,試試看最後的 showName()
會印出什麼?
function getName() {
let name = "Jack";
return function() {
console.log(name);
};
}
let name = "John";
let showName = getName();
showName(); // 印出什麼?
這邊會產生幾個 execution context,包括 return function() { console.log(name); };
、getName()
函式 以及全域的 execution context,雖然 showName()
是在全域被呼叫,但它的 execution context 根據閉包特性能查找到 getName()
宣告的 name 變數,故印出 'Jack'。
相信有很多讀者在面試時都可能看過這樣的一個題目:
for(var i = 0; i < 5; i++) {
setTimeout(function(){
console.log(i); // ?
}, 1000)
}
首先面試官會問這題會印出什麼? 當然都會是 5,那接著面試官一定會追問說為什麼? 那原因說明如下:
由於 setTimeout 的 callback function 會移到 Task Queue 等 for loop 執行完才執行,此時函式根據閉包特性,變數 var i 是個全域變數,並且所參考的值都是最終執行完的 i,所以都印出 5。
接著面試官可能還會問如何避免都印出 5 而是印出 1, 2, 3, 4, 5,這可以有好幾種解法,以下介紹常見的解法:
因為每次帶入 counter 函式的參數值都不一樣,所以就可以順利解決問題。
function counter(num) {
setTimeout(function() {
console.log(num);
}, 1000)
}
for(var i = 0; i < 5; i++) {
counter(i);
}
立即呼叫函式,每次 for loop 會產生新的作用域,將當前 i 值帶入函式。
for (var i = 0; i < 5; i++) {
(function(num) {
setTimeout(function(){
console.log(num);
}, 1000)
})(i)
}
利用 let 區塊作用域的特性,每次 for loop 都產生新的 Lexical Environment,裡面包含當前的 i 值,所以印出的值就會是 0~4
for(let i = 0; i < 5; i++) {
setTimeout(function(){
console.log(i);
}, 1000)
}
setTimeout 其實可以接收很多個參數,從第三個參數之後的參數會作為 setTimeout 第一個參數函式的參數。
所以這樣每次從函式印出的值都是從 for 迴圈中取出的 i 值,也就能依序印出 0~4。
for(var i = 0; i < 5; i++) {
setTimeout(function(i){
console.log(i);
}, 1000, i)
}
其他的解法還有很多,有興趣可以上網再進一步尋找,像 try + catch 也可以 XD。
// 使用 try + catch
for (var i = 0; i < 5; i++) {
try {
throw i;
} catch(i) {
setTimeout(() => { console.log(i); }, 1000);
}
}
// 或是直接把 setTimeout 拿掉(欸不是
// for (var i = 0; i< 10; i++) { console.log(i); }
[第十七週] JavaScript 進階:什麼是閉包 Closure 與實際應用