天天看點

高性能Javascript--高效的資料通路

  接上一篇,希望能寫一個高性能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()函數的作用域鍊,注意這裡隻畫出全局變量中很少的一部分)

高性能Javascript--高效的資料通路

  add函數的作用域鍊将會在運作時用到,假設運作了如下代碼:

1 var total = add(5,10);      

  運作此add函數時會建立一個内部對象,稱作“運作期上下文”(execution context),一個運作期上下文定義了一個函數運作時的環境。且對于單獨的每次運作而言,每個運作期上下文都是獨立的,多次調用就會産生多此建立。而當函數執行完畢,運作期上下文被銷毀。

  一個運作期上下文有自己的作用域鍊,用于解析辨別符。當運作期上下文被建立的時,它的作用域被初始化,連同運作函數的作用域鍊[[Scope]]屬性所包含的對象。這些值按照它們出現在函數中的順序,被複制到運作期上下文的作用域鍊中。這項工作一旦執行完畢,一個被稱作“激活對象”的新對象就位運作期上下文建立好了。此激活對象作為函數執行期一個可變對象,包含了通路所有局部變量,命名參數,參數集合和this的接口。然後,此對象被推入到作用域鍊的最前端。當作用域鍊被銷毀時,激活對象也一同被銷毀。如下所示:(運作add()時的作用域鍊)

高性能Javascript--高效的資料通路

  在函數運作的過程中,每遇到一個變量,就要進行辨別符識别。辨別符識别這個過程要決定從哪裡獲得資料或者存取資料。此過程搜尋運作期上下文的作用域鍊,查找同名的辨別符。搜尋工作從運作函數的激活目标的作用域前端開始。如果找到了,就使用這個具有指定辨別符的變量;如果沒找到,搜尋工作将進入作用域鍊的下一個對象,此過程持續運作,直到辨別符被找到或者沒有更多可用對象可用于搜尋,這種情況視為辨別符未定義。正是這種搜尋過程影響了性能。

  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]]屬性與這些對象一起被初始化,如下圖:

高性能Javascript--高效的資料通路

  由于閉包的[[Scope]]屬性包含與運作期上下文作用域鍊相同的對象引用,會産生副作用,通常,一個函數的激活對象與運作期上下文一同銷毀。當涉及閉包時,激活對象就無法銷毀了,因為仍然存在于閉包的[[Scope]]屬性中。這意味着腳本中的閉包與非閉包函數相比,需要更多的記憶體開銷。尤其在IE,使用非本地Javascript對象實作DOM對象,閉包可能導緻記憶體洩露。

  當閉包被執行,一個運作期上下文将被建立,它的作用域鍊與[[Scope]]中引用的兩個相同的作用域鍊同時被初始化,然後一個新的激活對象為閉包自身建立。如下圖:

高性能Javascript--高效的資料通路

  可以看到,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繼承自原型對象的原型成員。下圖表示了它們的關系:

高性能Javascript--高效的資料通路

  處理對象成員的過程與處理變量十分相似。當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代碼的網頁應用的實際性能。

繼續閱讀