閉包
什么是閉包
閉包,是 JavaScript 中一個非常重要的知識點,也是我們前端面試中較高幾率被問到的知識點之一。
打開《JavaScript 高級程序設計》和《 JavaScript 權威指南》,會發現里面針對閉包的解釋各執一詞,在網絡上搜索關于閉包的內容,也發現眾說紛紜,這就導致了這個知識點本身顯得有點神秘,甚至還有一點玄幻。
那么這個知識點真的有那么深奧么?
非也!其實要理解 JavaScript 中的閉包,非常容易,但是在此之前你需要先知道以下兩個知識點:
- JavaScript 中的作用域和作用域鏈
- JavaScript 中的垃圾回收
這里我們來簡單回顧一下這兩個知識點:
1. JavaScript 中的作用域和作用域鏈
- 作用域就是一個獨立的地盤,讓變量不會外泄、暴露出去,不同作用域下同名變量不會有沖突。
- 作用域在定義時就確定,并且不會改變。
- 如果在當前作用域中沒有查到值,就會向上級作用域去查,直到查到全局作用域,這么一個查找過程形成的鏈條就叫做作用域鏈。
2. JavaScript 中的垃圾回收
- Javascript 執行環境會負責管理代碼執行過程中使用的內存,其中就涉及到一個垃圾回收機制
- 垃圾收集器會定期(周期性)找出那些不再繼續使用的變量,只要該變量不再使用了,就會被垃圾收集器回收,然后釋放其內存。如果該變量還在使用,那么就不會被回收。
OK,有了這 2 個知識點的鋪墊后,接下來我們再來看什么是閉包。
閉包不是一個具體的技術,而是一種現象,是指在定義函數時,周圍環境中的信息可以在函數中使用。換句話說,執行函數時,只要在函數中使用了外部的數據,就創建了閉包。
而作用域鏈,正是實現閉包的手段。
什么?只要在函數中使用了外部的數據,就創建了閉包?
真的是這樣么?下面我們可以證明一下:

在上面的代碼中,我們在函數 a 中定義了一個變量 i,然后打印這個 i 變量。對于 a 這個函數來講,自己的函數作用域中存在 i 這個變量,所以我們在調試時可以看到 Local 中存在變量 i。
下面我們將上面的代碼稍作修改,如下圖:

在上面的代碼中,我們將聲明 i 這個變量的動作放到了 a 函數外面,也就是說 a 函數在自己的作用域已經找不到這個 i 變量了,它會怎么辦?
學習了作用域鏈的你肯定知道,它會順著作用域鏈一層一層往外找。然而上面在介紹閉包時說過,如果出現了這種情況,也就是函數使用了外部的數據的情況,就會創建閉包。
仔細觀察調試區域,我們會發現此時的 i 就放在 Closure 里面的,從而證實了我們前面的說法。
那么是一個函數下所有的變量聲明都會被放入到閉包這個封閉的空間里面么?
倒也不是,放不放入到閉包中,要看其他地方有沒有對這個變量進行引用,例如:
在上面的代碼中,函數 c 中一個變量都沒有創建,卻要打印 i、j、k 和 x,這些變量分別存在于 a、b 函數以及全局作用域中,因此創建了 3 個閉包,全局閉包里面存儲了 i 的值,閉包 a 中存儲了變量 j 和 k 的值,閉包 b 中存儲了變量 x 的值。
但是你仔細觀察,你就會發現函數 b 中的 y 變量并沒有被放在閉包中,所以要不要放入閉包取決于該變量有沒有被引用。
當然,此時的你可能會有這樣的一個新問題,那么多閉包,那豈不是占用內存空間么?
實際上,如果是自動形成的閉包,是會被銷毀掉的。例如:
在上面的代碼中,我們在第 16 行嘗試打印輸出變量 k,顯然這個時候是會報錯的,在第 16 行打一個斷點調試就可以清楚的看到,此時已經沒有任何閉包存在,垃圾回收器會自動回收沒有引用的變量,不會有任何內存占用的情況。
當然,這里我指的是自動產生閉包的情況,關于閉包,有時我們需要根據需求手動的來制造一個閉包。
來看下面的例子:
function eat(){var food = "雞翅";console.log(food);
}
eat(); // 雞翅
console.log(food); // 報錯
在上面的例子中,我們聲明了一個名為 eat 的函數,并對它進行調用。
JavaScript 引擎會創建一個 eat 函數的執行上下文,其中聲明 food 變量并賦值。
當該方法執行完后,上下文被銷毀,food 變量也會跟著消失。這是因為 food 變量屬于 eat 函數的局部變量,它作用于 eat 函數中,會隨著 eat 的執行上下文創建而創建,銷毀而銷毀。所以當我們再次打印 food 變量時,就會報錯,告訴我們該變量不存在。
但是我們將此代碼稍作修改:
function eat(){var food = '雞翅';return function(){console.log(food);}
}
var look = eat();
look(); // 雞翅
look(); // 雞翅
在這個例子中,eat 函數返回一個函數,并在這個內部函數中訪問 food 這個局部變量。調用 eat 函數并將結果賦給 look 變量,這個 look 指向了 eat 函數中的內部函數,然后調用它,最終輸出 food 的值。
為什么能訪問到 food,原因很簡單,上面我們說過,垃圾回收器只會回收沒有被引用到的變量,但是一旦一個變量還被引用著的,垃圾回收器就不會回收此變量。在上面的示例中,照理說 eat 調用完畢 food 就應該被銷毀掉,但是我們向外部返回了 eat 內部的匿名函數,而這個匿名函數有引用了 food,所以垃圾回收器是不會對其進行回收的,這也是為什么在外面調用這個匿名函數時,仍然能夠打印出 food 變量的值。
至此,閉包的一個優點或者特點也就體現出來了,那就是:
- 通過閉包可以讓外部環境訪問到函數內部的局部變量。
- 通過閉包可以讓局部變量持續保存下來,不隨著它的上下文環境一起銷毀。
通過此特性,我們可以解決一個全局變量污染的問題。早期在 JavaScript 還無法進行模塊化的時候,在多人協作時,如果定義過多的全局變量 有可能造成全局變量命名沖突,使用閉包來解決功能對變量的調用將變量寫到一個獨立的空間里面,從而能夠一定程度上解決全局變量污染的問題。
例如:
var name = "GlobalName";
// 全局變量
var init = (function () {var name = "initName";function callName() {console.log(name);// 打印 name}return function () {callName();// 形成接口}
}());
init(); // initName
var initSuper = (function () {var name = "initSuperName";function callName() {console.log(name);// 打印 name}return function () {callName();// 形成接口}
}());
initSuper(); // initSuperName
好了,在此小節的最后,我們來對閉包做一個小小的總結:
-
閉包是一個封閉的空間,里面存儲了在其他地方會引用到的該作用域的值,在 JavaScript 中是通過作用域鏈來實現的閉包。
-
只要在函數中使用了外部的數據,就創建了閉包,這種情況下所創建的閉包,我們在編碼時是不需要去關心的。
-
我們還可以通過一些手段手動創建閉包,從而讓外部環境訪問到函數內部的局部變量,讓局部變量持續保存下來,不隨著它的上下文環境一起銷毀。
閉包經典問題
聊完了閉包,接下來我們來看一個閉包的經典問題。
for (var i = 1; i <= 3; i++) {setTimeout(function () {console.log(i);}, 1000);
}
在上面的代碼中,我們預期的結果是過 1 秒后分別輸出 i 變量的值為 1,2,3。但是,執行的結果是:4,4,4。
實際上,問題就出在閉包身上。你看,循環中的 setTimeout 訪問了它的外部變量 i,形成閉包。
而 i 變量只有 1 個,所以循環 3 次的 setTimeout 中都訪問的是同一個變量。循環到第 4 次,i 變量增加到 4,不滿足循環條件,循環結束,代碼執行完后上下文結束。但是,那 3 個 setTimeout 等 1 秒鐘后才執行,由于閉包的原因,所以它們仍然能訪問到變量 i,不過此時 i 變量值已經是 4 了。
要解決這個問題,我們可以讓 setTimeout 中的匿名函數不再訪問外部變量,而是訪問自己內部的變量,如下:
for (var i = 1; i <= 3; i++) {(function (index) {setTimeout(function () {console.log(index);}, 1000);})(i)
}
這樣 setTimeout 中就可以不用訪問 for 循環聲明的變量 i 了。而是采用調用函數傳參的方式把變量 i 的值傳給了 setTimeout,這樣它們就不再創建閉包,因為在我自己的作用域里面能夠找到 i 這個變量。
當然,解決這個問題還有個更簡單的方法,就是使用 ES6 中的 let 關鍵字。
它聲明的變量有塊作用域,如果將它放在循環中,那么每次循環都會有一個新的變量 i,這樣即使有閉包也沒問題,因為每個閉包保存的都是不同的 i 變量,那么剛才的問題也就迎刃而解。
for (let i = 1; i <= 3; i++) {setTimeout(function () {console.log(i);}, 1000);
}
真題解答
- 閉包是什么?閉包的應用場景有哪些?怎么銷毀閉包?
閉包是一個封閉的空間,里面存儲了在其他地方會引用到的該作用域的值,在 JavaScript 中是通過作用域鏈來實現的閉包。
只要在函數中使用了外部的數據,就創建了閉包,這種情況下所創建的閉包,我們在編碼時是不需要去關心的。
我們還可以通過一些手段手動創建閉包,從而讓外部環境訪問到函數內部的局部變量,讓局部變量持續保存下來,不隨著它的上下文環境一起銷毀。
使用閉包可以解決一個全局變量污染的問題。
如果是自動產生的閉包,我們無需操心閉包的銷毀,而如果是手動創建的閉包,可以把被引用的變量設置為 null,即手動清除變量,這樣下次 JavaScript 垃圾回收器在進行垃圾回收時,發現此變量已經沒有任何引用了,就會把設為 null 的量給回收了。
-EOF-