接上一篇,希望能寫一個高性能Javascript專題。
第一篇:高性能Javascript--腳本的無阻塞加載政策。
參考摘錄《高性能Javascript》。
經典計算機科學的一個問題是,資料應當存放在什麼地方,以實作最佳的讀寫效率。資料存儲是否得當,關系到代碼運作期間資料被檢索到的速度。在Javascript中,此問題相對簡單,因為資料表現方式隻有少量方式可供選擇。在Javascript中,有四種基本的資料通路位置:
- Literal values 直接量
- 直接量僅僅代表自己,而不存儲于特定的位置。
- Javascript的直接量包括:字元串(strings)、數字(numbers)、布爾值(booleans)、對象(objects)、數組(arrays)、函數(functions)、正規表達式(regular expressions),具有特殊意義的空值(null),以及未定義(undefined)。
- Variables 變量
- 開發人員用var關鍵字建立用于存儲資料值。
- Array items 數組項
- 具有數字索引,存儲一個Javascript數組對象。
- Object members 對象成員
- 具有字元串索引,存儲一個Javascript對象。
每一種資料存儲位置都具有特定的讀寫操作負擔。在大多數情況下,對一個直接量和一個局部變量的資料通路的性能差異是微不足道的。具體而言,通路數組項和對象成員的代價要高一些,具體高多少,很大程度上取決于浏覽器。一般的建議是,如果關心運作速度,那麼盡量使用直接量和局部變量,限制數組項和對象成員的使用。為此,有如下幾種模式,用于避免并優化我們的代碼:
Managing Scope 管理作用域
作用域概念是了解Javascript的關鍵,無論是從性能還是功能的角度而言,作用域對Javascript有着巨大影響。要了解運作速度與作用域的關系,首先要了解作用域的工作原理。
Scope Chains and Identifier Resolution 作用域鍊和辨別符解析
每一個Javascript函數都被表示為對象,它是一個函數執行個體。它包含我們程式設計定義的可通路屬性,和一系列不能被程式通路,僅供Javascript引擎使用的内部屬性,其中一個内部屬性是[[Scope]],由ECMA-262标準第三版定義。
内部[[Scope]]屬性包含一個函數被建立的作用域中對象的集合。此集合被稱為函數的作用域鍊,它決定哪些資料可以由函數通路。此函數中作用域鍊中每個對象被稱為一個可變對象,以“鍵值對”表示。當一個函數建立以後,它的作用域鍊被填充以對象,這些對象代表建立此函數的環境中可通路的資料:
1 function add(num1, num2){
2 var sum = num1 + num2;
3 return sum;
4 }
當add()函數建立以後,它的作用域鍊中填入了一個單獨可變對象,此全局對象代表了所有全局範圍定義的變量。此全局對象包含諸如視窗、浏覽器和文檔之類的通路接口。如下圖所示:(add()函數的作用域鍊,注意這裡隻畫出全局變量中很少的一部分)
add函數的作用域鍊将會在運作時用到,假設運作了如下代碼:
1 var total = add(5,10);
運作此add函數時會建立一個内部對象,稱作“運作期上下文”(execution context),一個運作期上下文定義了一個函數運作時的環境。且對于單獨的每次運作而言,每個運作期上下文都是獨立的,多次調用就會産生多此建立。而當函數執行完畢,運作期上下文被銷毀。
一個運作期上下文有自己的作用域鍊,用于解析辨別符。當運作期上下文被建立的時,它的作用域被初始化,連同運作函數的作用域鍊[[Scope]]屬性所包含的對象。這些值按照它們出現在函數中的順序,被複制到運作期上下文的作用域鍊中。這項工作一旦執行完畢,一個被稱作“激活對象”的新對象就位運作期上下文建立好了。此激活對象作為函數執行期一個可變對象,包含了通路所有局部變量,命名參數,參數集合和this的接口。然後,此對象被推入到作用域鍊的最前端。當作用域鍊被銷毀時,激活對象也一同被銷毀。如下所示:(運作add()時的作用域鍊)
在函數運作的過程中,每遇到一個變量,就要進行辨別符識别。辨別符識别這個過程要決定從哪裡獲得資料或者存取資料。此過程搜尋運作期上下文的作用域鍊,查找同名的辨別符。搜尋工作從運作函數的激活目标的作用域前端開始。如果找到了,就使用這個具有指定辨別符的變量;如果沒找到,搜尋工作将進入作用域鍊的下一個對象,此過程持續運作,直到辨別符被找到或者沒有更多可用對象可用于搜尋,這種情況視為辨別符未定義。正是這種搜尋過程影響了性能。
Identifier Resolution Performance 辨別符識别性能
辨別符識别是耗能的。
在運作期上下文的作用域鍊中,一個辨別符所處的位置越深,它的讀寫速度就越慢。是以,函數中局部變量的通路速度總是最快的,而全局變量通常是最慢的(優化Javascript引擎,如Safari在某些情況下可用改變這種情況)。
請記住,全局變量總是處于運作期上下文作用域鍊的最後一個位置,是以總是最遠才能被通路的。一個好的經驗法則是:使用局部變量存儲本地範圍之外的變量值,如果它們在函數中的使用多于一次。考慮下面的例子:
1 function initUI(){
2 var bd = document.body,
3 links = document.getElementsByTagName("a"),
4 i = 0,
5 len = links.length;
6
7 while(i < len){
8 update(links[i++]);
9 }
10
11 document.getElementById("go-btn").onclick = function(){
12 start();
13 };
14
15 bd.className = "active";
16 }
此函數包含三個對document的引用,而document是一個全局對象。搜尋至document,必須周遊整個作用域鍊,直到最後才能找到它。使用下面的方法減輕重複的全局變量通路對性能的影響:
1 function initUI(){
2 var doc=document,
3 bd = doc.body,
4 links = doc.getElementsByTagName("a"),
5 i = 0,
6 len = links.length;
7
8 while(i < len){
9 update(links[i++]);
10 }
11
12 doc.getElementById("go-btn").onclick = function(){
13 start();
14 };
15
16 bd.className = "active";
17 }
用doc代替document更快,因為它是一個局部變量。當然,這個簡單的函數不會顯示出巨大的性能改進,因為數量的原因,不過可以想象一下,如果幾十個全部變量反複被通路,那麼性能改進将顯得多麼出色。
Scope Chain Augmentation 改變作用域鍊
一個來說,一個運作期上下文的作用域鍊不會被改變。但是,有兩種表達式可以在運作時臨時改變運作期上下文。第一個是with表達式:
1 function initUI(){
2 with (document){ //avoid!
3 var bd = body,
4 links = getElementsByTagName("a"),
5 i = 0,
6 len = links.length;
7
8 while(i < len){
9 update(links[i++]);
10 }
11
12 getElementById("go-btn").onclick = function(){
13 start();
14 };
15
16 bd.className = "active";
17 }
18 }
此重寫版本使用了一個with表達式,避免了多次書寫“document”。這看起來似乎更有效率,實際不然,這裡産生了一個性能問題。
當代碼流執行到一個with表達式,運作期上下文的作用域被臨時改變了。一個新的可變對象将被建立,它包含了指定對象(針對這個例題是document對象)的所有屬性。此對象被插入到作用域鍊的最前端。意味着現在函數的所有局部變量都被推入到第二個作用域鍊對象中,是以局部變量的通路代價變的更高了。
正式因為這個原因,最好不要使用with表達式。這樣會得不償失。正如前面提到的,隻要簡單的将document存儲在一個局部變量中,就可以獲得性能上的提升。
另一個能改變運作期上下文的是try-catch語句的字句catch具有同樣的效果。當try塊發生錯誤的時,程式自動轉入catch塊,并将所有局部變量推入第二個作用域鍊對象中,隻要catch之塊執行完畢,作用域鍊就會傳回到原來的狀态。
1 try {
2 methodThatMightCauseAnError();
3 } catch (ex){
4 alert(ex.message); //作用域鍊在這裡發生改變
5 }
如果使用得當,try-catch表達式是非常有用的語句,是以不建議完全避免。但是一個try-catch語句不應該作為Javascript錯誤解決的辦法,如果你知道一個錯誤會經常發生,那麼說明應該修改代碼本身。不是麼?
Dynamic Scope 動态作用域
無論是with表達式還是try-catch表達式的子句catch,以及包含()的函數,都被認為是動态作用域。一個動态作用域因代碼運作而生成存在,是以無法通過靜态分析(通過檢視代碼)來确定是否存在動态作用域。例如:
function execute(code) {
(code);
function subroutine(){
return window;
}
var w = subroutine(); // w的值是什麼?
};
execute()函數看上去像一個動态作用域,因為它使用了()。w變量的值與傳入的code代碼有關。大多數情況下,w将等價于全局的window對象。但是如果傳入的是:
1 execute("var window = {};");
這種情況下,()在execute()函數中建立了一個局部window變量。是以w将等價于這個局部window變量而不是全局window的那個。是以不運作這段代碼是無法預知最後的具體情況,辨別符window的确切含義無法預先知道。
是以,隻有在絕對必要時刻才推薦使用動态作用域。
Closure,Scope,and Memory 閉包,作用域,和記憶體
閉包是Javascript最強大的一個方面,它允許函數通路局部範圍之外的的資料。為了解與閉包有關的性能問題,考慮下面的例子:
1 function assignEvents(){
2 var id = "xdi9592";
3 document.getElementById("save-btn").onclick = function(event){
4 saveDocument(id);
5 };
6 }
assignEvents()函數為DOM元素指定了一個事件處理句柄。此事件處理是一個閉包,當函數執行建立時可以通路其範圍内部的id變量。而這種方法封閉了對id變量的通路,必須建立一個特定的作用域鍊。
當assignEvents()函數執行時,一個激活對象被建立,并且包含了一些應有的内容,其中包含id變量。它将成為運作期上下文作用域鍊上的第一個對象,全局對象是第二個。當閉包建立的時,[[Scope]]屬性與這些對象一起被初始化,如下圖:
由于閉包的[[Scope]]屬性包含與運作期上下文作用域鍊相同的對象引用,會産生副作用,通常,一個函數的激活對象與運作期上下文一同銷毀。當涉及閉包時,激活對象就無法銷毀了,因為仍然存在于閉包的[[Scope]]屬性中。這意味着腳本中的閉包與非閉包函數相比,需要更多的記憶體開銷。尤其在IE,使用非本地Javascript對象實作DOM對象,閉包可能導緻記憶體洩露。
當閉包被執行,一個運作期上下文将被建立,它的作用域鍊與[[Scope]]中引用的兩個相同的作用域鍊同時被初始化,然後一個新的激活對象為閉包自身建立。如下圖:
可以看到,id和saveDocument兩個辨別符存在于作用域鍊第一個對象之後的位置。這是閉包最主要的性能關注點:你經常通路一些範圍之外的辨別符,每次通路都将導緻一些性能損失。
在腳本中最好小心的使用閉包,記憶體和運作速度都值得被關注。但是,你可以通過上文談到的,将常用的域外變量存入局部變量中,然後直接通路局部變量。
Object Members 對象成員
對象成員包括屬性和方法,在Javascript中,二者差别甚微。對象的一個命名成員可以包含任何資料類型。既然函數也是一種對象,那麼對象成員除了傳統資料類型外,也可以包含函數。當一個命名成員引用了一個函數時,它被稱作一個“方法”,而一個非函數類型的資料則被稱作“屬性”。
如前所言,對象成員的通路比直接量和局部變量通路速度慢,在某些浏覽器上比通路數組還慢,這與Javascript中對象的性質有關。
Prototype 原型
Javascript中的對象是基于原型的,一個對象通過内部屬性綁定到它的原型。Firefox,Safari和Chrome向開發人員開放這一屬性,稱作_proto_。其他浏覽器不允許腳本通路這個屬性。任何時候我們建立一個内置類型的實作,如Object或Array,這些執行個體自動擁有一個Object作為它們的原型。而對象可以有兩種類型的成員:執行個體成員和原型成員。執行個體成員直接存在于執行個體自身而原型成員則從對象繼承。考慮如下例子:
1 var book = {
2 title: "High Performance JavaScript",
3 publisher: "Yahoo! Press"
4 };
5 alert(book.toString()); //"[object Object]"
此代碼中book有title和publisher兩個執行個體成員。注意它并沒有定義toString()接口,但這個接口卻被調用且沒有抛出錯誤。toString()函數就是一個book繼承自原型對象的原型成員。下圖表示了它們的關系:
處理對象成員的過程與處理變量十分相似。當book.toString()被調用時,對成員進行名為“toString”的搜尋,首先從對象執行個體開始,若果沒有名為toString的成員,那麼就轉向搜尋原型對象,在那裡發現了toString()方法并執行它。通過這種方法,book可以通路它的原型所擁有的每個屬性和方法。
我們可以使用hasOwnProperty()函數确定一個對象是否具有特定名稱的執行個體成員。執行個體略。
Prototype Chains 原型鍊
對象的原型決定了一個執行個體的類型。預設情況下,所有對象都是Object的執行個體,并繼承了所有基本方法。如toString()。我們也可以使用構造器建立另外一種原型。例如:
1 function Book(title, publisher){
2 this.title = title;
3 this.publisher = publisher;
4 }
5
6 Book.prototype.sayTitle = function(){
7 alert(this.title);
8 };
9 var book1 = new Book("High Performance JavaScript", "Prototype Chains");
10 var book2 = new Book("JavaScript: The Good Parts", "Prototype Chains");
11 alert(book1 instanceof Book); //true
12 alert(book1 instanceof Object); //true
13 book1.sayTitle(); //"High Performance JavaScript"
14 alert(book1.toString()); //"[object Object]"
Book構造器用于建立一個新的book執行個體book1。book1的原型(_proto_)是Book.prototype,Book.prototype的原型是Object。這就建立了一條原型鍊。
注意,book1和book2共享了同一個原型鍊。每個執行個體擁有自己的title和publisher屬性,其他成員均繼承自原型。而正如你所懷疑的那樣,深入原型鍊越深,搜尋的速度就會越慢,特别是IE,每深入原型鍊一層都會增加性能損失。記住,搜尋執行個體成員的過程比通路直接量和局部變量負擔更重,是以增加周遊原型鍊的開銷正好放大了這種效果。
Nested Members 嵌套成員
由于對象成員可能包含其他成員。譬如window.location.href(擷取目前頁面的url)這種模式。每遇到一個點号(.),Javascript引擎就要在對象成員上執行一次解析過程,而且成員嵌套越深,通路速度越慢。location.href總是快于window.location.href,而後者比window.location.href.toString()更快。如果這些屬性不是對象的執行個體成員,那麼成員解析還要在每個點上搜尋原型鍊,這将需要更多的時間。
Summary 總結
- 在Javascript中,資料存儲位置可以對代碼整體性能産生重要影響。有四種資料通路類型:直接量,變量,數組項,對象成員。對它們我們有不同的性能考慮。
- 直接量和局部變量的通路速度非常快,而數組項和對象成員需要更長時間。
- 局部變量比外部變量快,是因為它位于作用域鍊的第一個對象中。變量在作用域鍊中的位置越深,通路所需的時間就越長。而全局變量總是最慢的,因為它處于作用域鍊的最後一環。
- 避免使用with表達式,因為它改變了運作期上下文的作用域鍊。而且應當特别小心對待try-catch語句的catch子句,它具有同樣的效果。
- 嵌套對象成員會造成重大性能影響,盡量少用。
- 一般而言,我們通過将經常使用的對象成員,數組項,和域外變量存入局部變量中。然後,通路局部變量的速度會快于那些原始變量。
通過上述政策,可以極大提高那些使用Javascript代碼的網頁應用的實際性能。