這可能是每一個jser都曾經為之頭疼的卻又非常經典的問題,關係到記憶體,關係到閉包,關係到javascript運行機制。關係到功能,關係到效能。
文章內容主要參考自《High Performance JavaScript》,這本書對javascript效能方面確實講的比較深入,大家有空都可以嘗試著閱讀一下,:中英電子版。
複習,筆記,更深入的理解。
歡迎拍磚指正。
範圍:
下面我們先搞明白這樣幾個概念:
- 函數對象的[[scope]]屬性、ScopeChain(範圍鏈)
- Execution Context(運行期上下文)、Activation Object(啟用物件)
[[scope]]屬性:
javascript中每個函數都是一個函數對象(函數執行個體),既然是對象,就有相關的屬性和方法。[[scope]]就是每個函數對象都具有的一個僅供javascript引擎內部使用的屬性,該屬性是一個集合(類似於鏈表結構),集合中儲存了該函數在被建立時的範圍中的所有對象,而這個範圍集合形成的鏈表則被稱為ScopeChain(範圍鏈)。
該範圍鏈中儲存的範圍對象,就是該函數可以訪問的所有資料。例如(例子引用自《High Performance JavaScript高效能javascript》):
function add(num1, num2){
var sum = num1 + num2;
return sum;
}
圖 1
當add函數被建立時,函數所在的全域範圍的全域對象被放置到add函數的範圍鏈([[scope]]屬性)中。我們可以從圖1中看到範圍鏈的第一個對象儲存的是全域對象,全域對象中儲存了諸如this,window,document以及全域對象中的add函數,也就是他自己。這也就是我們可以在全域範圍下的函數中訪問window(this),訪問全域變數,訪問函數自身的原因。當然還有函數範圍不是全域的情況,等會兒我們再討論。
Execution Context(運行期上下文)、Activation Object(啟用物件):
(前天看了老羅的演講,老羅說過年的時候給全公司的人每人發一台電冰箱,要給校舍的所有的廁所門上都安上新鎖,保證童鞋們能有個真正隱私的地方。)
var total = add(5, 10);
當開始執行此函數時,就會建立一個Execution Context的內部對象,該對象定義了函數運行時的範圍環境(注意這裡要和函數建立時的範圍鏈對象[[scope]]區分,這是兩個不同的範圍鏈對象,這樣分開我猜測一是為了保護[[scope]],二是為了方便根據不同的運行時環境控製作用域鏈。函數每執行一次,都會建立單獨的Execution Context,也就相當於每次執行函數前,都把函數的範圍鏈複製了一份到當前的Execution Context中)。Execution Context對象有自己的範圍鏈,在Execution Context建立時初始化,會將函數建立時的範圍鏈對象[[scope]]中的全部內容按照在[[scope]]範圍鏈中的順序複製到Execution Context的範圍鏈中。
此時,在Execution Context的範圍鏈的頂部會插入一個新的對象,叫做Activation Object(啟用物件),這個啟用物件又是幹嘛的呢?這個啟用物件儲存了函數中的所有形參,實參,局部變數,this指標等函數執行時函數內部的資料情況,這個Activation Object是一個可變對象,裡面的資料隨著函數執行時的資料的變化而變化,當函數執行結束之後,就會銷毀Execution Context,也就會銷毀Execution Context的範圍鏈,當然也就會銷毀Activation Object(但如果存在閉包,Activation Object就會以另外一種方式存在,這也是閉包產生的真正原因,具體的我們稍後討論。)。具體情況:
圖 2
我們從左往右看,第一部分是函數執行時建立的Execution Context,它有自己的範圍鏈,第二部分是範圍鏈中的對象,索引為1的對象是從[[scope]]範圍鏈中複製過來的,索引為0的對象是在函數執行時建立的,第三部分是範圍鏈中的對象的內容Activation Object和Global Object。
函數在運行過程中,沒遇到一個變數,都會去Execution Context的範圍鏈中從上到下依次搜尋,如果在第一個範圍鏈(假如是Activation Object)中找到了,那麼就返回這個變數,如果沒有找到,那麼繼續向下尋找,直到找到為止,這也就是為什麼函數可以訪問全域變數,當局部變數和全域變數同名時,會使用局部變數而不使用全域變數,以及javascript中各種看似怪異的、有趣的範圍問題的答案(你可以用這種方法來解釋你以前碰到的所有範圍問題,當然,如果還是有疑問的話,非常希望你能貼出代碼,我們一起討論。)
一般情況下,一個函數的範圍鏈是不會在函數運行時被改變的,但有些運算子會臨時改變範圍鏈,with和try catch的catch子句。看下面的例子:
function initUI(){
with (document){ //avoid!
var bd = body,
links = getElementsByTagName("a"),
i= 0,
len = links.length;
while(i < len){
update(links[i++]);
}
getElementById("go-btn").onclick = function(){
start();
};
bd.className = "active";
}//eOf with
}
上例中,在onclick事件的事件處理器中引用了外部函數assignEvents的局部變數id,形成了閉包,下面我們看一下它們的範圍圖示:
圖 4
我們一起來從範圍的角度分析一下閉包的形成過程:
- assignEvents函數建立,詞法解析後,函數對象assignEvents的[[scope]]屬性被初始化,範圍鏈形成,範圍鏈中包含了全域對象的所有屬性和方法(注意,此時因為assignEvents函數並未執行,所以閉包函數並沒有被解析)。
- assignEvents函數執行,在開始執行時,建立Execution Context(我們將圖4按照從左至右,從上到下的順序劃分為6部分,第一部分就是運行期上下文),在運行期內容相關的範圍鏈中建立Activation Object(第二、三部分),並將Activation Object放置與範圍鏈頂點,在其中儲存了函數執行時所有可訪問函數內部的資料。
- 當執行到閉包時,javascript引擎發現了閉包函數的存在,按照通常的手法,將閉包函數解析,為閉包函數對象建立[[scope]]屬性,初始化範圍鏈(此時閉包函數對象的範圍鏈中有兩個對象,一個是assignEvents函數執行時的Activation Object,還有一個是全域對象,圖4的4、5、6部分。)。我們看到圖中閉包函數對象的範圍鏈和assignEvents函數的執行內容範圍鏈相同?為什麼相同呢?我們來分析一下,閉包函數是在assignEvents函數執行的過程中被發現並且解析的,而函數執行時的範圍是Activation Object,那麼結果就很明顯了,閉包函數被解析的時候它的範圍正是assignEvents範圍鏈中的第一個範圍對象Activation Object,當然,由於範圍鏈的關係,全域對象範圍也被引入到閉包函數的範圍鏈中。 那麼我們現在考慮另一個問題,閉包範圍鏈中的Activation Object,是引用了assignEvents函數的Activation Object,還是拷貝了一個副本到閉包的範圍鏈中了?我們可以做一個小的測試,在有多個閉包同時引用外層函數局部變數(i)的情況下,如果其中一個閉包改變了i的內容,而其他閉包中的i的內容沒有發生改變,則說明產生了拷貝,反之,則引用了同一個Activation Object。
function fn(){ var i = 0;
(function(){++i;console.log(i)})(); (function(){++i;console.log(i)})();}
fn();
//1
//2
- 我們發現變數i從1變為了2,說明兩個閉包引用的是同一個變數i,也就說明他們引用的fn的Activation Object是同一個,其實完全可以換一種非常簡單的方式來解釋:全域對象肯定是同一個吧?
- 下面討論當閉包函數執行時的情況,因為在詞法分析的時候閉包函數就已經在範圍鏈中儲存了對assignEvents函數的Activation Object的引用,所以當assignEvents函數執行完畢之後,閉包函數雖然還沒有開始執行,但依然可以訪問assignEvents的局部資料(並不是因為閉包函數要訪問assignEvents的局部變數id,所以當assignEvents函數執行完畢之後依然保持了對局部變數id的引用。而是不管是否存在變數引用,都會儲存對assignEvents的Activation Object範圍對象的引用。因為在詞法分析時,閉包函數沒有執行,函數內部根本就不知道是否要對assignEvents的局部變數進行訪問和操作,所以只能先把assignEvents的Activation Object範圍對象儲存起來,當閉包函數執行時,如果需要訪問assignEvents的局部變數,那麼再去範圍鏈中搜尋)。
- 閉包函數執行時建立了自己的Execution Context和Activation Object,在運行期內容相關的範圍鏈中儲存了自己的Activation Object,外層函數assignEvents的Execution Context的Activation Object,以及Global Object,
圖 5
這也就是閉包為何能“記得”在它周圍到底發生了什麼,為何閉包能訪問外層函數的局部資料,為何閉包能保持這些局部資料而不在外層函數執行完畢銷毀時一起銷毀等等的原因。
前些天一個前輩(Darrel文叔)告訴我一句話,一針見血:沒有記憶體,就沒有閉包。
效能問題:
在範圍鏈和閉包中的效能問題主要表現在資料讀寫的速度上。
由於範圍鏈的原因,我們訪問全域範圍的資料(這裡為什麼不說變數呢?因為不僅包括變數,還有函數,對象等其他內容)時,效率是最低的,而訪問局部資料時的效率是最高的。
所以一個非常經典的解決資料訪問效能問題的方案出現了:將需要訪問的資料盡量的以局部資料的方式緩衝起來。這樣當標識符解析程式在範圍鏈中尋找資料時,直接就可以在範圍鏈的最上層找到想要的資料,效率自然就提升了。
這句話可以解決很多效能問題:設定緩衝,將資料儲存在局部變數中。
轉載請註明出處:
參考:
- 《高效能javascript》——Zakas(劉新 譯)
- 李松峰《理解javascript閉包》
- 阮一峰《學習javascript閉包》
- 網路各種雜文