前言
本文專門介紹閉包,但事實上,閉包的難點並不在概念,而是在詞法環境的嵌套上。只要將詞法環境的嵌套關係整理清楚,閉包就瞬間被克服了。
總之,先不廢話了,正文開始。
閉包
如果一個函數在定義的詞法環境外運行並記住了定義時的詞法環境,這樣的現象就可以稱作函數閉包(Function Closure)
舉個簡單例子:
function f(x=0){
var count=x;
function getCount(){
return count++;
}
return getCount
}
var func = f(10);
console.log(func()); // 10
console.log(func()); // 11
console.log(func()); // 12
console.log(func()); // 13
console.log(func()); // 14
首先要明白上面代碼究竟發生了什麼,梳理一下過程:
函數f
返回了函數getCount
的引用, 並將局部變量count設爲了10
。- 外部詞法環境中,
func
指向了getCount
然後在執行func
時就很神奇的進行了疊加。
爲了直觀表示,我就是用圖示法表示環境綁定了(也可以用執行上下文僞代碼):
注意: func
和getCount
沒有在同一個詞法環境。
那麼執行過程就是:
func
引用自getCount
所指向的函數。- 調用
func
會進入getCount
的詞法環境 - 解析標識符
count
,在當前詞法環境中未找到,進入外部詞法環境; - 在外部詞法環境找到,返回值後疊加。 --> 保存詞法環境狀態
- 再次調用
func
時,會再次訪問外部詞法環境,訪問count
,此時count
爲11,然後返回。 - ……
可以注意到:形成函數的閉包的關鍵在於:它會保存外部詞法環境的狀態。
但是爲什麼會這樣?
閉包實現 I: 執行上下文也會創建外部環境
- 函數調用時,會爲該函數創建一個執行上下文。
- 執行上下文中會創建當前詞法環境的環境記錄,記作
CurrentEnvRec
- 除了會創建
CurrentEnvRec
,還會創建外部詞法環境的環境記錄, 記作OuterEnvRec
- 如果
OuterEnvRec
還有外部詞法環境,那麼繼續創建OuterEnvRec的外部詞法環境的環境記錄 …… - 上面的過程一直延伸到全局詞法環境截止,上面的詞法環境形成了一個鏈表,就是衆所周知的作用域鏈(Scope Chain)了。
在環境記錄中提到過,環境記錄用於記錄當前詞法環境標識符狀態的對象;只要環境記錄中的變量存在,那麼就可以訪問。
這個作用域鏈會保存在函數的[[Scope]]
內部屬性中,
可以在調試中可以看到:
注意:
Chrome
中,Google瀏覽器專門對console.log
做了優化,因此可以通過func.prototype
看到這個屬性值。
閉包實現 II: [[Scope]]不會被刪除。
Javascript另外一個特別的地方是:一切皆對象。在Javascript中採用的是標記清除算法釋放內存的;簡單來說,如果對象可以被訪問到,那麼就會一直帶有一個標記,當對象再也無法被訪問到時,那就去除標記,在下一個遍歷週期中被釋放。
- 因爲函數也是一個對象,並且保存了
[[Scope]]
屬性的值。 - 因爲
[[Scope]]
一直在引用環境記錄。 - 所以
[[Scope]]
中的環境記錄會一直被保存。 - 所以函數總能夠訪問到外部詞法環境的值並且能夠一直更新。
- 所以即便是執行上下文被銷燬,函數的詞法環境也沒有消失。再次創建該函數的執行上下文時,也只是重新指向函數的詞法環境而已。
閉包陷阱
一般情況下,不會用上面那種麻煩的形式,而是直接會返回一個匿名函數:
function f(x=0){
var count=x;
return function(){
return count++;
}
}
或是更加精簡:
let f= (x=0)=>{
var count=x;
return ()=>count++
}
偶爾,會遇到需要多個函數閉包的情形,即:
let f = ()=>{
let func = []
for(var i = 0; i < 3; i++){
func.push(()=>{
return i*i;
})
}
return func
}
這樣,f1應該輸出0
, f2輸出1
,f3輸出4
……
本應該這樣纔對。
但是結果卻是:
let [f1,f2,f3]=f();
console.log(f1()); // 9
console.log(f2()); // 9
console.log(f3()); // 9
這TMD是又爲何?
道理很簡單,因爲使用了var聲明的變量。var聲明的變量是沒有塊級作用域的,上面的代碼在邏輯上,等價於:
let f = ()=>{
let func = []
var i
for(i = 0; i < 3; i++){
func.push(()=>{
return i*i;
})
}
return func /// (*)
}
所以整理一下就是:
- 因爲
func[0] / func[1] / func[2]
三個閉包函數的外部詞法環境都是循環體的詞法環境和函數f的詞法環境: - 在查詢標識符i的過程中, 在循環體的詞法環境中未找到標識符i,所以將使用函數f的詞法環境中的標識符i。
- 調用
f1 / f2 / f3
時,循環體已經結束, 所以標識符 i
最終值爲3
。所以最終皆返回9
我們知道,導致最終結果都相同的原因是f1/f2/f3
在解析標識符i的過程中使用的都是函數詞法環境的標識符i。
一圖勝千言:
所以:
可以在函數外部創建一個立即執行函數表達式,我們可以直接使用這個函數作用域中的標識符x。
let f = ()=>{
let func = []
var i
for(i = 0; i < 3; i++){
func.push((function(){
var x=i;
return ()=>x*x;
}()))
}
return func
}
詞法環境嵌套圖:
或是直接用循環體的作用域:
let f = ()=>{
let func = []
var i
for(i = 0; i < 3; i++){
let x = i;
func.push(()=>{
return x*x;
})
}
return func
}
詞法環境嵌套圖……
(自己嘗試下)
這樣每次都會聲明一個x並保留標識符i的值,這時正確輸出:
let [f1,f2,f3]=f();
console.log(f1()); // 0
console.log(f2()); // 1
console.log(f3()); // 4
知道了前因後果,可以再對上面的代碼加以簡化。
let
聲明的計數變量每次都會重新聲明,並以上一次循環結束後的值作爲初始化計數變量。(詳見規範CreatePerIterationEnvironment條目)即:
let f = ()=>{
let func = []
for(let i = 0; i < 3; i++){
func.push(()=>{
return i*i;
})
}
return func
}
okay.
這時詞法環境嵌套圖是這樣子的:
補充: setTimeout
我記得有個經典面試題,是這樣的:
for(var i =0;i<6;i++)
setTimeout(()=>{console.log(i)})
// 輸出 666666
我覺得這個,理解了上面的示例,這個應該不成問題;
但是要注意,裏面有個坑存在,那就是這樣魔改一下:
//
for(var i =0;i<6;i++)
setTimeout(console.log, 0, i)
// 0-5
它是正常輸出的, 因爲setTimeout
接收了當前i的值
作爲參數後,會在函數內部將i
參數傳遞給console.log
。:
即:
setTimeout(F, 0, x)
// 在其setTimeout內部實現上,會有:
function setTimeout(F, delay, ...x){
// ....其他代碼
F.apply(thisObj, x)
// ...
}
window.setTimeout=setTimeout;
備註:
setTimeout
的delay
,其實是最小延遲時間, 但是HTML標準規定,但凡是小於4ms皆以4ms計算。
最後
下一篇,開始異步。
要牢記一句話: 進階要有深度,學習不要總被套路