今天終於是把這本書看完了,每一章都有不小的收穫,之後有時間的話會陸續整理出每一章的筆記,^_^
言歸正傳,這一章講到的是如何從資料訪問層面上提高JS 代碼的執行效率。總的來講有以下幾條原則:
- 函數中讀寫局部變數總是最快的,而全域變數的讀取則是最慢的;
- 儘可能地少用with 語句,因為它會增加with 語句以外的資料的訪問代價;
- 閉包儘管強大,但不可濫用,否則會影響到執行速度以及記憶體;
- 嵌套的對象成員會明顯影響效能,盡量少用;
- 避免多次訪問對象成員或函數中的全域變數,盡量將它們賦值給局部變數以緩衝。
這麼幾句話看似簡單,但要深刻理解其中的道理則需涉及到JS的 標識符解析、範圍鏈、運行期上下文(又稱為執行環境)、原型鏈、閉包 等一系列概念,之前我有看過一篇網上翻譯的 JavaScript 閉包,文中講解了這些東東,但幾遍下來還是似懂非懂。然而本書則是圖文並茂,很好理解,不由的感慨一下,牛人就是牛~
範圍鏈和標識符解析
每一個JS 函數都表示為一個對象,該對象有一個內部屬性[[Scope]],它包含了一個函數被建立的範圍中對象的集合,這個集合被稱為函數的範圍鏈(Scope chain),它決定哪些資料能被函數訪問。函數範圍中的每個對象被稱為一個可變對象(variable object)。當一個函數建立後,它的範圍鏈會被建立此函數的範圍中可訪問的資料對象所填充。
例如下面這個全域函數:
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
由於此函數是在全域範圍下建立的,所以函數add() 的範圍鏈中只填入了一個單獨的可變對象——全域對象:
而執行此函數時則會建立一個稱為“運行期上下文(execution context)”的內部對象,它定義了函數執行時的環境。函數每次執行時對應的運行期上下文都是獨一無二的,所以多次調用同一個函數就會導致建立多個運行期上下文。當函數執行完畢,執行內容就被銷毀。
而我們剛剛講的函數範圍鏈和這個運行期上下文有什麼關係呢?是這樣的,每個運行期上下文都有自己的範圍鏈,用於標識符解析,而它的範圍鏈引用的則是函數的內部對象[[Scope]] 所指向的範圍鏈。此外還會建立一個“使用中的物件(Activation object)”,該對象包含了函數的所有局部變數、具名引數、參數集合以及this,當函數執行時它又被當作可變對象,和前面是一回事。然後此對象會被推入範圍鏈的前端,就這樣運行期上下文也就建立好了,當運行期上下文被銷毀,使用中的物件也隨之銷毀(閉包除外,後面會講到)。
例如前面add() 函數運行時對應的運行期上下文和範圍鏈:
函數執行時,每遇到一個變數都會進行一次標識符解析的過程,該過程從頭至尾搜尋範圍鏈,從當前運行函數的使用中的物件到全域對象,直至尋找到同名的標識符,如果沒找到則被認為是undefined。
標識符解析的效能
標識符解析是有代價的,在運行期內容相關的作用鏈中,一個標識符所在的位置越深,它的讀寫速度也就越慢,顯然對局部變數的讀寫是最快的,因為它所在的對象處於作用鏈的最前端。一個好的經驗法則是:如果某個跨範圍的值在函數中被引用一次以上,那麼就把它儲存到局部變數裡。
此外代碼執行時臨時改變範圍鏈也會影響標識符解析的效能,有兩個語句會造成這種情況——with 和catch,這兩條語句被執行時都會將一個新的可變對象(with 的是語句中指定的對象、catch 的是異常對象)推入範圍鏈的頭部,這樣原有的可訪問對象都被往後推了一個層次,這使得它們的訪問代價更高了。因此對於with 語句最好避免使用,catch 語句要用的話可以定義一函數來進行錯誤的處理以減少catch 內的語句數量。
便是一個在with 語句中改變後的範圍鏈的例子:
閉包、範圍和記憶體
理解了範圍鏈之後,閉包也就好懂了。閉包是JavaScript 最強大的特性之一,它允許函數訪問局部範圍之外的資料。然而,有一種效能問題與閉包有關,思考如下代碼:
function assignEvents() {
var id = 'xdi9592';
document.getElementById('save-btn').onclick = function(event) {
saveDocument(id);
}
}
函數內部的onclick 事件處理器就是一個閉包,為了能讓該閉包訪問函數assignEvents() 內的資料,閉包被建立時,它的[[Scope]] 屬性被初始化為其外部函數運行時的範圍鏈中的對象,即閉包的[[Scope]] 屬性包含了與運行期上下文範圍相同的對象的引用。
為函數assignEvents() 運行期內容相關的範圍鏈和閉包:
通常來說,函數的使用中的物件會隨同運行期上下文一同銷毀。但引入閉包時,由於引用仍然存在於閉包的[[Scope]] 屬性中,因此啟用物件無法被銷毀。這意味著指令碼中的閉包與非閉包函數相比,需要更多的記憶體開銷。
而當閉包被執行時,一個使用中的物件會為閉包自身所建立並被置於範圍鏈的最前端:
此時對於閉包外資料(如id、saveDocument 等) 的訪問開銷就更大了,因為它們在範圍鏈的位置均被推後了一個層次。這就是使用閉包最主要的效能關注點:你要經常訪問大量跨範圍的標識符,每次訪問都會導致效能損失。
對象成員解析、原型、原型鏈
對象成員指的是對象的屬性或方法,當我們要訪問TestObj.abc 這樣一個對象成員時,首先在標識符解析的過程中找到了TestObj 對象,接下來要訪問abc 屬性(或是方法)則要進行對象成員解析的過程了。
JavaScript 中的對象是基於原型的,原型是其他對象的基礎,它定義並實現一個新對象必須包含的成員列表。對象通過一個內部屬性__proto__(這個屬性在Firefox、Safari 和Chrome 中對開發人員可見) 綁定到它的原型。
對象可以有兩種成員類型:執行個體成員和原型成員。執行個體成員存在於對象執行個體中,原型成員則由對象原型繼承而來。一旦建立了一個內建對象(如Object 和Array) 的執行個體,它們就會自動擁有一個Object 執行個體作為原型,如下面的代碼:
var book = {
title : 'High Performance JavaScript',
publisher : 'Yahoo! Press'
}
alert(book.toString()); //"[object Object]"
這個例子中book 並沒有定義toString() 方法,然而卻能被順利執行,原因是方法toString() 是由對象book 繼承而來的原型成員:
注意book 原型中也有__proto__ 屬性,前面也說到了JavaScript 的對象是基於原型的,既然每個對象都具有原型,這自然便形成了一個“鏈”,我們稱之為原型鏈,原型鏈終止於原型為null 的那個對象上。而對象成員的解析實際上就是原型鏈的遍曆過程,從執行個體成員開始尋找到原型成員。
此外也可以定義並使用構造器來建立另一種類型的原型,這樣則插入了新定義的原型對象至原型鏈中,考慮下面這個例子:
function Book(title, publisher) {
this.title = title;
this.publisher = publisher;
}
Book.prototype.sayTitle = function() {
alert(this.title);
}
var book1 = new Book('High Performance JavaScript', 'Yahoo! Press');
var book2 = new Book('JavaScript: The Good Parts', 'Yahoo! Press');
這裡為Book 對象手動建立了一個原型,並定義了一個方法sayTitle(),對於執行個體book1 來說原型鏈是這樣的:book1 的原型 -> Book.prototype, Book.prototype 的原型 -> Object, Object 的原型 -> null。
當要訪問一個book1 中的一個成員時,檢索其原型鏈的過程則是:首先尋找book1 的執行個體成員(title, publisher),若沒找到則接著尋找book1 的原型Book.prototype 中的原型成員(sayTitle),若還沒到則繼續尋找Book.prototype 的原型Object 的原型成員(toString, valueOf ...),若仍然沒找到,則繼續尋找Object 的原型,但Object 的原型為null,則尋找終止,此時該成員判定為undefined,若該過程中有尋找到的話則立即中止尋找並返回。原型鏈的關係
對象成員解析的效能
和標識符解析一樣,對象成員的解析也是有開銷的,原型鏈的遍曆過程中,每深入一層都會增加效能的損失,於是對象在原型鏈中存在的位置越深,找到它就越慢。
另外由於對象成員可能包含其他成員,例如window.location.href,每次遇到點操作符,該嵌套成員都會導致JavaScript 引擎搜尋所有對象成員,顯然對象成員嵌套得越深,訪問速度就會越慢,因此盡量少用,例如執行location.href 總是要比window.location.href 要快。
很顯然,當要頻繁地訪問對象成員時,最好用變數將它們緩衝起來。