閱讀本文大概需要二十分鐘
一直有一些剛入門js的朋友問我“什麼是閉包?”,這裏我就專門總結一下,下次再有人問起來,就直接把這篇文章給他看好了。
爲什麼閉包這麼重要?
因爲要想理解閉包的概念,就必須要理解js語言的幾個基本特性:執行上下文
、作用域鏈
(與類C語言的作用域不同!)。所以閉包考察的不僅僅是這一個概念,而是考察的是對js語言基本特性的理解程度。
所以本文將從執行上下文
、作用域鏈
講起,在理解了這幾個概念之後,再介紹閉包
的概念。
一、執行上下文
如果要問到JS的執行順序,想必有過JS開發經驗的開發者都會有一個直觀印象:順序執行。然而,
JavaScript引擎並非一行一行地分析和執行程序,而是一段一段地分析執行。當執行一段代碼的時候,會進行一個“準備工作”。這裏的準備工作,更專業一點的說法,叫做“執行上下文”。
我們先來講全局環境
的“執行上下文”,再講函數
的執行上下文。
1. 全局環境的執行上下文
我們分三種情況來講全局環境的執行上下文。
(1)第一種情況
我們先來看如下代碼:
這裏第一塊直接報錯,因爲根本沒有聲明變量a,第二三塊都輸出了undefined,這說明a被定義了,但並沒有被賦值,然而定義變量a的代碼在運行時是在輸出語句之後被執行到,可是變量a被提前聲明瞭。
這說明,在js代碼被一行一行執行之前,引擎已經提前做了一些準備操作
,這其中就包括對變量的聲明,而不是賦值
。
(2)第二種情況
還是先來看代碼:
有js開發經驗的朋友都知道,無論在哪個位置獲取this,都可以得到值,只不過根據不同情況,this的值都不同。與第一種情況不同的是:第一種情況只對變量進行聲明(並沒有賦值),而這種情況直接給this賦值,這也是準備工作
做的重要事情之一。
(3)第三種情況
在第三種情況中,需要注意兩個概念——‘函數聲明
’和‘函數表達式
’。雖然兩者都很常用,但在準備工作
中卻有着不同的待遇。
看以上代碼,函數聲明在準備工作中不但被聲明,還被賦值了,而對待函數表達式就像對待變量聲明一樣,只是聲明。
好了,準備工作
介紹完畢。
(4)注意函數聲明和變量聲明的優先級
這裏先看一個例子
function test() {
function a() {}
var a;
log(a); //打印出a的函數體
var b;
function b() {}
log(b); //打印出b的函數體
// !注意看,一旦變量被賦值後,將會輸出變量
var c = 12
function c() {}
log(c); //12
function d() {}
var d = 12
log(d); //12
}
test();
可以看到,在準備工作
中,當變量和函數同時聲明時,函數的優先級是更高的。然而,應該明確的是,變量賦值是比函數聲明的優先級更高的。
我們總結一下,在準備工作
中做了哪些工作:
- 變量、函數表達式:聲明(默認值是undefined)
- this:聲明&賦值
- 函數:聲明&賦值
這些準備工作我們稱之爲執行上下文
。
2. 函數的執行上下文
在函數中,除了做如上準備工作,還會做其他準備工作。先來看代碼:
在函數體執行前,arguments和參數x就已經被賦值。從這裏可以看出,函數每調用一次,就創建一個執行上下文
。因爲不同調用可能有不同的參數。
3、執行上下文棧
前面我們講解了執行上下文
,下面我們來講一下執行上下文棧
。執行全局代碼時,會先創建一個執行上下文環境
;每次調用一個函數時,也會先創建一個執行上下文環境
。當函數調用完成,這個執行上下文環境及其創建的數據都會被銷燬,並回到全局上下文環境,處於活動的執行上下文只有一個。
這其實是一個壓棧出棧的過程——執行上下文棧。如下圖:
可以根據如下代碼來講解執行上下文棧:
這是一種很理想的情況,在實際情況中可能沒有辦法乾淨利落地說銷燬就銷燬。這種情況就是偉大的——閉包。
在介紹閉包之前,還需要講解下作用域
和自由變量
的概念。
二. 作用域
這部分分爲塊級作用域
、立即執行函數
、作用域和執行上下文的關係
、作用域鏈
四個部分來講解。
1. 塊級作用域
提到作用域,有一句話大家可能比較熟悉:“JavaScript沒有塊級作用域,只有函數作用域
”。塊級作用域就是大括號“{ }”中間的代碼。任何一對大括號中的語句都屬於一個塊,在這之中定義的變量在括號外無法訪問,這叫做塊級作用域。例如:
function scopeTest() {
var scope = {};
if (scope instanceof Object) {
var j = 1;
for (var i = 0; i < 10; i++) {
//console.log(i);
}
console.log(i); //輸出10
}
console.log(j);//輸出1
}
在JavaScript中變量的作用範圍是函數級的,所以會在for循環後輸出10,在if語句後輸出1。
那麼在JavaScript中怎麼模擬一個塊級作用域呢?就可以用我們接下來要講到的立即執行函數
。
2. 立即執行函數
立即執行函數可以模擬塊級作用域,防止變量全局污染
,同時也是爲了立即去執行一個函數。
立即執行函數是指聲明完便立即執行的函數,這裏函數通常是一次性使用的,因此沒必要給函數命名,直接讓它執行就好了。
所以,立即執行函數的形式應該如下:
<script type="text/javascript">
function (){}(); // SyntaxError: Unexpected token (
//引擎在遇到關鍵字function時,會默認將其當做是一個函數聲明,函數聲明必須有一個函數名,所以在執行到第一個左括號時就報語法錯誤了;
(function(){…})();
//在function前面加!、+、 -、=甚至是逗號等或者把函數用()包起來都可以將函數聲明轉換成函數表達式;我們一般用()把函數聲明包起來或者用 =
</script>
雖然立即執行函數是想在定義完函數後直接就調用,但是引擎在遇到關鍵字function時,會默認將其當做是一個函數聲明,函數聲明必須要有一個函數名,所以執行到第一個括號就報錯了。
正確地定義一個立即執行函數,是應該用括號把函數聲明包起來。
此外,實際應用中,立即執行函數還可用來寫插件。
<script type="text/javascript">
var Person = (function(){
var _sayName = function(str){
str = str || 'shane';
return str;
}
var _sayAge = function(age){
age = age || 18;
return age;
}
return {
SayName : _sayName,
SayAge : _sayAge
}
})();
//通過插件提供的API使用插件
console.log(Person.SayName('lucy')); //lucy
console.log(Person.SayName());//shane
console.log(Person.SayAge());//18
</script>
那麼在Javascript中如何模擬塊級作用域呢?舉個例子:
function test(){
(function (){
for(var i=0;i<4;i++){
}
})();
alert(i);
}
test();
函數執行完,彈出的是i
未定義的錯誤。
3. 作用域與執行上下文
作用域和執行上下文是一對一的關係,如下圖:
在執行全局作用域中的代碼前,會先創建一個全局作用域的執行上下文;當調用函數時,也會創建一個函數作用域的上下文。
作用域是一個抽象的概念,其中沒有變量,要通過作用域對應的執行上下文來獲取變量的值。同一個函數,在不同的調用下,會創建不同的執行上下文,所以會產生不同的變量的值。
如果要查找一個作用域下某個變量的值,就需要找個這個作用域的執行上下文,在裏面找到對應變量的值。
這個時候,要是在當前作用域的執行上下文中找不到變量該怎麼辦?這就涉及到了作用域鏈的概念。
4. 作用域鏈
我們先來看如下代碼:
var x = 10
function fn() {
var b = 20
console.log(b + x)
}
在調用fn時,取變量b的值就可以在當前作用域中取,而取變量x的值時,就需要到另一個作用域中取。到哪個作用域中取呢?
很多人說到父作用域中取,其實這種解釋會產生歧義。例如:
var x = 10
function fn() {
console.log(x)
}
function show(f) {
var x = 20
f()
}
show(fn)
程序執行完,輸出是10,而不是20。這是因爲,在當前函數中找不到變量時,要到創建
這個函數的那個作用域中去取值。
想必接下來這個程序的執行結果你應該也知道了。
var a = 'global';
var f = function(){
console.log(a); // 答案是undefined, 想想爲什麼
var a = 'local';
}
f();
好了,有了以上基礎知識,我們接下來來講解閉包
。
三. 閉包
JavaScript語言的特別之處在於:函數內部可以讀取全局變量,但函數外部無法訪問到函數內部的變量。出於種種原因,我們有時候需要獲取函數內部變量的值。正常情況下,這是辦不到的!只有通過變通的方法才能實現:在函數內部,再定義一個函數。
function f1(){
var n=999;
function f2(){
alert(n); // 999
}
}
那麼什麼是閉包呢?閉包就是有權訪問另一個函數內部作用域的變量的函數。
有了前文的基礎,理解閉包的概念就不是那麼難了。不過在現實情況中,閉包有兩種典型場景一定要知道。
1. 閉包的兩種典型應用
第一,函數作爲返回值
function fn() {
var max = 10
return function bar(x) {
if(x > max) {
console.log(x)
}
}
}
var f1 = fn()
f1(15)
第二,函數作爲參數被傳遞
var max = 10,
fn = function(x) {
if(x > max) {
console.log(x)
}
}
(function(f) {
var maxx = 100
f(15)
})(fn)
2. 如何從內存角度理解閉包?
- JavaScript具有自動垃圾回收機制,函數運行完之後,其內部變量就會被銷燬;
- 閉包就是在外部可以訪問此函數作用域變量的函數,JavaScript中,只要存在引用函數內部變量的可能,JavaScript就需要在內存中保留這些變量,而且JavaScript運行時需要跟蹤這個內部變量的所有外部引用,直到最後一個引用被解除(置爲null或者頁面關閉),JavaScript垃圾收集器才釋放相應的內存空間。
舉個例子,
<script type="text/javascript">
function outer(){
var a = 1;
function inner(){
return a++;
}
return inner;
}
var abc = outer();
//outer()只要執行過,就有了引用函數內部變量的可能,然後就會被保存在內存中;
//outer()如果沒有執行過,由於作用域的關係,看不到內部作用域,更不會被保存在內存中了;
console.log(abc());//1
console.log(abc());//2
//因爲a已經在內存中了,所以再次執行abc()的時候,是在第一次的基礎上累加的
var def = outer();
console.log(def());//1
console.log(def());//2
//再次把outer()函數賦給一個新的變量def,相當於綁定了一個新的outer實例;
//console.log(a);//ReferenceError: a is not defined
//console.log(inner);//ReferenceError: a is not defined
//由於作用域的關係我們在外部還是無法直接訪問內部作用域的變量名和函數名
abc = null;
//由於閉包占用內存空間,所以要謹慎使用閉包。儘量在使用完閉包後,及時解除引用,釋放內存;
</script>
四. 閉包的經典陷阱
接下來來看一個閉包的經典陷阱——在循環中使用閉包。舉個例子,要給10個span元素加上click事件監聽,讓每個span點擊時依次輸出0-9。我們可能會這樣寫:
for (var i = 0; i < spans.length; i++) {
spans[i].onclick = function() {
alert(i);
}
}
可實際上呢?這裏每個span標籤點擊都輸出的是10。
爲什麼會這樣呢?
因爲內部函數持有了外部函數中變量i的引用,所以i不會被銷燬,每個function都返回i,而在執行上述程序時,onclick方法是沒有執行的,等到for循環執行完畢,i變爲10,每個onclick函數都返回i,i這時已經變爲10了。
爲了解決這個問題,我們應該用到立即執行函數:
for (var i = 0; i < spans.length; i++) {
(function(e){
spans[e].onclick = function() {
alert(e);
}
})(i)
}
立即執行函數會立即執行,並把 i 作爲它的參數,此時函數內 e 變量就擁有了 i 的一個拷貝。
五. 爲什麼要用閉包?
- 符合函數式編程規範
什麼是函數式編程?它的思想是:把運算過程儘量寫成一系列嵌套的函數調用。舉例來說,要想代碼中實現數學表達式:
(1 + 2) * 3 - 4
傳統的寫法是:
var a = 1 + 2;
var b = a * 3;
var c = b - 4;
函數式編程要求儘量使用函數,把運算過程定義爲不用的函數:
var result = subtract(multiply(add(1,2), 3), 4);
此外,函數式編程把函數作爲“一等公民”。函數與其他數據類型一樣,處於平等地位,可以賦值給其他變量,也可以作爲參數傳入另一個函數,或者作爲別的函數的返回值。
- 延長變量生命週期
局部變量本來在函數執行完就被銷燬,然而閉包中不是這樣,局部變量生命週期被延長。不過這也容易使這些數據無法及時銷燬,會佔用內存,容易造成內存泄漏。如:
function addHandle() {
var element = document.getElementById('myNode');
element.onclick = function() {
alert(element.id);
}
}
onclick保存了一個element的引用,element將不會被回收。
function addHandle() {
var element = document.getElementById('myNode');
var id = element.id;
element.onclick = function() {
alert(id);
}
element = null;
}
此處將element設爲null,即解除對其引用,垃圾回收器將回收其佔用內存。
六. tips:
- 如果閉包只有一個參數,這個參數可以省略,可以直接用it訪問該參數。
- 實際中閉包常常和立即執行函數結合使用。
七. 參考
http://imweb.io/topic/5665683bd91952db73b41f5e
https://www.cnblogs.com/sspeng/p/6623556.html
http://www.cnblogs.com/dolphinX/archive/2012/09/29/2708763.html
https://segmentfault.com/a/1190000003985390
https://www.jianshu.com/p/0fe03fd2d862
https://segmentfault.com/a/1190000000618597
http://www.cnblogs.com/wangfupeng1988/p/3977924.html
https://www.cnblogs.com/cxying93/p/6103375.html
https://www.cnblogs.com/ZinCode/p/5551907.html
https://blog.csdn.net/weixin_40197429/article/details/79557101
https://www.cnblogs.com/luqin/p/5164132.html