高效能Javascript:高效的資料訪問

edithfang發表於2014-11-18
在Javascript中,有四種基本的資料訪問位置:

1.Literal values 直接量

直接量僅僅代表自己,而不儲存於特定的位置。

Javascript的直接量包括:字串(strings)、數字(numbers)、布林值(booleans)、物件(objects)、陣列(arrays)、函式(functions)、正規表示式(regularexpressions),具有特殊意義的空值(null),以及未定義(undefined)。

2.Variables 變數

開發人員用var關鍵字建立用於儲存資料值。

3.Array items 陣列項

具有數字索引,儲存一個Javascript陣列物件。

4.Object members 物件成員

具有字串索引,儲存一個Javascript物件。

每一種資料儲存位置都具有特定的讀寫操作負擔。在大多數情況下,對一個直接量和一個區域性變數的資料訪問的效能差異是微不足道的。具體而言,訪問陣列項和物件成員的代價要高一些,具體高多少,很大程度上取決於瀏覽器。一般的建議是,如果關心執行速度,那麼儘量使用直接量和區域性變數,限制陣列項和物件成員的使用。為此,有如下幾種模式,用於避免並優化我們的程式碼:

Managing Scope 管理作用域

作用域概念是理解Javascript的關鍵,無論是從效能還是功能的角度而言,作用域對Javascript有著巨大影響。要理解執行速度與作用域的關係,首先要理解作用域的工作原理。

Scope Chains and Identifier Resolution 作用域鏈和識別符號解析

每一個Javascript函式都被表示為物件,它是一個函式例項。它包含我們程式設計定義的可訪問屬性,和一系列不能被程式訪問,僅供Javascript引擎使用的內部屬性,其中一個內部屬性是[[Scope]],由ECMA-262標準第三版定義。

內部[[Scope]]屬性包含一個函式被建立的作用域中物件的集合。此集合被稱為函式的作用域鏈,它決定哪些資料可以由函式訪問。此函式中作用域鏈中每個物件被稱為一個可變物件,以“鍵值對”表示。當一個函式建立以後,它的作用域鏈被填充以物件,這些物件代表建立此函式的環境中可訪問的資料:
function add(num1, num2){ 
  var sum = num1 + num2; 
    return sum;
}
當add()函式建立以後,它的作用域鏈中填入了一個單獨可變物件,此全域性物件代表了所有全域性範圍定義的變數。此全域性物件包含諸如視窗、瀏覽器和文件之類的訪問介面。如下圖所示:(add()函式的作用域鏈,注意這裡只畫出全域性變數中很少的一部分)


add函式的作用域鏈將會在執行時用到,假設執行了如下程式碼:
var total = add(5,10);
執行此add函式時會建立一個內部物件,稱作“執行期上下文”(executioncontext),一個執行期上下文定義了一個函式執行時的環境。且對於單獨的每次執行而言,每個執行期上下文都是獨立的,多次呼叫就會產生多此建立。而當函式執行完畢,執行期上下文被銷燬。

一個執行期上下文有自己的作用域鏈,用於解析識別符號。當執行期上下文被建立的時,它的作用域被初始化,連同執行函式的作用域鏈[[Scope]]屬性所包含的物件。這些值按照它們出現在函式中的順序,被複制到執行期上下文的作用域鏈中。這項工作一旦執行完畢,一個被稱作“啟用物件”的新物件就位執行期上下文建立好了。此啟用物件作為函式執行期一個可變物件,包含了訪問所有區域性變數,命名引數,引數集合和this的介面。然後,此物件被推入到作用域鏈的最前端。當作用域鏈被銷燬時,啟用物件也一同被銷燬。如下所示:(執行add()時的作用域鏈)


在函式執行的過程中,每遇到一個變數,就要進行識別符號識別。識別符號識別這個過程要決定從哪裡獲得資料或者存取資料。此過程搜尋執行期上下文的作用域鏈,查詢同名的識別符號。搜尋工作從執行函式的啟用目標的作用域前端開始。如果找到了,就使用這個具有指定識別符號的變數;如果沒找到,搜尋工作將進入作用域鏈的下一個物件,此過程持續執行,直到識別符號被找到或者沒有更多可用物件可用於搜尋,這種情況視為識別符號未定義。正是這種搜尋過程影響了效能。

Identifier Resolution Performance 識別符號識別效能

識別符號識別是耗能的。

在執行期上下文的作用域鏈中,一個識別符號所處的位置越深,它的讀寫速度就越慢。所以,函式中區域性變數的訪問速度總是最快的,而全域性變數通常是最慢的(優化Javascript引擎,如Safari在某些情況下可用改變這種情況)。

請記住,全域性變數總是處於執行期上下文作用域鏈的最後一個位置,所以總是最遠才能被訪問的。一個好的經驗法則是:使用區域性變數儲存本地範圍之外的變數值,如果它們在函式中的使用多於一次。考慮下面的例子:
function initUI(){
  var bd = document.body,
  links = document.getElementsByTagName("a"), 
  i = 0,
  len = links.length;
 
  while(i < len){
    update(links[i++]); 
  }
 
    document.getElementById("go-btn").onclick = function(){ 
    start();
  };
 
  bd.className = "active"; 
此函式包含三個對document的引用,而document是一個全域性物件。搜尋至document,必須遍歷整個作用域鏈,直到最後才能找到它。使用下面的方法減輕重複的全域性變數訪問對效能的影響:
function initUI(){
    var doc=document,
   bd = doc.body,
  links = doc.getElementsByTagName("a"), 
  i = 0,
  len = links.length;
 
  while(i < len){
    update(links[i++]); 
  }
 
     doc.getElementById("go-btn").onclick = function(){ 
    start();
  };
 
  bd.className = "active"; 
}
用doc代替document更快,因為它是一個區域性變數。當然,這個簡單的函式不會顯示出巨大的效能改進,因為數量的原因,不過可以想象一下,如果幾十個全部變數反覆被訪問,那麼效能改進將顯得多麼出色。

Scope Chain Augmentation 改變作用域鏈

一個來說,一個執行期上下文的作用域鏈不會被改變。但是,有兩種表示式可以在執行時臨時改變執行期上下文。第一個是with表示式:
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"; 
  }
}
此重寫版本使用了一個with表示式,避免了多次書寫“document”。這看起來似乎更有效率,實際不然,這裡產生了一個效能問題。

當程式碼流執行到一個with表示式,執行期上下文的作用域被臨時改變了。一個新的可變物件將被建立,它包含了指定物件(針對這個例題是document物件)的所有屬性。此物件被插入到作用域鏈的最前端。意味著現在函式的所有區域性變數都被推入到第二個作用域鏈物件中,所以區域性變數的訪問代價變的更高了。

正式因為這個原因,最好不要使用with表示式。這樣會得不償失。正如前面提到的,只要簡單的將document儲存在一個區域性變數中,就可以獲得效能上的提升。

另一個能改變執行期上下文的是try-catch語句的字句catch具有同樣的效果。當try塊發生錯誤的時,程式自動轉入catch塊,並將所有區域性變數推入第二個作用域鏈物件中,只要catch之塊執行完畢,作用域鏈就會返回到原來的狀態。
try { 
     methodThatMightCauseAnError();
} catch (ex){
     alert(ex.message); //作用域鏈在這裡發生改變
}
如果使用得當,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物件。但是如果傳入的是:
execute("var window = {};");
這種情況下,()在execute()函式中建立了一個區域性window變數。所以w將等價於這個區域性window變數而不是全域性window的那個。所以不執行這段程式碼是無法預知最後的具體情況,識別符號window的確切含義無法預先知道。

因此,只有在絕對必要時刻才推薦使用動態作用域。

Closure,Scope,and Memory 閉包,作用域,和記憶體

閉包是Javascript最強大的一個方面,它允許函式訪問區域性範圍之外的的資料。為了解與閉包有關的效能問題,考慮下面的例子:
function assignEvents(){
   var id = "xdi9592"; 
   document.getElementById("save-btn").onclick = function(event){
     saveDocument(id); 
   };
}
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作為它們的原型。而物件可以有兩種型別的成員:例項成員和原型成員。例項成員直接存在於例項自身而原型成員則從物件繼承。考慮如下例子:
var book = {
  title: "High Performance JavaScript",
    publisher: "Yahoo! Press"
};
alert(book.toString()); //"[object Object]"
此程式碼中book有title和publisher兩個例項成員。注意它並沒有定義toString()介面,但這個介面卻被呼叫且沒有丟擲錯誤。toString()函式就是一個book繼承自原型物件的原型成員。下圖表示了它們的關係:


處理物件成員的過程與處理變數十分相似。當book.toString()被呼叫時,對成員進行名為“toString”的搜尋,首先從物件例項開始,若果沒有名為toString的成員,那麼就轉向搜尋原型物件,在那裡發現了toString()方法並執行它。通過這種方法,book可以訪問它的原型所擁有的每個屬性和方法。

我們可以使用hasOwnProperty()函式確定一個物件是否具有特定名稱的例項成員。例項略。

Prototype Chains 原型鏈

物件的原型決定了一個例項的型別。預設情況下,所有物件都是Object的例項,並繼承了所有基本方法。如toString()。我們也可以使用構造器建立另外一種原型。例如:
function Book(title, publisher){ 
  this.title = title;
  this.publisher = publisher;
}
 
Book.prototype.sayTitle = function(){
  alert(this.title); 
};
  var book1 = new Book("High Performance JavaScript", "Prototype Chains"); 
  var book2 = new Book("JavaScript: The Good Parts", "Prototype Chains"); 
  alert(book1 instanceof Book); //true
  alert(book1 instanceof Object); //true
  book1.sayTitle(); //"High Performance JavaScript" 
  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 總結

1.在Javascript中,資料儲存位置可以對程式碼整體效能產生重要影響。有四種資料訪問型別:直接量,變數,陣列項,物件成員。對它們我們有不同的效能考慮。

2.直接量和區域性變數的訪問速度非常快,而陣列項和物件成員需要更長時間。

3.區域性變數比外部變數快,是因為它位於作用域鏈的第一個物件中。變數在作用域鏈中的位置越深,訪問所需的時間就越長。而全域性變數總是最慢的,因為它處於作用域鏈的最後一環。

4.避免使用with表示式,因為它改變了執行期上下文的作用域鏈。而且應當特別小心對待try-catch語句的catch子句,它具有同樣的效果。

5.巢狀物件成員會造成重大效能影響,儘量少用。

6.一般而言,我們通過將經常使用的物件成員,陣列項,和域外變數存入區域性變數中。然後,訪問區域性變數的速度會快於那些原始變數。

通過上述策略,可以極大提高那些使用Javascript程式碼的網頁應用的實際效能。
評論(1)

相關文章