深入理解javascript範圍和閉包
範圍
範圍是一個變數和函數的作用範圍,javascript中函數內聲明的所有變數在函數體內始終是可見的,在javascript中有全域範圍和局部範圍,但是沒有塊級範圍,局部變數的優先順序高於全域變數,通過幾個樣本來瞭解下javascript中範圍的那些“潛規則”(這些也是在前端面試中經常問到的問題)。
1. 變數聲明提前
樣本1:
| 1 2 3 4 5 6 |
var scope="global"; function scopeTest(){ console.log(scope); var scope="local" } scopeTest(); //undefined |
此處的輸出是undefined,並沒有報錯,這是因為在前面我們提到的函數內的聲明在函數體內始終可見,上面的函數等效於:
| 1 2 3 4 5 6 7 |
var scope="global"; function scopeTest(){ var scope; console.log(scope); scope="local" } scopeTest(); //local |
注意,如果忘記var,那麼變數就被聲明為全域變數了。
2. 沒有塊級範圍
和其他我們常用的語言不同,在Javascript中沒有塊級範圍:
| 1 2 3 4 5 6 7 8 9 10 11 12 |
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中變數的作用範圍是函數級的,即在函數中所有的變數在整個函數中都有定義,這也帶來了一些我們稍不注意就會碰到的“潛規則”:
| 1 2 3 4 5 6 |
var scope = "hello"; function scopeTest() { console.log(scope);//① var scope = "no"; console.log(scope);//② } |
在①處輸出的值竟然是undefined,簡直喪心病狂啊,我們已經定義了全域變數的值啊,這地方不應該為hello嗎?其實,上面的代碼等效於:
| 1 2 3 4 5 6 7 |
var scope = "hello"; function scopeTest() { var scope; console.log(scope);//① scope = "no"; console.log(scope);//② } |
聲明提前、全域變數優先順序低於局部變數,根據這兩條規則就不難理解為什麼輸出undefined了。
範圍鏈
在javascript中,每個函數都有自己的執行內容環境,當代碼在這個環境中執行時,會建立變數對象的範圍鏈,範圍鏈是一個對象列表或對象鏈,它保證了變數對象的有序訪問。
範圍鏈的前端是當前代碼執行環境的變數對象,常被稱之為“活躍對象”,變數的尋找會從第一個鏈的對象開始,如果對象中包含變數屬性,那麼就停止尋找,如果沒有就會繼續向上級範圍鏈尋找,直到找到全域對象中:
範圍鏈的逐級尋找,也會影響到程式的效能,變數範圍鏈越長對效能影響越大,這也是我們盡量避免使用全域變數的一個主要原因。
閉包
基礎概念
範圍是理解閉包的一個前提,閉包是指在當前範圍內總是能訪問外部範圍中的變數。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function createClosure(){ var name = "jack"; return { setStr:function(){ name = "rose"; }, getStr:function(){ return name + ":hello"; } } } var builder = new createClosure(); builder.setStr(); console.log(builder.getStr()); //rose:hello |
上面的樣本在函數中返回了兩個閉包,這兩個閉包都維持著對外部範圍的引用,因此不管在哪調用總是能夠訪問外部函數中的變數。在一個函數內部定義的函數,會將外部函數的活躍對象添加到自己的範圍鏈中,因此上面執行個體中通過內建函式能夠訪問外部函數的屬性,這也是javascript類比私人變數的一種方式。
注意:由於閉包會額外的附帶函數的範圍(內部匿名函數攜帶外部函數的範圍),因此,閉包會比其它函數多佔用些記憶體空間,過度的使用可能會導致記憶體佔用的增加。
閉包中的變數
在使用閉包時,由於範圍鏈機制的影響,閉包只能取得內建函式的最後一個值,這引起的一個副作用就是如果內建函式在一個迴圈中,那麼變數的值始終為最後一個值。
| 1 2 3 4 5 6 7 8 |
//該執行個體不太合理,有一定延遲因素,此處主要為了說明閉包迴圈中存在的問題 function timeManage() { for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); },1000) }; } |
上面的程式並沒有按照我們預期的輸入1-5的數字,而是5次全部輸出了5。再來看一個樣本:
| 1 2 3 4 5 6 7 8 9 |
function createClosure(){ var result = []; for (var i = 0; i < 5; i++) { result[i] = function(){ return i; } } return result; } |
調用createClosure()[0]()返回的是5,createClosure()[4]()傳回值仍然是5。通過以上兩個例子可以看出閉包在帶有迴圈的內建函式使用時存在的問題:因為每個函數的範圍鏈中都儲存著對外部函數(timeManage、createClosure)的活躍對象,因此,他們都引用著同一變數i,當外部函數返回時,此時的i值為5,所以內部的每個函數i的值也為5。
那麼如何解決這個問題呢?我們可以通過匿名包裹器(匿名自執行函數運算式)來強制返回預期的結果:
| 1 2 3 4 5 6 7 8 9 |
function timeManage() { for (var i = 0; i < 5; i++) { (function(num) { setTimeout(function() { console.log(num); }, 1000); })(i); } } |
或者在閉包匿名函數中再返回一個匿名函數賦值:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function timeManage() { for (var i = 0; i < 10; i++) { setTimeout((function(e) { return function() { console.log(e); } })(i), 1000) } } //timeManager();輸出1,2,3,4,5 function createClosure() { var result = []; for (var i = 0; i < 5; i++) { result[i] = function(num) { return function() { console.log(num); } }(i); } return result; } //createClosure()[1]()輸出1;createClosure()[2]()輸出2 |
無論是匿名包裹器還是通過嵌套匿名函數的方式,原理上都是由於函數是按值傳遞,因此會將變數i的值複製給實參num,在匿名函數的內部又建立了一個用於返回num的匿名函數,這樣每個函數都有了一個num的副本,互不影響了。
閉包中的this
在閉包中使用this時要特別注意,稍微不慎可能會引起問題。通常我們理解this對象是運行時基於函數綁定的,全域函數中this對象就是window對象,而當函數作為對象中的一個方法調用時,this等於這個對象(TODO 關於this做一次整理)。由於匿名函數的範圍是全域性的,因此閉包的this通常指向全域對象window:
| 1 2 3 4 5 6 7 8 9 |
var scope = "global"; var object = { scope:"local", getScope:function(){ return function(){ return this.scope; } } } |
調用object.getScope()()傳回值為global而不是我們預期的local,前面我們說過閉包中內部匿名函數會攜帶外部函數的範圍,那為什麼沒有取得外部函數的this呢?每個函數在被調用時,都會自動建立this和arguments,內部匿名函數在尋找時,搜尋到活躍對象中存在我們想要的變數,因此停止向外部函數中的尋找,也就永遠不可能直接存取外部函數中的變數了。總之,在閉包中函數作為某個對象的方法調用時,要特別注意,該方法內部匿名函數的this指向的是全域變數。
幸運的是我們可以很簡單的解決這個問題,只需要把外部函數範圍的this存放到一個閉包能訪問的變數裡面即可:
| 1 2 3 4 5 6 7 8 9 10 11 |
var scope = "global"; var object = { scope:"local", getScope:function(){ var that = this; return function(){ return that.scope; } } } object.getScope()()傳回值為local。 |
記憶體與效能
由於閉包中包含與函數運行期上下文相同的範圍鏈引用,因此,會產生一定的負面作用,當函數中活躍對象和運行期上下文銷毀時,由於必要仍存在對活躍對象的引用,導致活躍對象無法銷毀,這意味著閉包比普通函數佔用更多的記憶體空間,在IE瀏覽器下還可能會導致記憶體流失的問題,如下:
| 1 2 3 4 5 6 |
function bindEvent(){ var target = document.getElementById("elem"); target.onclick = function(){ console.log(target.name); } } |
上面例子中匿名函數對外部對象target產生一個引用,只要是匿名函數存在,這個引用就不會消失,外部函數的target對象也不會被銷毀,這就產生了一個循環參考。解決方案是通過建立target.name副本減少對外部變數的循環參考以及手動重設對象:
| 1 2 3 4 5 6 7 8 |
function bindEvent(){ var target = document.getElementById("elem"); var name = target.name; target.onclick = function(){ console.log(name); } target = null; } |
閉包中如果存在對外部變數的訪問,無疑增加了標識符的尋找路徑,在一定的情況下,這也會造成效能方面的損失。解決此類問題的辦法我們前面也曾提到過:盡量將外部變數存入到局部變數中,減少範圍鏈的尋找長度。
總結:閉包不是javascript專屬的特性,但是在javascript中有其獨特的表現形式,使用閉包我們可以在javascript中定義一些私人變數,甚至模仿出塊級範圍,但閉包在使用過程中,存在的問題我們也需要瞭解,這樣才能避免不必要問題的出現。