Day 25 最後留了一個計數器。先不急著背 Closure 的定義,把程式接著跑下去。
function createCounter() {
let count = 0
return function increment() {
count += 1
return count
}
}
const counter = createCounter()
console.log(counter())
console.log(counter())
結果是:
1
2
等一下。createCounter() 在建立 counter 時,不就已經執行完了嗎?
count 又不是全域變數。為什麼第一次呼叫記得從 0 開始,第二次還知道上次已經加到 1?
今天只追這個問題:外層函式結束了,內層函式為什麼還能使用它的變數?
const counter = createCounter()
這一行呼叫的是 createCounter。它建立 count,再回傳裡面的 increment 函式。
回傳的是「函式本身」,不是先執行 increment()、再把結果交出來。函式可以當成值交給其他變數,這正好接回 Day 22。
所以流程是:
createCounter()
↓
建立這次呼叫的 count = 0
↓
建立 increment 函式
↓
把 increment 交給外面的 counter
之後寫:
counter()
才是在執行剛才拿到的內層函式。
它沒有重新呼叫一次 createCounter(),所以也沒有再執行一次 let count = 0。
先把這兩次呼叫分開,計數器就沒那麼神祕了。
Day 24 的 Scope Chain 說的是:函式裡找不到的名稱,會沿著它建立時的外層環境繼續找。
increment 裡沒有自己的 count,但它是在這次 createCounter() 的環境裡建立的。因此,它能存取那個 count。
重點是:把函式交到外面,並不會切斷這個連結。
counter
↓ 指向
increment 函式
↓ 仍能存取
建立它時的外層環境
↓
count
這個「函式,加上它能存取的詞法環境」,就是 Closure,中文常叫閉包。
比較有用的問題是:
這個函式建立在哪裡?它現在用到的變數,屬於哪一次呼叫?
createCounter() 確實已經執行完,不是偷偷在背景繼續跑。
但回傳的函式還能到達它需要的環境;不能只因為外層函式返回,就把之後仍然需要的資料當成無用資料丟掉。這和垃圾回收所關心的「還能不能被存取」有關。
這也不代表整個函式、整段堆疊都必須原封不動留著。引擎可以最佳化;理解行為時,只要抓住仍可存取的變數就好。
現在再看兩次呼叫:
第一次 counter()
同一個 count:0 → 1
第二次 counter()
同一個 count:1 → 2
所以第二次得到 2,不是 JavaScript 記得上一個回傳值,而是內層函式又修改了同一個變數。
如果聽到「記住外面的變數」,很容易想成:函式建立時,把當時的值複製一份。
換一個最小例子試試:
function createReader() {
let message = 'before'
const read = () => message
message = 'after'
return read
}
const read = createReader()
console.log(read())
結果是 after,不是 before。
read 存取的是那個 message 變數,不是建立函式時拍下來的字串照片。對可重新賦值的變數來說,之後讀到的是該變數當下的值。
這也能解釋前面的 count 為什麼可以一直增加,而不是每次都拿著一份固定的 0。
沿用前面的 createCounter,再看:
const counterA = createCounter()
const counterB = createCounter()
console.log(counterA())
console.log(counterA())
console.log(counterB())
console.log(counterA())
結果是:
1
2
1
3
函式程式碼看起來一樣,不代表使用同一份變數。這裡呼叫了兩次 createCounter(),各自建立新的環境。
第一次 createCounter() 第二次 createCounter()
↓ ↓
count A = 0 count B = 0
↑ ↑
counterA counterB
所以 A 加到 2,不會順便把 B 加到 2。
在前面例子的外層另外執行:
console.log(count)
會得到 ReferenceError,因為那裡沒有可用的 count 綁定。
Closure 沒有把區域變數變成全域變數。外面拿到的是能使用它的函式,不是直接取得那個名稱。
也不是一定要寫 return function 才會產生 Closure;事件處理函式或其他 callback 也可能使用外層環境。這次用回傳函式,只是比較容易看出外層呼叫已經結束。
function counterThatResets() {
let count = 0
count += 1
return count
}
console.log(counterThatResets())
console.log(counterThatResets())
結果變成:
1
1
因為每次呼叫都會重新建立自己的 count,並執行 let count = 0。這次回傳的是數字,不是保留外層變數存取能力的計數函式。
今天真正要分清楚的不是「裡面有沒有 function」,而是:每次呼叫時,用到的是新建立的變數,還是先前那一份?
仍然沿用 createCounter:
const first = createCounter()
const second = first
const third = createCounter()
console.log(first())
console.log(second())
console.log(third())
先猜三行結果,再說說:second = first 有沒有呼叫 createCounter()?
1
2
1
first 和 second 指向同一個函式,因此操作同一份 count。只有 third 是另外呼叫 createCounter() 建立的新計數器。
這不是變數名稱不同,就自動得到一份新資料。
Closure 讓函式持續存取建立它時的外層變數;外層函式返回,不代表這份存取關係就消失。
讀程式時,先找出「哪個函式建立在哪裡」,再問「這個變數屬於哪一次呼叫」。比只背「函式包住函式」更能解釋結果。