JavaScript 核心 - 你其實已經用過閉包(closure)


Posted by ai86109 on 2020-09-29

前言

如果你還不清楚 EC, VO 等名詞以及其運作,建議先看:JavaScript 核心 - 來講講提升(hoisting),不然可能會看不懂這篇所要講的東西。

話不多說,直接先看個程式碼

function complex(num) {
  console.log('calculate')
  return num * num * num
}

function cache(func) {
  var ans = {}
  return function(num) {
    if(ans[num]) {
      return ans[num]
    }
    ans[num] = func(num)
    return ans[num]
  }
}

const cachedComplex = cache(complex)
console.log(cachedComplex(20))
console.log(cachedComplex(20))
console.log(cachedComplex(20))

我們先來看一下變數 cachedComplex

因為他會 call cache 這個 function,而 cache 最後會 return 裡面一個 function,所以我們可以看成

const cachedComplex = return function(num) {
  if(ans[num]) {
    return ans[num]
  }
  ans[num] = func(num)
  return ans[num]
}

所以 call cachedComplex 這個 function,並帶入參數 20,這個參數就會是 num

const cachedComplex = cache(complex) 除了 call cache 這個 function 外,也帶入參數 complex(也就是最上面這個 function complex)

所以 function cache(func) 的 func 就是 function complex

OK,搞懂所有變數之後,我們就來實際跑一遍

-

第一次 console.log call cachedComplex 帶參數 20 給 num

call cache 這個參數並帶 complex 這個 function 給 func

進入 cache 裡,此時 ans 是一個空物件

進到匿名函式中,因為 ans[num] 是 false,所以 if 不成立,因此繼續往下執行

ans[num] = func(num),帶入參數後 ans[20] = complex(20)

進入 complex 後,先印出 calculate

接著回傳 8000

ans[20] = 8000

所以 ans = { 20: 8000 }

並且將 ans[20] 的值回傳

剛剛我們說到 const cachedComplex = cache(complex) 可以看成

const cachedComplex = return function(num) {
  if(ans[num]) {
    return ans[num]
  }
  ans[num] = func(num)
  return ans[num]
}

所以 const cachedComplex = ans[20],會印出剛剛回傳的 8000

第二次 console.log 一樣帶參數 20,中間過程一樣

進到匿名函式中,因為 ans[num] 剛剛已經有值了,所以是 true

if(ans[num]) {
  return ans[num]
}

回傳 ans[20],最後印出 8000

*注意這邊是直接回傳,並沒有經過 function complex,所以不會回傳 calculate!

最後全部跑完會印出

calculate
8000
8000
8000

而以上這種在 function 裡 return function,又可以把值記住就是閉包的基本應用。


運作的規則

在講閉包前你該知道的事

根據 ECMAScript 的規範,每一個 EC 都有 scope chain,當你進入一個新的 function EC 時,scope chain 就會被建立。

Scope chain 裡包含 AO(Activation Object),還有 [[Scope]] 這個東西(只有 function 的 EC 才有)

scope chain: [AO, [[Scope]] ]

AO 是什麼?

其實 AO 跟我們之前說的 VO 很像

現階段就把他看成,在 global EC 裡的是 VO,在其他 function 裡的就是 AO

那什麼是 [[Scope]]

當我們宣告 function 時,就會產生一個隱藏屬性

function名字.[[Scope]] = 這一層的 scope chain

這邊先知道這樣就好,直接看範例走一遍就知道了!

var a = 1

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

test()

-
第一步先初始化

剛說到因為 global 不是 function 所以會建立 VO

然後進入一個新的 EC,scope chain 就會被建立

因為 global 有 function,所以也會建立一個隱藏的屬性 [[Scope]]

globalEC: {
  VO: {
    a: undefined,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
初始化完成,開始逐行執行賦值等

最後也執行到 test(),進入 testEC 的初始化

因為是 function 所以建立 AO

function test 內有 function,所以也建立 [[Scope]]

testEC: {
  AO:{
    b: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]]
  // 其實[testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.scopeChain] = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain
// 這邊也可以看成 = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    a: 1,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
接著我們一樣繼續逐行執行,然後 call inner

innerEC: {
  AO:{
    c: undefined
  },
  scopeChain: [innerEC.AO, inner.[[Scope]]] = [innerEC.AO, testEC.AO, globalEC.VO]
}

testEC: {
  AO:{
    b: 2,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain = [testEC.AO, globalEC.VO]

globalEC: {
  VO: {
    a: 1,
    test: func
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
逐行執行(因為這邊只有要幫 c 賦值,所以就不寫了)

接著 console.log(b),我們透過 scope chain 來找

scopeChain: [innerEC.AO, inner.[[Scope]]] = [innerEC.AO, testEC.AO, globalEC.VO]

在 innerEC 的 AO 沒找到,再去 testEC.AO 找

找到了 b,所以印出 2

再來 console.log(a),一樣用 scope chain 來找

在 innerEC 的 AO 沒找到,再去 testEC.AO 找也沒找到,再去 globalEC.VO 找

找到了 a,所以印出 1


所以我說那個閉包呢

那實際上閉包的怎麼運作的呢?

有了前面這些基礎知識之後,我們再實際跑一次以下程式碼:

var v1 = 10
function test() {
  var vTest = 20
  function inner() {
    console.log(v1, vTest)
  }
  return inner
}
var inner = test()
inner()

-
首先一樣初始化 global EC

globalEC: {
  VO: {
    v1: undefined,
    test: func,
    inner: undefined
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
執行程式碼,並且在執行到 var inner = test() 時,進入 test EC

globalEC: {
  VO: {
    v1: 10,
    test: func,
    inner: undefined
  },
  scopeChain: [globalEC.VO]
}

test.[[Scope]] = globalEC.scopeChain

-
進入 test EC

testEC = {
  AO: {
    vTest: undefined,
    inner: func
  },
  scopeChain: [testEC.AO, test.[[Scope]]] = [testEC.AO, globalEC.VO]
}

inner.[[Scope]] = testEC.scopeChain

globalEC = {
  VO: {
   v1: 10,
   inner: undefined,
   test: func 
  },
  scopeChain: globalEC.VO
}

test.[[Scope]] = globalEC.scopeChain

-
執行程式碼,並且把 inner 回傳回去

testEC = {
  AO: {
    vTest: 20,
    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.scopeChain

執行完後,照理說 test 這個 function 結束了,所以 testEC 應該就要釋放掉。

BUT! 因為我們把 inner 這個 function 回傳了,而 inner.[[Scope]] 裡面還記著 testEC.AO,所以導致 testEC.AO 還存在記憶體裡無法釋放。

testEC 執行完了仍困在記憶體裡,這也是為什麼我們可以把閉包裡的值記住,因為他並沒有被釋放。

接下來只剩下執行 inner(),就不再贅述。


其實你用過閉包了

在我們學到 closure 之前,相信很多人其實已經跟他打過照面了

我們用一個常見的例子來說明

var arr = []

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

arr[0]()

這邊我們用迴圈,依序在 arr 裡面放入可以 log 出位置 0~4 的 function

所以我們執行 arr[0]() 時,預期會印出 0

但最後結果竟然印出 5

-
這是因為當我們宣告 var i=0 時,其實是將 i 宣告在 global

而我們跑迴圈時

第一圈 i=0

arr[0] = function(){
  console.log(i)
}

第二圈 i=1

arr[1] = function(){
  console.log(i)
}

...後面以此類推

i=5 時,因為 i 必須 < 5,所以跳出迴圈

所以當後面執行 arr[0]() 時,就會 call

arr[0] = function(){
  console.log(i)
}

此時的 i 就是 5,所以才會印出 5

那我們要怎麼改寫成我們想要的呢?

(1) 用一個新的 function 代替

var arr = []

for(var i=0; i<5; i++){
  arr[i] = logN(i)
}

function logN(n){
  return function(){
    console.log(n)
  }
}

arr[0]()

i=0 時,會執行 logN 並帶入 i

這時候會產生一個作用域並且回傳 i

由於用到閉包的緣故,這些值會被保留

因此之後 call 數字時,就會印出相同的數字

-

(2) 立即呼叫函式(IIFE, Immediately Invoked Function Expression)

其實跟上面一樣只是用 IIFE 的方式讓程式碼較簡潔,但可讀性會變差

IIFE 的形式其實就是 (func)(參數)

所以我們要把上面那個 logN 改成立即呼叫函式,只需要幾個步驟

arr[i] = logN(i)

第一,將 logN 換成原本另外寫的 function,並且前後加上()

arr[i] = (function logN(n){
  return function(){
    console.log(n)
  }
})(i)

第二,將原本的 function 名刪掉

arr[i] = (function (n){
  return function(){
    console.log(n)
  }
})(i)

就完成了!

所以就是以下的程式碼:

var arr = []

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

arr[0]()

-
(3) 把 var 換成 let

因為 let 的 scope 是大括號內,因此迴圈跑 5 圈就會有 5 個 block

{
  let i = 0
  arr[0] = function(){
    console.log(i)
  }
}

{
  let i = 1
  arr[1] = function(){
    console.log(i)
  }
}

...以此類推,所以就可以印出想要的數字


所以閉包可以用在哪?

通常你會用 closure 是當你想隱藏東西的時候

什麼意思呢?我們用一個簡單的加減錢的算式來看

var money = 99

function add(num){
  money += num
}

function deduct(num){
  if(num >= 10){
    money -= 10
  }else{
    money -= num
  }
}

add(1)
deduct(100)
console.log(money)

答案便會是 90

但這樣做其實會有風險,如果我在 log 前面再加一行 money = -1,答案就會變了

要怎麼避免這件事呢,利用 closure

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

var myWallet = createWallet(99)
myWallet.add(1)
myWallet.deduct(100)
console.log(myWallet.getMoney())

這樣的寫法會讓我們無法從外部直接改寫 money 的值

只能用這個函式提供的 add & deduct

自由度降低,但相對較安全

以上就是 closure 的簡單介紹,可以參考這篇:所有的函式都是閉包:談 JS 中的作用域與 Closure,會有更詳細的說明!


#closure #javascript







Related Posts

Day00 - CERN ROOT 教學 | 動機、這是什麼

Day00 - CERN ROOT 教學 | 動機、這是什麼

【單元測試的藝術】Chap 3: 透過虛設常式解決依賴問題

【單元測試的藝術】Chap 3: 透過虛設常式解決依賴問題

自學程式設計與電腦科學入門實戰:Linux Command 命令列指令與基本操作入門教學

自學程式設計與電腦科學入門實戰:Linux Command 命令列指令與基本操作入門教學


Comments