談談 JS 中的 Closure(閉包)

好像很久沒有上來寫文章了。前一陣子花了比較多的時間在忙著做國外網站的挑戰,也參加了一場競賽活動,所以部落格暫時停擺了幾個月。

最近有了新的規劃,主要是想提升關於 JS 的相關知識,也想要把以前留下的坑給填完。我發現我以前的文章多數是以 CSS 為主,但前端除了 HTML、CSS 之外,JavaScript 也是很重要的核心。所以…是時候來寫寫關於 JS 的文章了(話說我好像還沒有在這裡發過 JS 的文章)。

廢話不多說,馬上開始吧!作為 JS 的第一篇文章,想跟大家談的是「閉包(Closure)」。

作用域(Scope)

在了解閉包之前,要先知道作用域是什麼東西?

作用域指的是:一個變數的生存範圍。

我們直接看一個例子:

1
2
3
4
function test() {
var a = 10
}
console.log(a) // Uncaught ReferenceError: a is not defined

以這個例子來說,a的作用域是 test 這個 function 中的範圍。一旦超出作用域,你就沒辦法存取到變數的值。

在 ES6 的 letconst 出現之前,只有 function 能夠產生出作用域。而 letconst 推出後,多了一個 block 的作用域。

全域作用域與區域作用域

作用域還可以分為「全域」跟「區域」。剛剛用 function 製造出的作用域就是屬於區域的作用域。那什麼是全域作用域?

全域作用域指的是直接寫在最外層的變數。一個在全域作用域下的變數,你不管在哪裡都可以存取得到這個變數:

1
2
3
4
5
var globalScope = 10
function test() {
console.log(globalScope) // 10
}
test() // 10

所以在前面的例子中你會發現,一個 function 可以去存取外面的變數,但外面卻沒有辦法去存取一個 function 中的變數。

如果用生活化一點的例子來說的話,一個處在全域作用域中的變數就好像是國際巨星,不管是哪個地方,大家都認識他。反之,一個處在 function 中的變數就好像是在地的偶像,也許當地的人都知道他是誰,但是一旦出了這個區域(function),別人就不見得知道他是誰。

一個地區裡面還可以在包含另一個地區,就像台灣這個地區裡面可以有高雄市,而高雄市這個地區裡面還可以有三民區,套用到 function 上也是同樣的概念。而住在台灣的人也許都知道台灣的總統是誰,但不一定曉得高雄市的市長是誰,因為那超出了高雄市這個(function)的範圍。

如果把上面的概念轉換成程式碼大概會像這樣子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function taiwan() {
var presidentOfTaiwan = '台灣總統'
function kaohsiung() {
var mayorOfKaohsiung = '高雄市長'
function sanminDistrict() {
var mayorOfSanminDistrict = '三民區區長'
console.log(president) // '台灣總統'
}
sanminDistrict()
console.log(mayorOfSanminDistrict) // Uncaught ReferenceError: a is not defined
}
kaohsiung()
}
taiwan()

所以,在一個 function 裡面我們可以去存取在外面的變數,但是在外面沒有辦法存取到 function 內的變數。

1
2
3
4
5
6
7
function outter() {
var a = 100
function inner() {
console.log(a) // 100
}
inner()
}

inner 這個 function 來說,a 並不是它自己的變數,而這種不在自己作用域中的變數稱為「自由變數(free variable)」

作用域鏈(Scope chain)

一樣看到剛剛的例子:

1
2
3
4
5
6
7
function outter() {
var a = 100
function inner() {
console.log(a) // 100
}
inner()
}

inner 來說,a 是一個自由變數(因為不在自己的作用域中)。此時當 inner 要印出這個變數時,它就會到外層去找有沒有這個變數存在:一開始先在 inner 的作用域中尋找 -> 接著再到 outter 的作用域尋找 -> 接著再到 global 的作用域尋找,最後如果都找不到的話就會拋出錯誤。

不斷往上找的這個動作,就是一個「作用域鏈」。

靜態作用域與動態作用域

作用域其實還可以分成「靜態」與「動態」,來看一個例子:

1
2
3
4
5
6
7
8
9
var a = 100
function echo() {
console.log(a) // 100 or 200?
}
function test() {
var a = 200
echo()
}
test()

應該很混亂吧?但正確答案是 100。不過這是以 JS 為前提之下的結果,如果你使用其他的程式語言來跑的話,答案有可能會變成 200。

為什麼會有這樣的差異?這跟程式語言如何決定「作用域」這件事有關(或換句話說,如何決定自由變數的值)

在 JS 中的作用域被定義為是靜態的。也就是說,一個作用域在 function 被「宣告」的當下就已經決定了。不管你之後在哪裡「呼叫」這個 function,它的作用域都不會改變。這也是為什麼會稱之為「靜態作用域」,因為它不會變來變去的。

反之,如果一個程式語言是採用動態作用域,那麼剛剛的範例會輸出的結果就會是 200。也就是說,作用域是在 function 被「呼叫」的時候才被動態決定的。你可以想想看 JS 中最難搞的 this ,兩者的觀念很類似。JS 中的 this 值就是在程式執行的時候才被決定的,所以你很容易搞混它的值到底是什麼,因為它會變來變去的。

事實上,靜態作用域的更正式的稱呼是「lexical scope」,可以翻作語彙範疇,或是詞法作用域。

要理解什麼是 lexical scope ,你得去理解程式碼在編譯過程的步驟。但總而言之,目前你只要知道 lexical scope 指的就是在編譯的時候就已經決定好作用域是什麼,這樣子就夠了。

閉包

理解完作用域後,就可以來談談什麼是閉包了。

先讓我們來看個範例吧:

1
2
3
4
5
6
7
8
function test() {
var a = 10
function inner() {
console.log(a) // 10
}
inner()
}
test()

嗯,相信你應該理解這段程式碼的意思。這裡宣告了一個 test function ,然後裡面又宣告了一個 inner function ,並且 inner 會印出 a 的值,最後 test 會執行 inner 這個 function。

但是現在我們如果不要執行 inner 這個 function,而是回傳呢?

1
2
3
4
5
6
7
8
9
function test() {
var a = 10
function inner() {
console.log(a)
}
return inner
}
var inner = test()
inner()

很神奇的是,最後還是會得到 10。

為什麼神奇?因為通常一個 function 在執行完成後,會做一個「資源釋放」的動作。也就是說在 test 在執行結束後,用來儲存變數 a 的值記憶體空間也要被釋放出來。但是,我們在呼叫 inner 的時候,居然還是能存取到 a

a 被「關在」 inner 裡面的這個行為,就叫做「閉包(Closure)」。我也有看過另外一種解釋是「讓 function 記住當時作用域下的變數值,即便 function 已經離開了那個作用域」。以 inner 當時的環境來思考的話,按照作用域鏈,會在 test 裡面找到 a 的值。

那麼,閉包又是怎麼引發的?其實會導致閉包發生的原因是我在 function 裡面回傳了一個 function,因為這個動作,所以才會得出明明結束完畢就應該消失的東西卻還被關注的這種現象。

實際應用

在理解閉包是什麼後,你應該會好奇閉包可以做什麼?其中最常被拿來應用的情況,是把變數隱藏在裡面,讓外面沒辦法存取到這個變數。

舉例來說,假設有一個變數用來代表 PeaNu 的存款金額,有一個 function 是用來提款,但這裡設置了一個上限,那就是最多只能領 1000:

1
2
3
4
5
6
var PeaNusMoney = 10000
function getMoney(n) {
PeaNusMoney -= n > 1000 ? 1000 : n // 最多只能領 1000
}
getMoney(2000) // 只被扣 1000
console.log(PeaNusMoney) // 9000

你應該也發現到了,即便我們不透過 function,也可以直接去修改變數的值:

1
2
3
4
5
6
var PeaNusMoney = 10000
function getMoney(n) {
PeaNusMoney -= n > 1000 ? 1000 : n // 最多只能領 1000
}
PeaNusMoney -= 2000 // 直接修改變數值
console.log(PeaNusMoney) // 8000

儘管我們寫了 getMoney 這個 function 來操作,但變數還是暴露在外面,任何人都可以修改這個變數。但這個時候如果改用閉包的話,世界就不一樣了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function PeaNusBank() {
var money = 10000
// 用來提款
function get(n) {
money -= n > 1000 ? 1000 : n // 最多只能領 1000
}
// 用來顯示餘額
function show() {
console.log(money)
}
// 把 function 包在一個物件裡回傳
return {
showMoney: show,
getMoney: get
}
}

var myBank = PeaNusBank()
myBank.getMoney(2000) // 只會被扣 1000
myBank.showMoney() // 9000

money -= 2000 // Uncaught ReferenceError: money is not defined

現在我們把變數隱藏在 PeaNusBank 裡面,如果你想要修改變數的值,就只能透過我暴露出去的 get 函式來修改。

既然都提到閉包了,那不得不提一個很經典的例子:

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

在不知道閉包是什麼以前,你可能會以為上面的程式碼會在 1 秒後印出 0, 1, 2, 3, 4, 5。不過正確的答案是六個 6

你原本以為是這樣子:

1
2
3
4
5
6
7
8
setTimeout(function () {
console.log(0)
}, 1000)
setTimeout(function () {
console.log(1)
}, 1000)

...

但實際上是這樣子:

1
2
3
4
5
6
7
8
setTimeout(function () {
console.log(i)
}, 1000)
setTimeout(function () {
console.log(i)
}, 1000)

...

其實仔細想想的話,會發現下面這樣比較合理,因為我們只有讓 setTimeout 裡的 function 在一秒後去印出 i 的值,而不是直接執行這個 function。

所以在一秒後,i 會是什麼?在一秒過後,迴圈早就已經跑完了,所以 i 會變成 6(在 i=5 跑完後,i++變成 6,最後不符合迴圈中的條件,跳出迴圈),所以一秒後就會蹦出六個 6

那要怎麼做才會變成我們要的結果呢?加上 function!

1
2
3
4
5
6
7
8
function showNum(n) {
return function () {
console.log(n)
}
}
for (var i = 0; i <= 5; i++) {
setTimeout(showNum(i), 1000)
}

在迴圈中,我們每一圈會建立一個 setTimeout,這個 setTimeout 在一秒後會執行 showNum(i) 回傳的 function。

如果你有點混亂的話,仔細回想一下閉包的特性:記住 function 在當時環境的變數值。

原本的 showNum(i) 在執行結束後,i 就會被釋放。但是現在我們在裡面回傳了一個新的 function,這個動作就建立了一個閉包,所以 i 會被「關在」這個新的 function 裡面。最後當 setTimeout 在一秒過後回來執行這個 function 時,會印出當時 showNum(i) 傳進去的值,也就是最後你看到的結果。

這個例子也可以改寫成這樣:

1
2
3
4
5
6
7
8
for (var i = 0; i <= 5; i++) {
// IIFE
;(function (number) {
setTimeout(function () {
console.log(number)
}, 1000)
})(i)
}

其實就是這個的簡化版:

1
2
3
4
5
6
7
8
for (var i = 0; i <= 5; i++) {
printNumber(i)
}
function printNumber (number) {
setTimeout(function() {
console.log(number)
}, 1000)
}

跟第一個方法的概念是一樣的,都是藉由閉包來把 i 關在 function 裡面。(也可以換個說法,每次迴圈都會執行一個新的 function,也就建立了一個新的作用域)

最後做一個補充,在 ES6 之後有了 block scope 的作用域。以剛剛的例子說,如果你覺得用 function 來產生出一個閉包很麻煩的話,只要把迴圈裡面的 var 改成 let 就行了:

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

let 會製造出 block scope,也就是說每一次迴圈都會產生出一個新的作用域,像這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ // 塊級作用域
let i = 0
setTimeout(function() {
console.log(i) // 0
})
}
{ // 塊級作用域
let i = 1
setTimeout(function() {
console.log(i) // 1
})
}

...

block scopefunction scope 的差異是:前者是以 { } 來劃分作用域;後者是以 function 來劃分:

1
2
3
4
5
6
7
8
9
10
11
{
let i = 0
function test() {
return function () {
console.log(i)
}
}
}
const a = test() // 被關在 function 裡的 i
a() // 0
console.log(i) // Uncaught ReferenceError: i is not defined

如果是 block scope,當離開了 { } 時,就會進行釋放。所以你在外面存取不到 i(沒有閉包的情況)。但如果把 let 改成 var,情況就又不一樣了,var 只能透過 function 來產生出作用域,所以:

1
2
3
4
5
6
7
8
9
10
11
{
var i = 0
function test() {
return function () {
console.log(i)
}
}
}
const a = test() // 被關在 function 裡的 i
a() // 0
console.log(i) // 0

這時候的 i 就等於是全域作用域下的變數,所以即便不使用閉包,也能存取到i

以上就是關於閉包的基本觀念,其實閉包可以討論的東西還有很多,但我理解的還不夠深,所以暫時在這裡打住。以後等我了解更多的時候,在回來用更清楚的方式來做解釋。

如果你想理解的更深入的話,可以看這篇文章

給自己做的筆記

在半夜睡覺時,我思考了一些更多能夠產生閉包的寫法,所以想記錄下來做個筆記:

1
2
3
4
5
6
7
function myFamily() {
var myBrother = 'PeaNu'
setTimeout(function () {
console.log(myBrother)
}, 1000)
}
myFamily() // 一秒後印出 'PeaNu'

我在 myFamily 裡宣告了一個 myBrother,並且會執行 setTimeout,在一秒後印出 'PeaNu'。但實際上 setTimeout 中的 function 在執行時,已經脫離了 myFamily 所產生的作用域,所以照理來說應該會跳出錯誤訊息,不過實際卻可以得到正確的值:'PeaNu'

我想這也是為什麼會需要閉包的一種原因吧?如果沒有閉包,讓 function 能夠有記住當時作用域下的變數能力,事情可能就會變成這樣:

1
2
3
4
5
6
7
8
我 : 嘿,setTimeout,幫我在一秒後叫 function 印出我哥的名字(myBrother)好嗎?
setTimeout : 好哦,沒問題!
( setTimeout 告訴 function 在一秒後印出 myBrother )

---- 一秒過後 ----

function:不好意思,myBrother 哪位?是香蕉掉了就會瞧 oo娘 那位嗎?
我:...

還有另外一種情境是這樣,我們先看一段程式碼:

1
2
3
4
5
6
7
8
var prettiestGirl = '許純美'
function sayWhoIsPrettiestGirl() {
var prettiestGirl = '林志玲'
setTimeout(function () {
console.log(prettiestGirl)
}, 1000)
}
sayWhoIsPrettiestGirl()

現在假設林志玲是白雪公主裡面的巫婆好了,他每天都會問魔鏡誰是最漂亮的女人。

當時的魔鏡是用高科技寫的一個智慧型 AI 魔鏡(我知道這設定很爛,但你就接受吧。),只要問它問題,他一秒後就會告訴你答案。但現在有個駭客故意在魔鏡的程式裡面加料,希望讓魔鏡回答出 '許純美',所以:

1
2
3
4
5
6
7
8
林志玲 : 魔鏡呀~誰是全台灣最美的女人呀~
魔鏡:嘿,setTimeout,幫我在一秒後叫 function 印出最美麗的的女人的名字(prettiestGirl)好嗎?
setTimeout : 好哦,沒問題!

---- 一秒過後 ----

function:是許純美 だぜ(Da-Ze)★! ( function 在全域作用域下找到 prettiestGirl )
林志玲:...

先別急著幫魔鏡上香,幸好這個駭客並不知道有閉包的這樣的特性存在,所以其實魔鏡還是會回答 林志玲。最後一天又平安的過去了,感謝飛天小女警的努力,可喜可賀,可喜可賀。

結語

好久沒有寫文章了,覺得自己寫文章的功力還很遜,很多地方總會照著別人的想法來思考,沒有用自己的方式來作詮釋 QQ。雖然也不是說這樣子就不好,畢竟在寫這篇文章時確實有讓自己更理解這些相關知識。但是我覺得,人在學習一件事的時候,有自己的想法也是很重要的一件事情吧?就像那些文章寫得很好的人,也都是用自己的想法來詮釋一個概念,這個是我也很想學習的一件事情。

雖然現在的我還沒有找到能夠做到這件事的方法,但我會持續練習下去,期望自己有一天也能做到。

最後是題外話,好不容易決定回來經營部落格了,希望自己可以養成寫文章的習慣,讓自己的文章越來越多,還有一步一步提升自己的寫作能力,加油!

參考資料

所有的函式都是閉包:談 JS 中的作用域與 Closure
重新認識 JavaScript: Day 19 閉包 Closure

Session 與 Cookie 是什麼? Vue的學習紀錄
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×