iT邦幫忙

2022 iThome 鐵人賽

DAY 7
3

前言

這篇要了解的是閉包以及它可以應用的地方,順便也分析和閉包相關的一題常見面試題目。


從範例了解閉包

讀者可以先閱讀以下的範例程式碼:

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());

接著我們用前兩天學到的概念來說明這段程式碼的運作。

  1. 建立全域的 Execution Context,裡面的 Lexical Environment 有沒賦值的變數 dog。
  2. 呼叫 myDog 函式並建立 myDog 函式的 Execution Context,裡面有三個沒賦值的變數。
  3. 開始執行 myDog 函式,函式內的三個變數被賦值,至此整個 Call Stack 內部如下圖:

接著 myDog 函式執行完畢,Execution Context 被移除,全域的變數 dog 被賦值。

根據內部函式可以取用外部函式的變數或是全域變數的特性,所以 getAge & setAge 都有用到 age 變數,age 變數被保留了下來,而 name 變數被移除,因為沒有函式會引用到它。

所以即使 myDog 函式已經執行結束,它的 execution context 移除,但它的內部函式依然保留找到上層作用域的參照。也就是說好像能夠記憶函式創建時的環境,讓函式去取得作用域鏈上層的一些變數、參數值,這個就是所謂的閉包!

最後執行 dog.setAge(6); 這行程式碼時,將閉包內的 age 改成了 6,所以最後一行印出了 6。

2023/11/01 補充

要留意一點,若外層變數在外層函式有多次被運算、賦值,則內層函式取到的是最終計算的結果,例如以下範例程式碼,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"

閉包應用

了解閉包是什麼後,來看看它是怎麼被應用。其實閉包的觀念在我們寫程式時就蠻常被用到了。

範例1

以下範例中,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 使用

範例2

利用閉包封裝變數或是函式,如下範例中,利用了閉包的特性,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,這可以有好幾種解法,以下介紹常見的解法:

1. 避免閉包結構

因為每次帶入 counter 函式的參數值都不一樣,所以就可以順利解決問題。

function counter(num) {
  setTimeout(function() {
    console.log(num);
  }, 1000)
}

for(var i = 0; i < 5; i++) {
  counter(i);
}

2. IIFE

立即呼叫函式,每次 for loop 會產生新的作用域,將當前 i 值帶入函式。

for (var i = 0; i < 5; i++) {
  (function(num) {
    setTimeout(function(){
      console.log(num);
    }, 1000)
  })(i)
}

3. let

利用 let 區塊作用域的特性,每次 for loop 都產生新的 Lexical Environment,裡面包含當前的 i 值,所以印出的值就會是 0~4

for(let i = 0; i < 5; i++) {
  setTimeout(function(){
    console.log(i);
  }, 1000)
}

4. 給 setTimeout 添加第三个參數

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 與實際應用

Encapsulation in JavaScript


上一篇
Day6-JavaScript Execution Context & var/let/const
下一篇
Day8-JavaScript this 關鍵字
系列文
強化 JavaScript 之 - 程式語感是可以磨練成就的30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

1
json_liang
iT邦研究生 5 級 ‧ 2022-09-07 10:52:57

感謝大大講解閉包概念,這個概念真的很重要。

1
雷N
iT邦研究生 1 級 ‧ 2022-09-07 11:51:08

Closure真的是很常見的設計與面試概念
介紹的很棒 感謝

我要留言

立即登入留言