2017年5月13日

深入淺出瞭解 JavaScript 閉包(closure)


雖然之前在 Udemy 上課時有整理了幾篇關於閉包的筆記,但其實當時對於閉包的概念和使用上還是不很清楚,只是大概知道有這個概念。
前陣子在 Treehouse 上課時,聽到了另一個用來說明閉包的例子,我覺得講解的非常清楚,在這裡做個筆記記錄一下。
個人覺得先瞭解閉包的應用,接著在回去看之前上 Udemy 時整理的和閉包有關的文章時,在理解上更有幫助。

不使用閉包(closure)的情況

在 JavaScript 中,global variable 的錯用可能會使得我們的程式碼出現不可預期的錯誤。
假設我們現在要做一個計數的程式,一開始我們想要先寫一個給狗的計數函式:
// 狗的計數程式
var count = 0

function countDogs () {
  count += 1
  console.log(count + ' dog(s)')
}

countDogs()    // 1 dog(s)
countDogs()    // 2 dog(s)
countDogs()    // 3 dog(s)
接著繼續寫程式的其他部分,當寫到程式的後面時,我發現我也需要寫貓的計數程式,於是我又開始寫了貓的計數程式:
// 狗的計數函式
var count = 0

function countDogs () {
  count += 1
  console.log(count + ' dog(s)')
}


// 中間是其他程式碼...

// 貓的計數函式
var count = 0

function countCats () {
  count += 1
  console.log(count + ' cat(s)')
}

countCats()    // 1 cat(s)
countCats()    // 2 cat(s)
countCats()    // 3 cat(s)

乍看之下運作上好像沒有問題,當我執行 countDogs()countCats(),都會讓 count 增加,然而問題在於當我在不注意的情況下把 counter 這個變數建立在了全域的環境底下時,不論是執行 countDogs() 或是 countCats() 時,都是用到了全域的 count 變數,這使得當我執行下面的程式時,他沒有辦法分辨現在到底是在對狗計數還是對貓計數,進而導致把貓的數量和狗的數量交錯計算的情錯誤況:
var count = 0

function countDogs () {
  count += 1
  console.log(count + ' dog(s)')
}


// 中間是其他程式碼...

var count = 0

function countCats () {
  count += 1
  console.log(count + ' cat(s)')
}

countCats()    // 1 cat(s)
countCats()    // 2 cat(s)
countCats()    // 3 cat(s)

countDogs()    // 4 dog(s),我希望是 1 dog(s)
countDogs()    // 5 dog(s),我希望是 2 dog(s)

countCats()    // 6 cat(s),我希望是 4 cat(s)

透過閉包讓 function 能夠有 private 變數

從上面的例子我們知道,如果錯誤的使用全域變數,程式很容易會出現一些莫名其妙的 bug ,這時候我們就可以利用閉包(closure)的作法,讓函式有自己私有變數,簡單來說就是 countDogs 裡面能有一個計算 dogs 的 count 變數;而 countCats 裡面也能有一個計算 cats 的 count 變數,兩者是不會互相干擾的。
為了達到這樣的效果,我們就要建立閉包,讓變數保留在該函式中而不會被外在環境干擾。
改成閉包的寫法會像這樣:
function dogHouse () {
  var count = 0
  function countDogs () {
    count += 1
    console.log(count + ' dogs')
  }
  return countDogs
}

const countDogs = dogHouse()
countDogs()    // "1 dogs"
countDogs()    // "2 dogs"
countDogs()    // "3 dogs"
這樣我們就將專門計算狗的變數 count 關閉在 dogHouse 這個函式中,上面這是閉包的基本寫法,當你看到一個 function 內 return 了另一個 function,通常就是有用到閉包的概念
從程式碼中我們可以看到在 dogHouse 這個函式中裡面的 countDogs() 才是我們真正執行計數的函式:
而在 dogHouse 這個函式中存在 count 這個變數,由於 JavaScript 變數會被縮限在函式的執行環境中,因此這個 count 的值只有在 dogHouse 裡面才能被取用,在 dogHouse 函式外是取用不到這個值的。
最後因為我們要能夠執行在 dogHouse 中真正核心 countDogs() 這個函式,因此我們會在最後把這個函式給 return 出來,好讓我們可以在外面去呼叫到 dogHouse 裡面的這個 countDogs() 函式:
接著,當我們在使用閉包時,我們先把存在 dogHouse 裡面的 countDogs 拿出來用,並一樣命名為 countDogs(這裡變數名稱可以自己取),因此當我執行全域中的 countDogs 時,實際上會執行的是 dogHouse 裡面的 countDogs 函式:
上面的例子就是一個很基本的閉包的寫法,一個 function 裡面包了另一個 function,同時會 return 裡面的 function 讓我們可以在外面使用到它。
我們可以把我們最一開始的程式碼都改成使用閉包的寫法:
function dogHouse () {
  var count = 0
  function countDogs () {
    count += 1
    console.log(count + ' dogs')
  }
  return countDogs
}

function catHouse () {
  var count = 0
  function countCats () {
    count += 1
    console.log(count + ' cats')
  }
  return countCats
}

const countDogs = dogHouse()
const countCats = catHouse()

countDogs()    // "1 dogs"
countDogs()    // "2 dogs"
countDogs()    // "3 dogs"

countCats()    // "1 cats"
countCats()    // "2 cats"

countDogs()    // "4 dogs"
當我們正確的使用閉包時,雖然一樣都是使用 count 來計數,但是是在不同執行環境內的 count 因此也不會相互干擾。

進一步瞭解和使用閉包

另外,甚至在運用的是同一個 dogHouse 時,變數間也都是獨立的執行環境不會干擾,例如:
function dogHouse () {
  var count = 0
  function countDogs () {
    count += 1
    console.log(count + ' dogs')
  }
  return countDogs
}

// 雖然都是使用 dogHouse ,但是各是不同的執行環境
// 因此彼此的變數不會互相干擾

var countGolden = dogHouse()
var countPug = dogHouse()
var countPuppy = dogHouse()

countGolden()     // 1 dogs
countGolden()     // 2 dogs

countPug()        // 1 dogs
countPuppy()      // 1 dogs

countGolden()     // 3 dogs
countPug()        // 2 dogs

將參數代入閉包中

但是這麼做你可能覺得不夠清楚,因為都是叫做 dogs,這時候我們一樣可以把外面的變數透過函式的參數代入閉包中,像是下面這樣,回傳的結果就清楚多了:
// 透過函式的參數將值代入閉包中
function dogHouse (name) {
  var count = 0
  function countDogs () {
    count += 1
    console.log(count + ' ' + name)
  }
  return countDogs
}

// 同樣是使用 dogHouse 但是使用不同的參數
var countGolden = dogHouse('Golden')
var countPug = dogHouse('Pug')
var countPuppy = dogHouse('Puppy')

// 結果更清楚了
countGolden()     // 1 Golden
countGolden()     // 2 Golden

countPug()        // 1 Pug
countPuppy()      // 1 Puppy

countGolden()     // 3 Golden
countPug()        // 2 Pug

進一步簡化程式

直接 return function

接著,如果我們熟悉在閉包中會 return 一個 function 出來,我們就可以不必為裡面的函式命名,而是用匿名函式的方式直接把它回傳出來。
因此寫法可以簡化成這樣:
function dogHouse () {
  var count = 0
  // 把原本 countDogs 函式改成匿名函式直接放進來
  return function () {
    count += 1
    console.log(count + ' dogs')
  }
}

function catHouse () {
  var count = 0
  // 把原本 countCats 函式改成匿名函式直接放進來
  return function () {
    count += 1
    console.log(count + ' cats')
  }
}
然後我們剛剛有提到,可以透過函式參數的方式把值代入閉包當中,因此實際上我們只需要一個 counter ,在不同的時間點給它參數區分就好。這樣子不管你是要記錄哪一種動物都很方便,而且程式碼也相當簡潔:
function createCounter (name) {
  var count = 0
  return function () {
    count++
    console.log(count + ' ' + name)
  }
}

const dogCounter = createCounter('dog')
const catCounter = createCounter('cat')
const birdCounter = createCounter('bird')

dogCounter()     // 1 dog
dogCounter()     // 2 dog
catCounter()     // 1 cat
catCounter()     // 2 cat
birdCounter()    // 1 brid
dogCounter()     // 3 dog
catCounter()     // 3 cat

閉包的實際應用

在 HTML 中我們先建立三個按鍵
<button id="first">First</button>
<button id="second">Second</button>
<button id="third">Third</button>
接著我們希望能夠讓這三個按鈕被點擊的時候可以回傳按鈕的文字到 console 上,因此直覺上我們可能會這樣寫:
var buttons = document.getElementsByTagName('button')

for (var i = 0; i < buttons.length; i ++) {
  // buttonName 暴露於 global environment
  var buttonName =  buttons[i].innerHTML
   buttons[i].addEventListener('click', function () {
    console.log(buttonName)
  })
}
這時候可能會預期點選不同的按鈕時,會根據每個 button 內容的不同而得到不同的結果。但是實際執行後,你會發現回傳的結果都是 “Third”。
操作範例 @ JSBin
之所以會這樣是因為 JavaScript 是在按鍵被點擊的時候才會執行 addEventListener 裡面的 callback function ,這時候它發現要使用到 buttonName 這個變數,於是它向外層去尋找,這時候它會找到的已經是處於 global 的 buttonName,而值會是 ‘Third’。
往外找的行為並不是 closure 而是 global environment 和 scope chain 的概念。可參考:[筆記] JavaScript中Scope Chain和outer environment的概念
因為 buttonName 這個變數是暴露在 global environment,所以他不會被保存在 function 中。我們若想把這個變數保存在 function 內,我們就可以使用到上面所講述的 closure 的概念:
// 建立一個閉包把資料存在這個 function 當中
function saveButtonName (buttonName){
  // buttonName 被儲存在閉包當中
  var buttonName = buttonName
  return function(){
    console.log(buttonName)
  }
}

var buttons = document.getElementsByTagName('button')
for (var i = 0; i < buttons.length; i ++) {
  var buttonName =  buttons[i].innerHTML
   buttons[i].addEventListener('click', saveButtonName(buttonName))
}
這時候我們就可以把跑迴圈時的 buttonName 的值存在閉包當中,當按鈕被點擊時會執行 saveButtonName 這個 function,而它在這個 function 裡面就可以找到 buttonName 的值,因此他不需要在到外層的 global 去找 buttonName 的值。
於是我們就可以得到正確的值。(如果你好奇現在 global 中 buttonName 的值是多少,可以透過在 global 環境 console 出來看看)。

使用 let

我們剛剛有提到,一開始的程式碼之所以會跑出非預期的結果,是因為我們的 buttonName 這個變數是處在 global environment ,再跑迴圈的過程中,它的值會一直被重新覆蓋。
在 ES6 中提出了新的用來定義變數的關鍵字 let ,簡單來說,透過 let 它可以幫我們把所定義的變數縮限在 block scoped 中,也就是變數的作用域只有在 { } 內,因此要解決上面程式碼的問題,我們也可以透過 let 來避免 buttonName 這個變數跑到 global variable 被重複覆蓋。
寫法如下:
// 使用 ES6 寫法
for (var i = 0; i < buttons.length; i ++) {
  let buttonName =  buttons[i].innerHTML
   buttons[i].addEventListener('click', saveButtonName(buttonName))
}
如此一樣能得到我們想要的結果
若想對 let 有更多的瞭解,可以參考
[筆記] JavaScript ES6 中使用block-scoped 的 let 宣告變數
希望看完這篇文章後,你能對於閉包有更清楚的認識,接著你可以看這四篇關於 closure 的筆記,相信你會對閉包有更深入的瞭解。

參考資料

0 意見:

張貼留言