傳統的 Web 頁面不會包含很多指令碼,至少不會太影響 Web 頁面的效能。然而,Web 頁面變得越來越像應用程式,指令碼對其的影響也越來越大。隨著越來越多的應用採用 Web 技術開發,指令碼效能的提升就變得越來越重要。
桌面應用程式通常是用編譯器將原始碼轉換為最終的二進位制。編譯器在生成最終的應用程式時,可以花費時間,儘可能地對效能進行優化。Web 應用程式就不能這麼奢侈了。因為它們需要在多種瀏覽器、平臺和架構上執行,所以不能對它們進行完全地預編譯。瀏覽器會每次取到一個指令碼並對其進行解釋和編譯,然而最終應用程式卻要像桌面應用一樣迅速載入、執行流暢。它被期望執行於大量各種各樣的裝置,從普通的臺式電腦到手機都包含在內。
瀏覽器相當擅長實現這個目標,而 Opera 擁有當前瀏覽器中最快的指令碼引擎之一。不過瀏覽器也有一些侷限,這正是 Web 開發者需要關注的。要確保 Web 應用能執行得儘可能的快,這可能只是一個簡單迴圈交換,改變一個合併的樣式而不是三個,或者只新增確實會執行到的指令碼。
本文會展示一些能提升 Web 應用效能的改變,其範圍涉及 ECMAScript —— JavaScript 的核心語言、DOM 和檔案載入。
小貼士
ECMAScript
- 避免使用
eval
或Function
構造器 - 改寫
eval
- 如果你需要函式,使用 function
- 不要使用
with
- 不要在要求效能的函式中使用
try-catch-finally
- 隔離
eval
和with
的使用 - 儘量不用全域性變數
- 注意物件的隱式換
- 在要求效能的函式中避免使用
for-in
- 使用累加形式連線字串
- 基本運算比呼叫函式更快
- 為
setTimeout()
和setInterval()
傳入函式而不是字串
DOM
- 重繪和重排
- 將重排數量降到最低
- 最小重排
- 修改文件樹
- 修改不可見的元素
- 測量
- 一次改變多項樣式
- 平滑度換速度
- 避免檢索大量節點
- 通過 XPath 提升速度
- 避免在遍歷 DOM 的時候進行修改
- 在指令碼中用變數快取 DOM 的值
文件載入
ECMAScript
避免使用 eval
或 Function
構造器
每次進行 eval
或呼叫 Function
構造器,指令碼引擎都會啟動一些機制來將字串形式的原始碼轉換為可執行的程式碼。這通常會嚴重影響效能 —— 比如說,直接呼叫函式的效能是它的 100 倍。
eval
函式尤其糟糕,因為 eval
無法預知傳遞給它的字串的內容。程式碼是在呼叫 eval
的上下文件中解釋,這就意味著編譯器無法優化相關上下文,就會留給瀏覽器很多需要在執行時解釋的內容。這就造成了額外的效能影響。
Function
構造器比 eval
要好一點,因為它不影響周圍程式碼的使用,但它仍然相當緩慢。
改寫 eval
eval
不僅僅是低效,它幾乎不用存在。多數使用它的情況都是另存為資訊是通過字串提供的,這些資訊被假定用於 eval
。下面的示例展示了一些常見的錯誤:
1 2 3 4 5 |
function getProperty(oString) { var oReference; eval('oReference = test.prop.' + oString); return oReference; } |
這段程式碼完成了同樣的功能,是它沒有使用 eval
:
1 2 3 |
function getProperty(oString) { return test.prop[oString]; } |
在 Opera9、Firefox 和 Internet Explorer 中,沒有使用 eval
的程式碼比用了 eval
的程式碼快 95% 左右,在 Safari 中快 85% 左右。(注意這不包含調函式本身所需要的時間。)
如果你需要函式,使用 function
這個例子展示了使用 Function
構造器常見的用法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
function addMethod(oObject, oProperty, oFunctionCode) { oObject[oProperty] = new Function(oFunctionCode); } addMethod( myObject, 'rotateBy90', 'this.angle = (this.angle + 90) % 360' ); addMethod( myObject, 'rotateBy60', 'this.angle = (this.angle + 60) % 360' ); |
下面的程式碼實現了同樣的功能,但沒有用 Function
構造林。它通過匿名函式實現,匿名函式可以像其它物件一樣被引用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function addMethod(oObject, oProperty, oFunction) { oObject[oProperty] = oFunction; } addMethod( myObject, 'rotateBy90', function() { this.angle = (this.angle + 90) % 360; } ); addMethod( myObject, 'rotateBy60', function() { this.angle = (this.angle + 60) % 360; } ); |
不要使用 with
雖然 with
能給開發者帶來便利,但它可能會影響效能。with
會在引用變數時為指令碼引擎構造一個額外的作用域。僅此,會造成少許效能下降。然而,編譯期並不能獲知這個作用域的內容,所以編譯器不會像優化普通的作用域(比如由函式建立的作用域)那樣優化它。
有更多的方法給開發者提供使得,比如使用一個普通變數來引用物件,然後通過這個變數來訪問其屬性。顯然只有當屬性不是字面型別,比如字串或布林型的時候,才能這樣做。
看看這段程式碼:
1 2 3 4 5 |
with(test.information.settings.files) { primary = 'names'; secondary = 'roles'; tertiary = 'references'; } |
下面的程式碼會讓指令碼引擎更有效率:
1 2 3 4 |
var testObject = test.information.settings.files; testObject.primary = 'names'; testObject.secondary = 'roles'; testObject.tertiary = 'references'; |
不要在要求效能的函式中使用 try-catch-finally
try-catch-finally
結構相當獨特。與其它結構不同,它執行時會在當前作用域建立一個新變數。在每次 catch
子句執行的時候,這個變數會引用捕捉到的異常物件。這個變數不會存在於指令碼的其它部分,哪怕是在相同的作用域中。它在 catch
子句開始的時候建立,並在這個子句結束的時候銷燬。
因為這個變數在執行時建立和銷燬,並且在語句中代表著一種特殊的情況,某些瀏覽器不能很有效地處理它。因此如果把它放在一個要求效能的迴圈中,在捕捉到異常時可能造成效能問題。
異常處理應該儘可能地放在更高層次的指令碼中,在這裡異常可能不會頻繁發生,或者可以先檢查操作是否可行以避免異常發生。下面的示例展示了一個迴圈,在訪問的屬性不存在時有可能丟擲幾個異常:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var oProperties = [ 'first', 'second', 'third', … 'nth' ]; for(var i = 0; i < oProperties.length; i++) { try { test[oProperties[i]].someproperty = somevalue; } catch(e) { … } } |
多數情況下,try-catch-finally
結構可以移動到迴圈外層。這對語義略有改動,因為如果異常發生,迴圈就中止了,不管之後的程式碼是否能繼續執行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var oProperties = [ 'first', 'second', 'third', … 'nth' ]; try { for(var i = 0; i < oProperties.length; i++) { test[oProperties[i]].someproperty = somevalue; } } catch(e) { … } |
某些情況下,try-catch-finally
結構可以通過檢查屬性或者其它適當的測試來完全規避:
1 2 3 4 5 6 7 8 9 10 11 12 |
var oProperties = [ 'first', 'second', 'third', … 'nth' ]; for(var i = 0; i < oProperties.length; i++) { if(test[oProperties[i]]) { test[oProperties[i]].someproperty = somevalue; } } |
隔離 eval
和 with
的使用
由於這些結構會對效能造成顯著影響,應該儘可能的少用它們。但有時候你可能仍然需要使用到它們。如果某個函式被反覆呼叫,或者某個迴圈在重複執行,那最好不要在它們內部使用這些結構。它們只適合在執行一次或很少幾次的程式碼中使用,還要注意這些程式碼對效能要求不高。
無論什麼情況,儘量將它們與其它程式碼隔離開來,這樣就不會影響到其它程式碼的效能。比如,把它們放在一個頂層函式中,或者只執行一次並把結果儲存下來,以便稍後可以使用其結果而不必再執行這些程式碼。
try-catch-finally
結構可能會在某些瀏覽器對效能產生影響,包括 Opera,所以你最好以同樣的方式對其進行隔離。
儘量不用全域性變數
建立臨時變數很簡單,所以很誘人。然而,因為某些原因,它可能會讓指令碼執行緩慢。
首先,如果程式碼在函式或另一個作用域中引用全域性變數,指令碼引擎會依次通過每個作用域直到全域性作用域。區域性變數找起來會快得多。
全域性作用域中的變數存在於指令碼的整個生命週期。而區域性變數會在離開區域性作用域的時候被銷燬,它們佔用的記憶體可以被垃圾收集器回收。
最後,全域性作用域由 window 物件共享,也就是說它本質上是兩個作用域而不是一個。在全域性作用域中,變數總是通過其名稱來定位,而不是像區域性變數那樣經過優化,通過預定義的索引來定位。這最終導致指令碼引擎需要花更多時間來找到全域性變數。
函式通常也在全域性作用域中建立。因此一個函式調另一個函式,另一個函式再接著調其它函式,如此深入下去,指令碼引擎就會不斷增加往回定位全域性變數的時間。
來看個簡單的示例,i
和 s
定義在全域性作用域中,而函式會使用這些全域性變數:
1 2 3 4 5 6 7 |
var i, s = ''; function testfunction() { for(i = 0; i < 20; i++) { s += i; } } testfunction(); |
下面的替代版本執行得更快。在多數當今的瀏覽器中,包括 Opera 9、最新的 Internet Explorer、Firefox、Konqueror 和 Safari,它的執行速度會比之前的版本快 30% 左右。
1 2 3 4 5 6 7 |
function testfunction() { var i, s = ''; for(i = 0; i < 20; i++) { s += i; } } testfunction(); |
注意物件的隱式轉換
字面量,比如字元中、數、和布林值,在 ECMAScript 中有兩種表現形式。它們每種型別都可以作為值建立,也可以作為物件建立。比如,var oString = 'some content';
建立了一個字串值,而 var oString = new String('some content');
建立了等價的字串物件。
所有屬性和方法都是在字串物件而不是值上定義的。如果你對字串值呼叫屬性和方法,ECMAScript 引擎必須用相同的字串值隱式地建立一個新的字串物件,然後才能呼叫方法。這個物件僅用於這一個需求,如果下次再對字串值呼叫某個方法,會再次類似地建立字串物件。
下面的示例的讓指令碼引擎建立 21 個新的字串物件。每次訪問 length
屬性和每次呼叫 charAt
方法的時候都會建立物件:
1 2 3 4 |
var s = '0123456789'; for(var i = 0; i < s.length; i++) { s.charAt(i); } |
下面的示例與上面那個示例等價,但只建立了一個物件,它的執行結果更好:
1 2 3 4 |
var s = new String('0123456789'); for(var i = 0; i < s.length; i++) { s.charAt(i); } |
如果你的程式碼經常呼叫字面量值的方法,你就應該考慮把它們轉換為物件,就像上面的例子那樣。
注意,雖然本文中大部分觀點都與所有瀏覽器相關,但這種優化主要針對 Opera。它也可能影響其它一些瀏覽器,但在 Internet Explorer 和 Firefox 中可能會慢一些。
在要求效能的函式中避免使用 for-in
for-in
迴圈經常被誤用,有時候普通的 for
迴圈會更合適。for-in
迴圈需要指令碼引擎為所有可列舉的屬性建立一個列表,然後檢查其中的重複項,之後才開始遍歷。
很多時候指令碼本身已經知道需要遍歷哪睦屬性。多數情況下,簡單的 for
迴圈可以逐個遍歷那些屬性,特別是它們使用有序的數字作為名稱的時候,比如陣列,或者偽陣列(像由 DOM 建立的 NodeList 就是偽陣列)。
下面有一個未正確使用 for-in
迴圈的示例:
1 2 3 4 |
var oSum = 0; for(var i in oArray) { oSum += oArray[i]; } |
使用 for
迴圈會更有效率:
1 2 3 4 5 |
var oSum = 0; var oLength = oArray.length; for(var i = 0; i < oLength; i++) { oSum += oArray[i]; } |
使用累加形式連線字串
字串連線可以非常消耗效能。使用 +
運算子不會直接把結果賦值給變數,它會在記憶體中建立一個新的字串用於儲存結果,這個新的字串可以賦值給變數。下面的程式碼展示了一個常見的字串連線:
1 |
`a += 'x' + 'y';` |
這段程式碼首先會在記憶體中建立一個臨時的字串儲存連線的結果 xy
,然後將它連線到 a
的當前值,再將最終的連線結果賦值給 a
。下面的程式碼使用了兩條命令,但因為它每次都是直接賦值,所以不會使用臨時字串。當今許多瀏覽器中執行這段程式碼速度會快 20%,而且只需要更少的記憶體,因為它不需要暫存連線結果的臨時字串:
1 2 |
a += 'x'; a += 'y'; |
基本運算比呼叫函式更快
雖然普通程式碼中不需要太注意,但在要求效能的迴圈和函式中還有辦法提高效能——把函式呼叫替換為等價的基本呼叫。比如對陣列呼叫 push 方法就會比直接通過陣列的尾部索引新增元素更慢。再比如對於 Math 物件來說,多數時候,簡單的數學計算會比呼叫方法更恰當。
1 2 |
var min = Math.min(a,b); A.push(v); |
下面的程式碼做了同樣的事情,但效能更好:
1 2 |
var min = a < b ? a : b; A[A.length] = v; |
為 setTimeout()
和 setInterval()
傳入函式而不是字串
setTimeout()
和 setInterval()
方法與 eval
類似。如果傳遞給它們的是字串,在等待指定的時間之後,會跟 eval
一樣進行計算,也會對效能造成同樣的影響。
不過這些方法都會接收函式作為第一個引數,所以可以不用傳入字串。作為引數傳入的函式會在一定延遲之後呼叫,但它們可以在編譯期進行解釋和優化,最終會帶來效能提升。這裡有個使用字串作為引數的典型示例:
1 2 |
setInterval('updateResults()', 1000); setTimeout('x+=3; prepareResult(); if(!hasCancelled){ runmore() }', 500); |
第一種情況可以直接引用函式。第二種情況可以使用匿名函式來封裝程式碼:
1 2 3 4 5 6 7 8 |
setInterval(updateResults, 1000); setTimeout(function () { x += 3; prepareResult(); if(!hasCancelled) { runmore(); } }, 500); |
注意,所有情況下超時和間隔時間都可能不準確。一般來說,瀏覽器延遲的時間可能會略長一些。有些人會把請求時間稍微提前一點來進行補償,其他人會嘗試每次都等待正確的時間。像 CPU 速度、執行緒狀態和 JavaScript 載入等因素都會影響延遲的準確性。多數瀏覽器不會按 0 毫秒進行延遲,它們會施以最小的延遲來代替,這個延遲一般在 10 到100 毫秒之間。
DOM
總的來說,有三個主要因素會導致 DOM 效能不佳。一是使用指令碼進行了大量的 DOM 操作,比如通過收到的資料建立一棵樹。二是指令碼觸發了太多重排或者重繪。三是指令碼使用了低效能的方法來定位 DOM 樹中的節點。
第二點和第三點非常普遍,也非常重要,所以首先解決它們。
重繪和重排
有東西從不可見變為可見,或者反之,但沒有改變文件佈局,就會觸發重繪。比如為某個元素新增輪廓線,改變背景色或者改變 visibility 樣式等。重繪很耗效能,因為它需要引擎搜尋所有元素來決定什麼是可見的,什麼應該顯示出來。
重排帶來更大的變化。如果對 DOM 樹進行了操作,或者某個樣式改變了佈局,比如元素的 className 屬性改變時,或者瀏覽器視窗大小改變的時候。引擎必須對相關元素進行重排,以確定現在各個部分應該顯示在哪裡。子元素也會因父元素的變化重排。顯示某個被重排的元素之後的元素也需要重新計算新的佈局,與最開始的佈局不同。由於子孫元素大小的改變,祖先元素也需要重排以適應新的大小。最後還需要對所有元素進行重繪。
重排特別消耗效能,它是造成 DOM 指令碼緩慢的主要原因之一,這對處理器效能不高的裝置,比如電話,尤其顯著。多數情況下它相當於重新佈局整個頁面。
將重排數量降到最低
很多時候指令碼都需要做一些引起重繪或者重排的事情。動畫就是基於重排的,而大家仍然希望看到它。因此在 Web 開發中,重排不可避免,要保證指令碼跑得飛快,就必須在保證相同整體效果的前提下將重排保持在最低限度。
瀏覽器可以選擇在指令碼執行緒完成後進行重排,顯示變化。Opera 會等到發生了足夠多的變化,經過了一定的時間,或者指令碼執行緒結束。也就是說,如果在同一個執行緒中發生的變化足夠快,它們就只會觸發一次重排。然而,考慮到 Opera 執行在不同速度的裝置上,這種現象並不保證一定會發生。
注意某些元素在重排時顯示慢於其它元素。比如重排一個 table 需要 3 倍於等效塊元素顯示的時間。
最小重排
一般的重排會影響到整文件。文件中需要重排的東西越多,重排花的時間就越長。絕對(absolute)定位和固定(fixed)定位的元素不會影響主文件的佈局,所以對它們的重排不會引起其它部分的連鎖反應。文件中在它們之後的內容可能需要重繪來呈現變化,但這也遠比一個完整的重排好得多。
因此,動畫不需要應用於整個文件,它最好只應用在一個固定位置的元素上。大多數動畫都只需要考慮這個問題。
修改文件樹
修改檔案樹 會 導致重排。在 DOM 中新增新的倖免於難、改變文字節點的值、或者修改各種屬性,都足以引起重排。多次連續地改變可能導致多次重排。因此,總的來說,最好在一段未顯示出來的 DOM 樹片段上進行多次改變,然後用一個單一的操作把改變應用在文件的 DOM 中。
1 2 3 4 5 6 7 8 9 |
var docFragm = document.createDocumentFragment(); var elem, contents; for(var i = 0; i < textlist.length; i++) { elem = document.createElement('p'); contents = document.createTextNode(textlist[i]); elem.appendChild(contents); docFragm.appendChild(elem); } document.body.appendChild(docFragm); |
修改文件樹也可以通過克隆一個元素實現,在修改完成之後將之替換掉文件樹中的某個元素,這樣只會導致一次重排。注意,如果元素中包含任何形式的控制,就不要使用這個方法,因為如果使用者修改了它們的值不會反映在主要的 DOM 樹上。如果你需要依賴附加在這個元素或其子元素上的事件處理函式,那麼也不要使用這個方法,因為這些附著關係不會被克隆。
1 2 3 4 5 6 7 8 9 10 11 |
var original = document.getElementById('container'); var cloned = original.cloneNode(true); cloned.setAttribute('width', '50%'); var elem, contents; for(var i = 0; i < textlist.length; i++) { elem = document.createElement('p'); contents = document.createTextNode(textlist[i]); elem.appendChild(contents); cloned.appendChild(elem); } original.parentNode.replaceChild(cloned, original); |
修改不可見的元素
如果某個元素的 display 樣式設定為 none,就不會對其進行重繪,哪怕它的內容發生改變。這都是因為它不可見,這是一種優勢。如果需要對某個元素或者它的子元素進行改變,而且這些改變又不能合併在一個單獨的重繪中,那就可以先設定這個元素的樣式為 display:none
,然後改變它,再把它設定為普通的顯示狀態。
不過這會造成兩次額外的重排,一次是在隱藏元素的時候,另一次是它再次顯示出來的時候,不過整體效果會快很多。這樣做也可能意外導致滾動條跳躍。不過把這種方式應用於固定位置的元素就不會導致難看的效果。
1 2 3 4 5 6 |
var posElem = document.getElementById('animation'); posElem.style.display = 'none'; posElem.appendChild(newNodes); posElem.style.width = '10em'; // Other changes… posElem.style.display = 'block'; |
測量
如前所述,瀏覽器會快取一些變化,然後在這些變化都完成之後只進行一次重排。不過,測量元素會導致其強制重排。這種變化可能會,也可能不會引起明顯地重繪,但重排仍然會在幕後發生。
這種影響發生在使用像 offsetWidth 這樣的屬性,或者 getComputedStyle 這樣的方法進行測量的時候。即使不使用這些數字,只要使用了它們,瀏覽器仍然會快取改變,這足以觸發隱藏的重排。如果這些測量需要重複進行,你就得考慮只測量一次,然後將結果儲存起來以備後用。
1 2 3 4 5 6 |
var posElem = document.getElementById('animation'); var calcWidth = posElem.offsetWidth; posElem.style.fontSize = (calcWidth / 10) + 'px'; posElem.firstChild.style.marginLeft = (calcWidth / 20) + 'px'; posElem.style.left = ((-1 * calcWidth) / 2) + 'px'; // Other changes… |
一次改變多項樣式
就像改變 DOM 樹一樣,也可以同時進行幾項相關樣式的改變,以儘可能減少重繪或重排次數。常見的方法是一次設定一個樣式:
1 2 3 4 |
var toChange = document.getElementById('mainelement'); toChange.style.background = '#333'; toChange.style.color = '#fff'; toChange.style.border = '1px solid #00f'; |
那種方式會造成多次重排或重繪。主要有兩種方法可以做得更好。如果元素本身需要應用的幾個樣式,而它們的值都是已知的,那就可以修改元素的 class,並在這個 class 中定義所有新樣式:
1 2 3 4 5 6 7 8 9 10 11 12 |
div { background: #ddd; color: #000; border: 1px solid #000; } div.highlight { background: #333; color: #fff; border: 1px solid #00f; } … document.getElementById('mainelement').className = 'highlight'; |
第二種方法是對為元素定義一個新的樣式屬性,而不是一個個地指定樣式。多數情況下這適用於像動畫這樣的動態變化,新的樣式預先並不知道。這通過 style 物件的 cssText 屬性實現,或者通過 setAttribute 實現。Internet Explorer 不支援第二種方式,所以只能使用第一種。一些舊的瀏覽器,包括 Opera 8,要使用第二種方式,不能使用第一種。因此,簡單的辦法是檢查是否支援第一種方式,如果支援,使用它,否則使用第二種。
1 2 3 4 5 6 7 8 9 |
var posElem = document.getElementById('animation'); var newStyle = 'background: ' + newBack + ';' + 'color: ' + newColor + ';' + 'border: ' + newBorder + ';'; if(typeof(posElem.style.cssText) != 'undefined') { posElem.style.cssText = newStyle; } else { posElem.setAttribute('style', newStyle); } |
平滑度換速度
開發者總是希望通過使用更小的間隔時間和更小的變化,讓動畫儘可能平滑。比如,使用 10ms 的時間間隔,每次移動 1 個畫素實現移動動畫。快速執行的動畫在某些 PC 或某些瀏覽器中會執行良好。但是,10ms 幾乎已經是瀏覽器能在不 100% 佔用大多數桌上型電腦 CPU 的情況能實現的最小時間間隔。有一些瀏覽器實現不了 —— 對於多數瀏覽器來說,每秒進行 100 次重排實在太多了。低功耗計算機或低功耗裝置上的瀏覽器無法以這樣的速度執行,動畫會給人以緩慢和卡頓的感覺。
有必要使用違背一下開發者的意願,使用動畫的平滑度來換取速度。將時間間隔改變為 50ms,動畫每次移動 5 個畫素,這樣需要的處理能力更少,也會讓動畫在低功耗處理器上執行起來快得多。
避免檢索大量節點
在試圖找到某個特定節點,或者某個節點的子集時,應該使用內建的方法和 DOM 集合來縮小搜尋範圍,使之在儘可能少的節點內進行搜尋。比如,如果你想在文件中找到一個具有某個特定屬性的未知的元素,可能這樣做:
1 2 3 4 5 6 |
var allElements = document.getElementsByTagName('*'); for(var i = 0; i < allElements.length; i++) { if(allElements[i].hasAttribute('someattr')) { // … } } |
即使我們忽略像 XPath 這樣的高階技術,那個例子中仍然存在兩個使之變慢的問題。首先,它搜尋了每一個元素,根本沒有嘗試縮小範圍。第二,它在找到了需要的元素之後並沒有中止搜尋。假如已經知道那個未知的元素在一個 id 為 inhere 的 div 中,下面的程式碼會好很多:
1 2 3 4 5 6 7 |
var allElements = document.getElementById('inhere').getElementsByTagName('*'); for(var i = 0; i < allElements.length; i++) { if(allElements[i].hasAttribute('someattr')) { // … break; } } |
如果那個未知的元素是那個 div 的直接子級,這種方法可能會更快,這取決於 div 的子孫元素的數量,將之與 childNodes 集合的 length 比較:
1 2 3 4 5 6 7 |
var allChildren = document.getElementById('inhere').childNodes; for(var i = 0; i < allChildren.length; i++) { if(allChildren[i].nodeType == 1 && allChildren[i].hasAttribute('someattr')) { // … break; } } |
基本的思路是儘可能避免手工步入 DOM。有許多在各種情況下表現更好的東西來代替 DOM,比如 DOM 2 Traversal TreeWalker,可用於代替遞迴遍歷 childNodes 集合。
通過 XPath 提升速度
一個簡單的示例是在 HTML 文件中使用 H2 – H4 建立一個目錄,這些元素可以出現在不同的地方,沒有任何適當的結構,所以不能用遞迴來獲得正確的順序。傳統的 DOM 會採用這樣的方法:
1 2 3 4 5 6 |
var allElements = document.getElementsByTagName('*'); for(var i = 0; i < allElements.length; i++) { if(allElements[i].tagName.match(/^h[2-4]$/i)) { // … } } |
在可能包含了 2000 個元素的文件,這會導致顯著的延遲,因為需要分別檢查它們每一個。XPath,當它得到原生支援的時候,能提供更快的方法。對 XPath 查詢引擎的優化可以比直接解釋 JavaScript 快得多。在某些情況下,甚至高達兩個數量級的速度提升。下面的示例與上面的傳統示例等效,但使用 XPath 提升了速度。
1 2 3 4 5 |
var headings = document.evaluate('//h2|//h3|//h4', document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); var oneheading; while(oneheading = headings.iterateNext()) { // … } |
下面的程式碼綜合了上面的兩個版本,在 XPath 可用的時候使用 XPath,否則回到傳統 DOM 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
if( document.evaluate ) { var headings = document.evaluate('//h2|//h3|//h4', document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); var oneheading; while(oneheading = headings.iterateNext()) { // … } } else { var allElements = document.getElementsByTagName('*'); for(var i = 0; i < allElements.length; i++) { if(allElements[i].tagName.match(/^h[2-4]$/i)) { // … } } } |
避免在遍歷 DOM 的時候進行修改
對於某些型別的 DOM 集合,如果你的指令碼在這些集合中檢索的時候改變了相關元素,集合會立即發生變化而不會等你的指令碼執行結束。這包含 childNodes 集合,以及 getElementsByTagName 返回的節點列表。
如果你的指令碼在像這樣的集合中檢索,同時又在向裡面新增元素,那你可能進入一個無限迴圈,因為在到達終點前不斷的往集合內新增項。不過,這不是唯一的問題。這些集合可能被優化以提升效能。它們能記住長度和指令碼引用的最後一個索引,以便在增加索引的時候,能迅速引用下一個節點。
如果你修改了 DOM 樹的任意部分,哪怕它不在集合中,集合也必須重新尋找新的條目。這樣做的話,它就不能記住最後的索引或長度,因為這些可能已經變化,之前所做的優化也就失效了:
1 2 3 4 |
var allPara = document.getElementsByTagName('p'); for(var i = 0; i < allPara.length; i++) { allPara[i].appendChild(document.createTextNode(i)); } |
在 Opera 中,下面等效的程式碼效能要好十倍,一些當今的瀏覽器,比如 Internet Explorer 也是如此。它的工作原理是先建立一個靜態元素列表用於修改,然後遍歷這個靜態列表來進行修改。以此避免對 getElementsByTagName 返回的列表進行修改。
1 2 3 4 5 6 7 8 9 |
var allPara = document.getElementsByTagName('p'); var collectTemp = []; for(var i = 0; i < allPara.length; i++) { collectTemp[collectTemp.length] = allPara[i]; } for(i = 0; i < collectTemp.length; i++) { collectTemp[i].appendChild(document.createTextNode(i)); } collectTemp = null; |
在指令碼中用變數快取 DOM 的值
DOM 返回的某些值是不快取的,它們會在再次呼叫的時候重新計算。getElementById 方法就是其中之一,下面的程式碼就比較浪費效能:
1 2 3 4 |
document.getElementById('test').property1 = 'value1'; document.getElementById('test').property2 = 'value2'; document.getElementById('test').property3 = 'value3'; document.getElementById('test').property4 = 'value4'; |
這段程式碼對同一個物件查詢了四次。下面的程式碼只會查詢一次並儲存下來。對於單獨一個請求來說,這樣的速度可能沒有變化,或者會因此賦值變得稍慢一點。但在後續操作中使用快取值之後,對當今的瀏覽器來說,命令執行的速度會快五到十倍。下面的命令與上面示例中的等效:
1 2 3 4 5 |
var sample = document.getElementById('test'); sample.property1 = 'value1'; sample.property2 = 'value2'; sample.property3 = 'value3'; sample.property4 = 'value4'; |
文件載入
避免在多個文件間保持同一個引用
如果一個文件訪問了另一個文件的節點或者物件,應該避免在指令碼使用完它們之後仍然保留它們的引用。如果某個引用儲存在當前文件的全域性變數中,或者儲存在某個長期存在的物件的屬性中,通過將其設定為 null,或者通過 delete 來清除它。
原因在於,如果另一個文件已經銷燬,比如原來顯示在彈出窗中而現在這個視窗關閉了,當前文件中儲存的引用通常仍然會使其 DOM 樹或者指令碼環境在 RAM 中存在,哪怕文件本身已經不在載入狀態了。在框架頁面,內聯框架頁面或 OBJECT 元素中同樣存在這個問題。
1 2 3 4 5 6 7 8 9 |
var remoteDoc = parent.frames['sideframe'].document; var remoteContainer = remoteDoc.getElementById('content'); var newPara = remoteDoc.createElement('p'); newPara.appendChild(remoteDoc.createTextNode('new content')); remoteContainer.appendChild(newPara); // Remove references remoteDoc = null; remoteContainer = null; newPara = null; |
快速歷史導航
Opera (和很多其它瀏覽器) 預設使用快速歷史導航。當使用者在瀏覽器歷史上前進或回退的時候,頁面的狀態及其中的指令碼都被儲存了。當使用者回到某個頁面的時候,它會像從未離開過一樣繼續執行,文件不會再次載入和初始化。這樣做的結果是對使用者進行快速響應,也可以使載入緩慢的 Web 應用唾棄在導航過程中表現得更好。
儘管 Opera 提供了一種方法被 創造出來控制這種行為,最好在任何可能的地方都讓它使用快速歷史導航模式。也就是說,指令碼會應該儘量避免做會導致這種行為失敗的事情。這就包括了在表單提交時禁用表單控制元件、選單項被點選之後就不再有效、離開頁面時的淡出效果使內容模糊不清或不可見。
使用 onunload 監聽器是比較簡單的解決辦法,可以通過它重置淡出效果,或者使表單控制元件變為可用。不過得注意,某些瀏覽器,比如 Firefox 和 Safari,為 unload 事件新增監聽器會導致快速歷史導航失效。此外,禁用提交按鈕在 Opera 中也會導致快速歷史導航失效。
1 2 3 |
window.onunload = function () { document.body.style.opacity = '1'; }; |
使用 XMLHttpRequest
這並非對所有專案都適用,但這個方法能有效減少從伺服器接收的內容,同時可以避免頁面載入帶來的指令碼環境的破壞和再造。最初,頁面以正常的方式載入,之後再通過 XMLHttpRequest 來載入最小需求的新內容。這會讓 JavaScript 環境保持下來。
不過需要注意,這種訪求可能會導致問題。總的來說,它完全打破了歷史導航。雖然這種方法可以通過將資訊儲存在內聯框架中來偽造歷史,但這違背了使用 XMLHttpReqest 的首要目的。因此,請謹慎地,只在它所造成的變化不需要回退的時候使用它。這種方法也有可能對輔助裝置造成混亂,因為輔助裝置感受不到頁面上的 DOM 的變化。所以最好在確保不會出現問題的情況下使用這個方法。
如果不允許 JavaScript,或者瀏覽器不支援 XMLHttpReqeust,這種方法就不可用。解決這個問題最簡單的方法就是使用一個正常的連結,指向新頁面。然後為這個連結新增事件處理函式,在連結被點選的時候進行檢查。事件處理函式可以檢測出是否支援 XMLHttpReqest,如果支援,則載入新資料並阻止連結的預設行為。一量資料載入完成,就可以用來替換頁面的某些內容,然後銷燬請求物件,以允許垃圾回收釋放記憶體。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
document.getElementById('nextlink').onclick = function() { if(!window.XMLHttpRequest) { return true; } var request = new XMLHttpRequest(); request.onreadystatechange = function() { if( request.readyState != 4 ) { return; } var useResponse = request.responseText.replace(/^[\w\W]*<div id="container">|<\/div>\s*<\/body>[\w\W]*$/g , ''); document.getElementById('container').innerHTML = useResponse; request.onreadystatechange = null; request = null; }; request.open('GET', this.href, true); request.send(null); return false; } |
動態建立 <script>
元素
載入和處理指令碼需要時間,但有些時候,載入了指令碼卻從未使用。載入這樣的指令碼純粹是在浪費時間和資源,最好根本就不要載入不使用的指令碼。通過一個簡單的載入器指令碼可以檢查其它指令碼是否會用到,只有在指令碼實際用到的時候才建立指令碼元素。
理論上來說,在頁面載入完成之後可以通過 SCRIPT 元素來載入額外的指令碼並通過 DOM 新增到文件中。當前所有主流瀏覽器都支援這樣做,但是它實際上可能是在瀏覽器上請求而不是立即載入指令碼。另外,如果需要在頁面載入完成之前載入指令碼,就最好在指令碼載入的過程中進行檢查並使用 document.write
來建立指令碼標籤。千萬記得要轉義斜槓以免過早結果當前指令碼:
1 2 3 4 5 6 |
if(document.createElement && document.childNodes) { document.write('<\/script>'); } if(window.XMLHttpRequest) { document.write('<\/script>'); } |
location.replace()
控制歷史記錄
偶爾是需要使用指令碼來改變頁面地址。最典型的做法是給 location.href
賦予一個新地地址。這樣做會新增一個歷史記錄,同時載入一個新的頁面,這和啟用一個普通的連結一樣。
在某些情況下,並不希望出現一條額外的歷史記錄,因為使用者不需要回到之前的頁面。如果在記憶體特別重要的環境下,這樣做就非常有用。當前頁面使用的記憶體可以通過替換歷史記錄來得到重新利用,使用 location.replace()
方法就可以做到。
1 |
`location.replace('newpage.html');` |
請注意,該頁可能仍然保留在快取中,並可能在那裡使用記憶體,但不會用到像儲存在歷史記錄裡那麼多。