JavaScript 閉包究竟是什麼?
用JavaScript一年多了,閉包總是讓人二丈和尚摸不著頭腦。陸陸續續接觸了一些閉包的知識,也犯過幾次因為不理解閉包導致的錯誤,一年多了資料也看了一些,但還是不是非常明白,最近偶然看了一下jQuery基礎教程的附錄,發現附錄A對JavaScript的閉包的介紹簡單易懂,於是藉花獻佛總結一下。
1、定義
閉包:是指有權存取另外一個函數作用域中的變數的函數。創建閉包的常見方式就是在一個函數內部創建另一個函數。
直接上例
function a(){ var i=0; function b(){ alert(++i); } return b; } var c = a(); c();
這段程式碼有兩個特點:
1)、函數b巢狀在函數a內部;
2)、函數a返回函數b。
這樣在執行完var c=a()後,變數c其實是指向了函數b,再執行c()後就會彈出一個視窗顯示i的值(第一次為1)。這段程式碼其實就創造了一個閉包,為什麼?因為函數a外的變數c引用了函數a內的函數b,就是說:
當函數a的內部函數b被函數a外的一個變數引用的時候,就創建了一個閉包。
我猜想你一定還是不懂閉包,因為你不知道閉包有什麼作用,以下讓我們繼續探索。
2、閉包有什麼作用?
簡而言之,閉包的作用就是在a執行完並返回後,閉包使得Javascript的垃圾回收機制GC不會收回a所佔用的資源,因為a的內部函數b的執行需要依賴a中的變數。這是對閉包作用的非常直白的描述,不專業也不嚴謹,但大概意思就是這樣,理解閉包需要循序漸進的過程。
在上面的例子中,由於閉包的存在使得函數a返回後,a中的i始終存在,這樣每次執行c(),i都是自加1後alert出i的值。
那我們來想像另一種情況,如果a回傳的不是函數b,情況就完全不同了。因為a執行完後,b沒有被回傳給a的外界,只是被a所引用,而此時a也只會被b引用,因此函數a和b互相引用但又不被外界打擾(被外界引用) ,函數a和b就會被GC回收。 (關於Javascript的垃圾回收機制將在後面詳細介紹)
3、閉包內的微觀世界
如果要更深入的了解閉包以及函數a和巢狀函數b的關係,我們需要引入另外幾個概念:函數的執行環境(excution context)、活動物件(call object)、作用域(scope )、作用域鏈(scope chain)。以函數a從定義到執行的過程為例闡述這幾個概念。
1)、當定義函數a的時候,js解釋器會將函數a的作用域鏈(scope chain)設定為定義a時a所在的“環境”,如果a是一個全域函數,則scope chain中只有window對象。
2)、當函數a執行的時候,a會進入對應的執行環境(excution context)。
3)、在創建執行環境的過程中,首先會為a增加一個scope屬性,即a的作用域,其值為第1步驟中的scope chain。即a.scope=a的作用域鏈。
4)、然後執行環境會建立一個活動物件(call object)。活動對像也是擁有屬性的對象,但它不具有原型且不能透過JavaScript程式碼直接存取。建立完活動物件後,把活動物件加入a的作用域鏈的最頂端。此時a的作用域鏈包含了兩個物件:a的活動物件和window物件。
5)、下一步是在活動物件上新增一個arguments屬性,它保存著呼叫函數a時所傳遞的參數。
6)、最後把所有函數a的形參和內部的函數b的引用也加到a的活動物件上。在這一步驟中,完成了函數b的的定義,因此如同第3步,函數b的作用域鏈被設定為b所被定義的環境,即a的作用域。
到此,整個函數a從定義到執行的步驟就完成了。此時a回傳函數b的引用給c,又函數b的作用域鏈包含了對函數a的活動物件的引用,也就是說b可以存取a中定義的所有變數和函數。函數b被c引用,函數b又依賴函數a,因此函數a在回傳後不會被GC回收。
當函數b執行的時候也會像上述步驟一樣。因此,執行時b的作用域鏈包含了3個對象:b的活動對象、a的活動對象和window對象,當在函數b中訪問一個變量的時候,搜索順序是先搜索自身的活動對象,如果存在則傳回,如果不存在將繼續搜尋函數a的活動對象,依序查找,直到找到為止。如果整個作用域鏈上都無法找到,則傳回undefined。如果函數b存在prototype原型對象,則在查找完自身的活動對象後先查找自身的原型對象,再繼續找。這就是Javascript中的變數查找機制。
4、閉包的應用場景
1)、保護函數內的變數安全。以最開始的例子為例,函數a中i只有函數b才能訪問,而無法透過其他途徑訪問到,因此保護了i的安全性。
2)、在記憶體中維持一個變數。依然如前例,由於閉包,函數a中i的一直存在於記憶體中,因此每次執行c(),都會給i自加1。
以上兩點是閉包最基本的應用場景,許多經典案例都源自於此。
5、Javascript的垃圾回收機制
在Javascript中,如果一個物件不再被引用,那麼這個物件就會被GC回收。如果兩個物件互相引用,而不再被第3者所引用,那麼這兩個互相引用的物件也會被回收。因為函數a被b引用,b又被a外的c引用,這就是為什麼函數a執行後不會被回收的原因。
在javascript中没有块级作用域,一般为了给某个函数申明一些只有该函数才能使用的局部变量时,我们就会用到闭包,这样我们可以很大程度上减少全局作用域中的变量,净化全局作用域。
使用闭包有如上的好处,当然这样的好处是需要付出代价的,代价就是内存的占用。
如何理解上面的那句话呢?
每个函数的执行,都会创建一个与该函数相关的函数执行环境,或者说是函数执行上下文。这个执行上下文中有一个属性 scope chain(作用域链指针),这个指针指向一个作用域链结构,作用域链中的指针又都指向各个作用域对应的活动对象。正常情况,一个函数在调用开始执行时创建这个函数执行上下文及相应的作用域链,在函数执行结束后释放函数执行上下文及相应作用域链所占的空间。
//声明函数 function test(){ var str = "hello world"; console.log(str); } //调用函数 test();
在调用函数的时候会在内存中生成如下图的结构:
但是闭包的情况就有点特殊了,由于闭包函数可以访问外层函数中的变量,所以外层函数在执行结束后,其作用域活动对象并不会被释放(注意,外层函数执行结束后执行环境和对应的作用域链就会被销毁),而是被闭包函数的作用域链所引用,直到闭包函数被销毁后,外层函数的作用域活动对象才会被销毁。这也正是闭包要占用内存的原因。
所以使用闭包有好处,也有坏处,滥用闭包会造成内存的大量消耗。
使用闭包还有其他的副作用,可以说是bug,也可以说不是,相对不同的业务可能就会有不同的看法。
这个副作用是闭包函数只能取到外层函数变量的最终值。
测试代码如下:(这里使用了jquery对象)
/*闭包缺陷*/ (function($){ var result = new Array(), i = 0; for(;i<10;i++){ result[i] = function(){ return i; }; } $.RES1 = result; })(jQuery); // 执行数组中的函数 $.RES1[0]();
上面的代码先通过匿名函数表达式开辟了一块私有作用域,这个匿名函数就是我们上面所说的外层函数,该外层函数有一个参数$,同时还定义了变量result和 I , 通过for循环给数组result赋值一个匿名函数,这个匿名函数就是闭包,他访问了外层函数的变量I , 理论上数组resulti 会返回相应的数组下标值,实际情况却不如所愿。
如上代码 $.RES10 的执行结果是10.
为什么会这样呢,因为i的最终值就是10.
下面我们通过下图来详细说明下,上面的那段代码执行时在内存中到底发生了什么:
那么这个副作用有没有办法可以修复呢?当然可以!
我们可以通过下面的代码来达到我们的预期。
/*修复闭包缺陷*/ (function($){ var result = new Array(), i = 0; for(;i<10;i++){ result[i] = function(num){ return function(){ return num; } }(i); } $.RES2 = result; })(jQuery); //调用闭包函数 console.log($.RES2[0]());
上面的代码又在内存中发生了什么?我们同样用下面的一幅图来详细解释。看懂了上面的图,我们也就不难理解下面的图。
6.简单的例子
首先从一个经典错误谈起,页面上有若干个div, 我们想给它们绑定一个onclick方法,于是有了下面的代码
<div id="divTest"> <span>0</span> <span>1</span> <span>2</span> <span>3</span> </div> <div id="divTest2"> <span>0</span> <span>1</span> <span>2</span> <span>3</span> </div> $(document).ready(function() { var spans = $("#divTest span"); for (var i = 0; i < spans.length; i++) { spans[i].onclick = function() { alert(i); } } });
很简单的功能可是却偏偏出错了,每次alert出的值都是4,简单的修改就好使了
var spans2 = $("#divTest2 span"); $(document).ready(function() { for (var i = 0; i < spans2.length; i++) { (function(num) { spans2[i].onclick = function() { alert(num); } })(i); } });
7.内部函数
让我们从一些基础的知识谈起,首先了解一下内部函数。内部函数就是定义在另一个函数中的函数。例如:
function outerFn () { functioninnerFn () {} }
innerFn就是一个被包在outerFn作用域中的内部函数。这意味着,在outerFn内部调用innerFn是有效的,而在outerFn外部调用innerFn则是无效的。下面代码会导致一个JavaScript错误:
function outerFn() { document.write("Outer function<br/>"); function innerFn() { document.write("Inner function<br/>"); } } innerFn();//Uncaught ReferenceError: innerFn is not defined
不过在outerFn内部调用innerFn,则可以成功运行:
function outerFn() { document.write("Outer function<br/>"); function innerFn() { document.write("Inner function<br/>"); } innerFn(); } outerFn();
8、伟大的逃脱(内部函数如何逃脱外部函数)
JavaScript允许开发人员像传递任何类型的数据一样传递函数,也就是说,JavaScript中的内部函数能够逃脱定义他们的外部函数。
逃脱的方式有很多种,例如可以将内部函数指定给一个全局变量:
//定义全局变量逃脱 var globalVar; function outerFn() { document.write("Outer function<br/>"); function innerFn() { document.write("Inner function<br/>"); } globalVar = innerFn; } outerFn(); //Outer function Inner function globalVar(); //Outer function Inner function innerFn(); //ReferenceError: innerFn is not defined
调用outerFn时会修改全局变量globalVar,这时候它的引用变为innerFn,此后调用globalVar和调用innerFn一样。这时在outerFn外部直接调用innerFn仍然会导致错误,这是因为内部函数虽然通过把引用保存在全局变量中实现了逃脱,但这个函数的名字依然只存在于outerFn的作用域中。
也可以通过在父函数的返回值来获得内部函数引用
function outerFn() { document.write("Outer function<br/>"); function innerFn() { document.write("Inner function<br/>"); } return innerFn; } var fnRef = outerFn(); fnRef();
这里并没有在outerFn内部修改全局变量,而是从outerFn中返回了一个对innerFn的引用。通过调用outerFn能够获得这个引用,而且这个引用可以可以保存在变量中。
这种即使离开函数作用域的情况下仍然能够通过引用调用内部函数的事实,意味着只要存在调用内部函数的可能,JavaScript就需要保留被引用的函数。而且JavaScript运行时需要跟踪引用这个内部函数的所有变量,直到最后一个变量废弃,JavaScript的垃圾收集器才能释放相应的内存空间(红色部分是理解闭包的关键)。
说了半天总算和闭包有关系了,闭包是指有权限访问另一个函数作用域的变量的函数,创建闭包的常见方式就是在一个函数内部创建另一个函数,就是我们上面说的内部函数,所以刚才说的不是废话,也是闭包相关的 ^_^
9、变量的作用域
内部函数也可以有自己的变量,这些变量都被限制在内部函数的作用域中:
function outerFn() { document.write("Outer function<br/>"); function innerFn() { var innerVar = 0; innerVar++; document.write("Inner function\t"); document.write("innerVar = "+innerVar+"<br/>"); } return innerFn; } var fnRef = outerFn(); fnRef(); fnRef(); var fnRef2 = outerFn(); fnRef2(); fnRef2();
每当通过引用或其它方式调用这个内部函数时,就会创建一个新的innerVar变量,然后加1,最后显示
Outer function Inner function innerVar = 1 Inner function innerVar = 1 Outer function Inner function innerVar = 1 Inner function innerVar = 1
内部函数也可以像其他函数一样引用全局变量:
var globalVar = 0; function outerFn() { document.write("Outer function<br/>"); function innerFn() { globalVar++; document.write("Inner function\t"); document.write("globalVar = " + globalVar + "<br/>"); } return innerFn; } var fnRef = outerFn(); fnRef(); fnRef(); var fnRef2 = outerFn(); fnRef2(); fnRef2();
现在每次调用内部函数都会持续地递增这个全局变量的值:
Outer function Inner function globalVar = 1 Inner function globalVar = 2 Outer function Inner function globalVar = 3 Inner function globalVar = 4
但是如果这个变量是父函数的局部变量又会怎样呢?因为内部函数会引用到父函数的作用域(有兴趣可以了解一下作用域链和活动对象的知识),内部函数也可以引用到这些变量
function outerFn() { var outerVar = 0; document.write("Outer function<br/>"); function innerFn() { outerVar++; document.write("Inner function\t"); document.write("outerVar = " + outerVar + "<br/>"); } return innerFn; } var fnRef = outerFn(); fnRef(); fnRef(); var fnRef2 = outerFn(); fnRef2(); fnRef2();
这一次结果非常有意思,也许或出乎我们的意料
Outer function Inner function outerVar = 1 Inner function outerVar = 2 Outer function Inner function outerVar = 1 Inner function outerVar = 2
我们看到的是前面两种情况合成的效果,通过每个引用调用innerFn都会独立的递增outerVar。也就是说第二次调用outerFn没有继续沿用outerVar的值,而是在第二次函数调用的作用域创建并绑定了一个一个新的outerVar实例,两个计数器完全无关。
当内部函数在定义它的作用域的外部被引用时,就创建了该内部函数的一个闭包。这种情况下我们称既不是内部函数局部变量,也不是其参数的变量为自由变量,称外部函数的调用环境为封闭闭包的环境。从本质上讲,如果内部函数引用了位于外部函数中的变量,相当于授权该变量能够被延迟使用。因此,当外部函数调用完成后,这些变量的内存不会被释放(最后的值会保存),闭包仍然需要使用它们。
10.闭包之间的交互
当存在多个内部函数时,很可能出现意料之外的闭包。我们定义一个递增函数,这个函数的增量为2
function outerFn() { var outerVar = 0; document.write("Outer function<br/>"); function innerFn1() { outerVar++; document.write("Inner function 1\t"); document.write("outerVar = " + outerVar + "<br/>"); } function innerFn2() { outerVar += 2; document.write("Inner function 2\t"); document.write("outerVar = " + outerVar + "<br/>"); } return { "fn1": innerFn1, "fn2": innerFn2 }; } var fnRef = outerFn(); fnRef.fn1(); fnRef.fn2(); fnRef.fn1(); var fnRef2 = outerFn(); fnRef2.fn1(); fnRef2.fn2(); fnRef2.fn1();
我们映射返回两个内部函数的引用,可以通过返回的引用调用任一个内部函数,结果:
Outer function Inner function 1 outerVar = 1 Inner function 2 outerVar = 3 Inner function 1 outerVar = 4 Outer function Inner function 1 outerVar = 1 Inner function 2 outerVar = 3 Inner function 1 outerVar = 4
innerFn1和innerFn2引用了同一个局部变量,因此他们共享一个封闭环境。当innerFn1为outerVar递增一时,久违innerFn2设置了outerVar的新的起点值,反之亦然。我们也看到对outerFn的后续调用还会创建这些闭包的新实例,同时也会创建新的封闭环境,本质上是创建了一个新对象,自由变量就是这个对象的实例变量,而闭包就是这个对象的实例方法,而且这些变量也是私有的,因为不能在封装它们的作用域外部直接引用这些变量,从而确保了了面向对象数据的专有性。
11.解惑
现在我们可以回头看看开头写的例子就很容易明白为什么第一种写法每次都会alert 4了。
for (var i = 0; i < spans.length; i++) { spans[i].onclick = function() { alert(i); } }
上面程式碼在頁面載入後就會執行,當i的值為4的時候,判斷條件不成立,for迴圈執行完畢,但是因為每個span的onclick方法這時候為內部函數,所以i被閉包引用(閉包引用傳的是引用),記憶體不能被銷毀,i的值會一直保持4,直到程式改變它或所有的onclick函數銷毀(主動把函數賦為null或頁面卸載)才會被回收。這樣每次我們點擊span的時候,onclick函數會找i的值(作用域鍊是引用方式),一查等於4,然後就alert給我們了。
而第二種方式是使用了一個立即執行的函數又創建了一層閉包,函數宣告放在括號內就變成了表達式,後面再加上括號括號就是呼叫了,這時候把i當參數傳入,函數立即執行,num保存每次i的值。
這一通下來想必大家也跟我一樣,對閉包有所了解了吧,當然完全了解的話需要把函數的執行環境和作用域鏈搞清楚。