[ 筆記 ] JavaScript 進階 06 - Closure


Posted by krebikshaw on 2020-09-26

在理解 hoisting 之前需要先了解 Scope Chain 的概念,可以先參考

[ 筆記 ] JavaScript 進階 05 - Execution Context - Scope Chain

前情提要

在我們上一篇介紹完 Scope Chain 的概念之後,接下來我們要把前面幾章個概念統整一下。

在了解 JavaScript 的運作模式之後,首先先建立一個 function ,並且以這個 function 的生命週期作為一個範例:

  1. function 一開始被「宣告」的時後,會同時建立 function 的 [[scope]] 屬性
  2. 當 function 被「呼叫」的瞬間,會先建立新的 Execution Context,並完成 AO 及 scopeChain 的初始化
  3. 初始化完成後,這個 function 的 Execution Context 就會被疊到 stack 的上方作為當前正在執行的事件,並且開始執行 function
  4. 執行完成後,stack 上的 Execution Context 會被 pop 掉,並且把 function 的 AO 及 scopeChain 釋放掉

以一個簡單的程式碼作為範例:

function test() {
  var a = 10;
  return a++;
}

// 重複呼叫同一個 function,會輸出相同的結果
console.log(test()) // 11
console.log(test()) // 11
console.log(test()) // 11

我們可以看到,變數 a 是被宣告在 test 這個 function 裡面的,所以一但 function 執行結束,這個 a 也會被釋放掉,所以不管我們重複呼叫同一個 function 多少次,輸出的都會是相同的結果。

但是下面這段程式碼就不一樣了:

function outter() {
  var a = 10;
  return function inner() { 
    a++
    console.log(a);
  }
}

let func = outter()
func()  // 11
func()  // 12
func()  // 13

現在只是把 a++ 這個動作,另外用一個 function 包起來而已,但是輸出的卻是完全不同的結果。我們可以看到 outter 已經 return 了,代表 outter 已經執行結束,照理來說變數 a 應該要被釋放掉,但我們看到的結果是,a 的數值被儲存起來了。

初探 Closure

一般 function 執行完之後,裡面的資源就會全部被釋放掉( 垃圾回收機制 ),而 Closure 可以保存著裡面的狀態,以上面的例子來說就是 a 這個變數,就算 outter 執行完,變數也沒有被清掉。

但照理來說,outter() 執行完畢後,應該就會被 JS 的垃圾回收機制回收掉啊,而到底是怎麼做到的呢?

首先我們來一步步拆解 Call Stack 中的步驟:

  1. 建立 global EC 初始化 VO 及 scopeChain,同時建立 outter 的 [[scope]] 屬性
    globalEC: {
    VO: {
     func: undefined,
     outter: function
    },
    scopeChain: [globalEC.VO]
    }
    outter.[[scope]] = globalEC.scopeChain
    
  2. 呼叫 outter() 建立 outterEC 初始化 AO 及 scopeChain,同時建立 inner 的 [[scope]] 屬性
    outterEC: {
    AO: {
     a: undefined,
     inner: function
    },
    scopeChain: [outterEC.AO, [[scope]]]
    }
    inner.[[scope]] = [outterEC.AO, globalEC.VO]
    

關鍵就在這邊了,inner 被宣告的同時,建立了 [[scope]] 屬性,而裡面紀錄著的父層 outter scopeChain 的值,如此一來,inner 才可以存取到 outter 的作用域。
也就是因為 inner 存取著 outter 的作用域,所以當 inner 被 return 出去的同時,outterEC.AO 得要保留下來,確保 inner 需要存取資料時可以存取得到。

  • 當內層函式存取了外部函式的變數時,就產生了 Closure

那這份 outterEC.AO 會保留到什麼時候呢?

  • outterEC.AO 會在主程式 global 結束之後,才會被真的釋放掉

Closure 幾項執行重點

  1. 要保存的變數需要宣告在外層函式裡面
    • ex. a 要宣告在 outter 裡面
  2. 要存取變數的函式(內層函式)一定要宣告在外層函式裡面
    • ex. inner 要宣告在 outter 裡面
  3. 外層函式必須把這個內層函式回傳出去
    • ex. outter 要把這個 inner function return 出去
  4. 外頭要建立一個新的變數來接這個內層函式 (這步很關鍵)
    • ex. let func = outter()
    • func 會接收到 outter() return 出來的 inner function
    • 這邊可以看成 func = inner
  5. 藉由呼叫這個新的變數來執行內層函式
    • ex. func()

這邊要來解釋,為什麼第 4 步要建立一個新的變數來接住這個內層函式,我們來看看如果不做這步會發生什麼事:

function outter() {
  var a = 10;
  return function inner() { 
    a++
    console.log(a);
  }
}

outter()  // 這樣執行不會有任何作用,因為 outter() 會回傳 inner 而不是執行 inner

// 所以要執行 inner 要寫成這樣 outter()(),我們多執行個幾遍
outter()()  // 11
outter()()  // 11
outter()()  // 11

我們可以發現,用 outter()() 這個方式執行,沒有出現 Closure 的效果,原因是因為,我們每一次重新呼叫 outter() 的時候,就會建立新的 scopeChain(待會再回來解釋這是什麼意思)

我們先看看為什麼第 5 步要藉由呼叫這個新的變數來執行內層函式,如果不做這步會發生什麼事:

function outter() {
  var a = 10;
  return function inner() { 
    a++
    console.log(a);
  }
}

let func = outter()
func()  // 11
func()  // 12
outter()()  // 11
func()  // 13
func()  // 14

咦~怎麼會這樣?
在呼叫 func 的過程中,穿插呼叫 outter()(),結果再下一次呼叫 func,數值居然沒有被打亂?(以為又會從 11 重新開始)

這是為什麼呢?

  • 因為 inner 存取著 outter 的作用域,每一次呼叫 outter(),它回傳的 inner 都會有自己獨一無二的 scopeChain

也就是說,我如果把 outter() 回傳的 inner 賦值到不同的變數,每個變數存取到的 a 是不會互相干擾的:

function outter() {
  var a = 10;
  return function inner() { 
    a++
    console.log(a);
  }
}

let func = outter()
let qq = outter()
let mm = outter()

func()  // 11
func()  // 12
qq()    // 11
mm()    // 11
func()  // 13
qq()    // 12
qq()    // 13
func()  // 14
mm()    // 12

建立三個變數 func, qq, mm 來接住 outter() 回傳的 inner
三個變數對應到的是三個不同的 scopeChain,也就是說 global 會記憶住三組不同的 outterEC.AO,我建立越多組,global 要記憶的 outterEC.AO 就越多組。所以三個函式不會相互影響。

這裡就說明了為什麼剛剛 outter()() 呼叫了三次都會印出 11,因為每一次呼叫所建立的 scopeChain 是不同的。

以上就是利用 Closure 時需要注意的幾項執行重點。接下來我們再來看看 Closure 可以用來做什麼

Closure 的作用

Closure 作用一: 可以保存狀態、且不需要用到容易污染的全域變數,也可以讓外部修改到狀態。

一般寫法
假如寫一個計數器的小程式,傳統的寫法如以下,但因為 count 暴露在外部 ( 全域 ),會有不小心修改到 count 的可能:

var count = 0;
function addCount(){
    count++;
    return count;
}

console.log(addCount()); // => 1
console.log(addCount()); // => 2

Closure 寫法
可以用閉包 Closure 的寫法、改寫成以下:

function createCounter() {
    var count = 0;
    function addCount() {
        count++;
        return count;
    }
    return addCount; // => 重點,回傳一個 addCount function
}

var counter = createCounter(); // 重點,counter 是一個 function: addCount
console.log(counter()); // => 執行 addCount 內容,回傳 1
console.log(counter()); // => 執行 addCount 內容,回傳 2
console.log(counter); // => [function: addCount]

實際上就是把剛剛的程式碼再包一層 function 而已,然後重點在於回傳「 要執行的 function 內容 」,這樣就可以把要保護的狀態包起來,又可以用到裡面的 method,因為狀態跟 method 在同一個作用域底下。

這樣一來,外部就存取不到 count 這個變數,所以 closure 簡單來說,可以當成是「 在 function 裡面回傳 function 」。

Closure 作用二: 拿來避免重複的運算

如果有某種運算是花費大量的時間且又會不停重複,就可以用閉包來做優化。

利用 Closure 寫一個簡單的 Cached 程式,如果有某輸入曾經丟入過 complex(),那就把輸出結果存起來,如果下次有同樣的輸入,就直接存過的拿值、無需重新運算。

function complex(num) { // => 假設 complex 要花費很多時間
  console.log('caculating');
  return num * num * num;
}

function Cache(func) {
  let ans = {};
  return function (num) {
    if (ans[num]) {
      return ans[num]; // => ans 有存過,直接回傳
    }
    return ans[num] = func(num); // => 沒存過,丟入 ans 並回傳
  }
}

const CachedComplex = Cache(complex);
console.log(CachedComplex(10)); // 'caculating',1000 => 跑運算結果
console.log(CachedComplex(10)); // 1000 => 直接拿值
console.log(CachedComplex(10)); // 1000 => 直接拿值

Closure 作用三: 封裝變數

假設我們有幾個函式 add, deduct, getMoney,都會修改到變數 money,但是又不希望他人從外部去直接修改 money 的數值。

我們可以把 money 放進 createWallet 的函式裡,在執行 createWallet 丟進初始化的值。再把需要用到的方法 add, deduct, getMoney return 出去。

在外面用一個 wallet 變數接住 createWallet() 的回傳值 ( 其實就是有 3 個 method 的 Object )
如此一來 wallet 就保留住 money 的狀態,也僅能透過調用 add, deduct, getMoney 來存取 money 這個變數,外部是無法使用的,此舉就稱為「 封裝 」。

function createWallet(init) {
  var money = init;
  return {
    add(num) {
      money += num;
    },
    deduct(num) {
      if (num > 10) {
        num -= 10;
      }
      else {
        money -= num;
      }
    },
    getMoney() {
      return money;
    }
  }
}

var wallet = createWallet(100);

wallet.add(1);
wallet.deduct(30);

console.log(wallet.getMoney());

Closure 的應用方式很多種,重點是要釐清,Closure 的發生跟 scopeChain 之間的關係,以及去意識到 function 存取變數時的路徑,會與 function 被「宣告」的位置有關,與他如何被「呼叫」無關。


#closure







Related Posts

週末台中聚加完成第 7 週卡關作業

週末台中聚加完成第 7 週卡關作業

570. Managers with at Least 5 Direct Reports

570. Managers with at Least 5 Direct Reports

React 基礎:以 state 為中心去思考

React 基礎:以 state 為中心去思考


Comments