提升 HTML5 應用程式的效能

Malte Ubl
Malte Ubl

簡介

HTML5 提供絕佳工具,可提升網頁應用程式的視覺效果。在動畫領域尤其如此。不過,這股強大的力量也帶來了新的挑戰。其實這些挑戰並非新問題,有時不妨向鄰座的 Flash 程式設計師請教,瞭解她過去如何克服類似問題。

總之,在處理動畫時,使用者是否認為動畫流暢,是非常重要的。我們必須瞭解,動畫的流暢度並非單純提高每秒影格數,超過任何認知門檻就能達成。可惜的是,我們的大腦比這更聰明。您會發現,真正的每秒 30 影格 (fps) 動畫,比中間掉幾格的 60 fps 動畫好得多。使用者不喜歡鋸齒狀。

本文將提供工具和技術,協助您改善應用程式的使用體驗。

策略

我們絕非不鼓勵您使用 HTML5 建構精彩、視覺效果驚人的應用程式。

如果發現效能有待提升,請返回此處,瞭解如何改善應用程式元素。當然,一開始就做好某些事情確實有幫助,但千萬不要因此而影響工作效率。

HTML5 視覺逼真度++

硬體加速

硬體加速是瀏覽器整體算繪效能的重要里程碑。一般做法是將原本由主 CPU 計算的工作,卸載至電腦圖形轉接器的圖形處理器 (GPU)。這可大幅提升效能,並減少行動裝置的資源消耗。

GPU 可加快處理文件的這些部分

  • 一般版面配置合成
  • CSS3 轉場效果
  • CSS3 3D 轉換
  • Canvas 繪圖
  • WebGL 3D 繪圖

雖然 Canvas 和 WebGL 加速是特殊用途功能,可能不適用於您的特定應用程式,但前三項功能幾乎可以協助所有應用程式加快速度。

哪些項目可以加速?

GPU 加速功能會將定義明確的特定工作卸載至特殊用途硬體。一般來說,文件會拆分成多個「層」,這些層不會因網頁加速而改變。這些圖層會使用傳統算繪管道算繪。接著,GPU 會將圖層組合成單一頁面,並套用可即時加速的「效果」。可能結果是,在畫面上呈現動畫效果的物件,在動畫播放期間不需要單一「重新版面配置」頁面。

您需要瞭解的是,您必須讓轉譯引擎能輕鬆辨識何時可以套用 GPU 加速。請參考以下範例:

雖然這種做法可行,但瀏覽器並不知道您正在執行的動作,應該要讓使用者覺得是流暢的動畫。請思考一下,如果改用 CSS3 轉場效果達到相同的視覺效果,會發生什麼情況:

瀏覽器如何實作這個動畫,對開發人員來說完全是隱藏的。這表示瀏覽器可以運用 GPU 加速等技巧,達到定義的目標。

Chrome 有兩個實用的指令列旗標,可協助偵錯 GPU 加速:

  1. --show-composited-layer-borders 會在 GPU 層級操作的元素周圍顯示紅色邊框。適合用來確認操作是否在 GPU 層內進行。
  2. --show-paint-rects 所有非 GPU 變更都會繪製,並在所有重新繪製的區域周圍加上淺色邊框。您可以看到瀏覽器最佳化繪製區域的實際運作情形。

Safari 也有類似的執行階段旗標,詳情請參閱這篇文章。

CSS3 轉場效果

CSS 轉場效果可讓所有人輕鬆製作樣式動畫,同時也是一項智慧效能功能。由於 CSS 轉場效果是由瀏覽器管理,因此動畫的擬真度可以大幅提升,而且在許多情況下,硬體加速。目前 WebKit (Chrome、Safari、iOS) 具有硬體加速 CSS 轉換,但其他瀏覽器和平台也即將推出。

您可以使用 transitionEnd 事件,將其編寫成強大的組合,但目前擷取所有支援的轉場結束事件,代表要監看 webkitTransitionEnd transitionend oTransitionEnd。

許多程式庫現在都導入了 Animation API,可利用轉場效果 (如有),否則會改用標準 DOM 樣式動畫。scripty2、YUI 轉場、jQuery 動畫強化。

CSS3 翻譯

相信您之前曾為網頁上某個元素的 x/y 位置建立動畫。您可能操控了內嵌樣式的 left 和 top 屬性。使用 2D 轉換時,我們可以使用 translate() 功能複製這項行為。

我們可以將這項功能與 DOM 動畫結合,

<div style="position:relative; height:120px;" class="hwaccel">

  <div style="padding:5px; width:100px; height:100px; background:papayaWhip;
              position:absolute;" id="box">
  </div>
</div>

<script>
document.querySelector('#box').addEventListener('click', moveIt, false);

function moveIt(evt) {
  var elem = evt.target;

  if (Modernizr.csstransforms && Modernizr.csstransitions) {
    // vendor prefixes omitted here for brevity
    elem.style.transition = 'all 3s ease-out';
    elem.style.transform = 'translateX(600px)';

  } else {
    // if an older browser, fall back to jQuery animate
    jQuery(elem).animate({ 'left': '600px'}, 3000);
  }
}
</script>

我們使用 Modernizr 測試 CSS 2D 轉換和 CSS 轉場效果,如果有的話,我們將使用 translate 來移動位置。如果使用轉場效果建立動畫,瀏覽器很有可能可以硬體加速。為了讓瀏覽器朝正確的方向發展,我們將使用上述的「CSS 魔法子彈」。

如果瀏覽器功能較弱,我們會改用 jQuery 移動元素。您可以選取 Louis-Remi Babe 的 jQuery Transform polyfill 外掛程式,讓整個過程自動化。

window.requestAnimationFrame

requestAnimationFrame 是由 Mozilla 推出,並由 WebKit 疊代,目標是提供原生 API 來執行動畫,無論是 DOM/CSS 型動畫,還是 <canvas> 或 WebGL。瀏覽器可以將並行動畫最佳化,整合為單一重排和重繪週期,進而提高動畫品質。例如,與 CSS 轉場或 SVG SMIL 同步的 JS 動畫。此外,如果動畫迴圈在不可見的分頁中執行,瀏覽器就不會繼續執行,這表示 CPU、GPU 和記憶體用量會減少,電池續航力也會大幅延長。

如要進一步瞭解 requestAnimationFrame 的使用方式和原因,請參閱 Paul Irish 的文章「requestAnimationFrame for smart animating」。

剖析

發現應用程式速度有提升空間時,請深入剖析,找出能帶來最大效益的最佳化方式。最佳化通常會對原始碼的可維護性造成負面影響,因此請務必在必要時才進行。剖析功能會指出程式碼的哪些部分在效能提升後,能帶來最大效益。

JavaScript 分析

JavaScript 分析器會測量每個函式從開始到結束的執行時間,讓您概略瞭解應用程式在 JavaScript 函式層級的效能。

函式的總執行時間是指從頭到尾執行函式所需的總時間。淨執行時間是總執行時間減去執行從函式呼叫的函式所需的時間。

某些函式的呼叫頻率高於其他函式。分析器通常會提供所有叫用作業的執行時間,以及平均、最短和最長執行時間。

詳情請參閱 Chrome 開發人員工具中有關剖析的文件。

DOM

JavaScript 的效能會大幅影響應用程式的流暢度和回應速度。請務必瞭解,JavaScript 分析器雖然會評估 JavaScript 的執行時間,但也會間接評估執行 DOM 作業所花費的時間。這些 DOM 作業通常是效能問題的核心。

function drawArray(array) {
  for(var i = 0; i < array.length; i++) {
    document.getElementById('test').innerHTML += array[i]; // No good :(
  }
}

舉例來說,在上述程式碼中,執行實際 JavaScript 幾乎不花時間。drawArray 函式仍很有可能出現在設定檔中,因為它與 DOM 的互動方式非常浪費資源。

提示與祕訣

匿名函式

匿名函式本身沒有名稱,因此不會顯示在分析器中,不容易進行分析。有兩種方法可以解決這個問題:

$('.stuff').each(function() { ... });

重寫為:

$('.stuff').each(function workOnStuff() { ... });

JavaScript 支援已命名函式運算式,但這項功能並不廣為人知。這樣一來,這些函式就會在分析器中完美顯示。這個解決方案有一個問題:具名運算式實際上會將函式名稱放入目前的詞彙範圍。這可能會覆寫其他符號,請務必小心。

剖析長函式

假設您有一個冗長的函式,並懷疑其中一小部分可能是效能問題的原因。你可以透過以下兩種方式找出問題所在:

  1. 正確做法:重構程式碼,不加入任何長函式。
  2. 邪惡的解決方法:在程式碼中,以具名自我呼叫函式的形式加入陳述式。只要稍加留意,就不會改變語意,而且函式的部分內容會在分析器中顯示為個別函式: js function myLongFunction() { ... (function doAPartOfTheWork() { ... })(); ... } 分析完成後,請務必移除這些額外函式,甚至可以將這些函式做為重構程式碼的起點。

DOM 剖析

最新版 Chrome Web Inspector 開發人員工具包含新的「時間軸檢視畫面」,可顯示瀏覽器執行的低階動作時間軸。您可以利用這項資訊最佳化 DOM 作業。請盡量減少瀏覽器在執行程式碼時必須執行的「動作」數量。

時間軸檢視畫面可建立大量資訊。因此,請盡量建立可獨立執行的最小測試案例。

DOM 剖析

上圖顯示簡單指令碼的時間軸檢視畫面輸出結果。左側窗格會依時間順序顯示瀏覽器執行的作業,右側窗格的時間軸則會顯示個別作業實際耗費的時間。

進一步瞭解時間軸檢視畫面。在 Internet Explorer 中進行剖析的替代工具是 DynaTrace Ajax Edition。

剖析策略

挑出切面

分析應用程式時,請盡可能找出可能導致速度變慢的功能。接著,請嘗試執行設定檔,只執行與應用程式這些層面相關的程式碼部分。這樣一來,剖析資料就不會與實際問題無關的程式碼路徑混在一起,更容易解讀。以下是應用程式各個層面的良好範例:

  1. 啟動時間 (啟用分析器、重新載入應用程式、等待初始化完成、停止分析器。
  2. 點選按鈕和後續動畫 (啟動分析器、點選按鈕、等待動畫完成、停止分析器)。
GUI 分析

在 GUI 程式中,只執行應用程式的正確部分可能比最佳化 3D 引擎的光線追蹤器更困難。舉例來說,如果您想分析點選按鈕時發生的事件,可能會在過程中觸發不相關的滑鼠游標懸停事件,導致結果不夠明確。請盡量避免這種情況 :)

程式輔助介面

此外,您也可以透過程式輔助介面啟用偵錯工具。這樣一來,您就能精確控制剖析的開始和結束時間。

使用下列指令開始剖析:

console.profile()

停止剖析:

console.profileEnd()

重複性

進行分析時,請務必確保能實際重現結果。這樣才能判斷最佳化作業是否確實改善成效。此外,函式層級剖析是在整部電腦的環境中進行。這並非精確的科學。個別設定檔執行作業可能會受到電腦上發生的許多其他事件影響:

  1. 在測量其他項目時,應用程式中不相關的計時器觸發。
  2. 垃圾收集器正在執行工作。
  3. 瀏覽器中的另一個分頁在同一個作業執行緒中執行繁重工作。
  4. 電腦上的其他程式耗盡 CPU 資源,導致應用程式速度變慢。
  5. 地球重力場突然變化。

在單一分析工作階段中多次執行相同的程式碼路徑,也是合理的做法。這樣一來,您就能降低上述因素的影響,而緩慢的部分可能會更明顯。

評估、改善、再評估

找出程式中速度較慢的部分後,請試著思考如何改善執行行為。變更程式碼後,請再次剖析。如果對結果感到滿意,請繼續進行。如果沒有看到改善,建議您復原變更,不要因為「反正不會造成傷害」而保留變更。

最佳化策略

盡量減少 DOM 互動

如要提升網頁用戶端應用程式的速度,常見的做法是盡量減少 DOM 互動。雖然 JavaScript 引擎的速度已大幅提升,但存取 DOM 的速度並未以相同速度提升。基於實際考量,這種情況也不可能發生 (在螢幕上配置和繪製內容需要時間)。

快取 DOM 節點

每當您從 DOM 擷取節點或節點清單時,請試著思考是否能在後續的運算中重複使用這些節點 (或甚至只是下一個迴圈疊代)。只要您沒有在相關區域實際新增或刪除節點,通常就會發生這種情況。

更新前:

function getElements() {
  return $('.my-class');
}

套用後:

var cachedElements;
function getElements() {
  if (cachedElements) {
    return cachedElements;
  }
  cachedElements = $('.my-class');
  return cachedElements;
}

快取屬性值

就像快取 DOM 節點一樣,您也可以快取屬性值。假設您要為節點樣式的屬性建立動畫。如果您知道只有您 (也就是程式碼的該部分) 會接觸該屬性,則可以快取每次疊代的最後一個值,這樣就不必重複讀取。

更新前:

setInterval(function() {
  var ele = $('#element');
  var left = parseInt(ele.css('left'), 10);
  ele.css('left', (left + 5) + 'px');
}, 1000 / 30);

套用後: js var ele = $('#element'); var left = parseInt(ele.css('left'), 10); setInterval(function() { left += 5; ele.css('left', left + 'px'); }, 1000 / 30);

將 DOM 操控移出迴圈

迴圈通常是最佳化的熱點。請試著找出方法,將實際的數字運算與 DOM 處理作業分開。通常可以先執行計算,完成後再一次套用所有結果。

更新前:

document.getElementById('target').innerHTML = '';
for(var i = 0; i < array.length; i++) {
  var val = doSomething(array[i]);
  document.getElementById('target').innerHTML += val;
}

套用後:

var stringBuilder = [];
for(var i = 0; i < array.length; i++) {
  var val = doSomething(array[i]);
  stringBuilder.push(val);
}
document.getElementById('target').innerHTML = stringBuilder.join('');

重繪和重排

如先前所述,存取 DOM 的速度相對較慢。如果程式碼讀取的值必須重新計算,因為程式碼最近修改了 DOM 中的相關內容,就會變得非常慢。因此,請避免同時讀取和寫入 DOM。理想情況下,程式碼應一律分為兩個階段:

  • 階段 1:讀取程式碼所需的 DOM 值
  • 階段 2:修改 DOM

請盡量避免編寫類似下列的模式:

  • 階段 1:讀取 DOM 值
  • 階段 2:修改 DOM
  • 階段 3:閱讀更多內容
  • 階段 4:在其他位置修改 DOM。

更新前:

function paintSlow() {
  var left1 = $('#thing1').css('left');
  $('#otherThing1').css('left', left);
  var left2 = $('#thing2').css('left');
  $('#otherThing2').css('left', left);
}

套用後:

function paintFast() {
  var left1 = $('#thing1').css('left');
  var left2 = $('#thing2').css('left');
  $('#otherThing1').css('left', left);
  $('#otherThing2').css('left', left);
}

這項建議適用於在單一 JavaScript 執行環境中發生的動作。(例如在事件處理常式、間隔處理常式中,或處理 AJAX 回應時)。

執行上述 paintSlow() 函式後,會建立以下圖片:

paintSlow()

改用更快的實作方式後,會產生以下圖片:

加速導入技術

這些圖片顯示,重新排序程式碼存取 DOM 的方式,可以大幅提升算繪效能。在這種情況下,原始程式碼必須重新計算樣式並配置網頁兩次,才能產生相同的結果。類似的最佳化作業基本上可套用至所有「實際」程式碼,並產生非常顯著的效果。

詳情請參閱:Stoyan Stefanov 的「Rendering: repaint, reflow/relayout, restyle」(轉譯:重新繪製、重排/重新版面配置、重新設定樣式)

重新繪製和事件迴圈

瀏覽器中的 JavaScript 執行作業遵循「事件迴圈」模型。瀏覽器預設處於「閒置」狀態。使用者互動事件或 JavaScript 計時器或 Ajax 回呼等事件,可能會中斷這個狀態。每當 JavaScript 在這類中斷點執行時,瀏覽器通常會等待執行完畢,再重新繪製畫面 (如果 JavaScript 執行時間極長,或出現有效中斷 JavaScript 執行的快訊方塊等情況,則可能例外)。

後果

  1. 如果 JavaScript 動畫循環的執行時間超過 1/30 秒,您將無法建立流暢的動畫,因為瀏覽器不會在 JS 執行期間重新繪製。如要處理使用者事件,則需要更快的速度。
  2. 有時,延遲執行某些 JavaScript 動作會很有幫助。 例如 setTimeout(function() { ... }, 0) 這會有效指示瀏覽器在事件迴圈再次閒置時立即執行回呼 (實際上,部分瀏覽器會等待至少 10 毫秒)。請注意,這會建立兩個時間非常接近的 JavaScript 執行週期。兩者都可能觸發畫面重新繪製,導致整體繪製時間加倍。是否實際觸發兩次繪製,取決於瀏覽器中的經驗法則。

一般版本:

function paintFast() {
  var height1 = $('#thing1').css('height');
  var height2 = $('#thing2').css('height');
  $('#otherThing1').css('height', '20px');
  $('#otherThing2').css('height', '20px');
}
重繪和事件迴圈

我們來加入一些延遲:

function paintALittleLater() {
  var height1 = $('#thing1').css('height');
  var height2 = $('#thing2').css('height');
  $('#otherThing1').css('height', '20px');
  setTimeout(function() {
    $('#otherThing2').css('height', '20px');
  }, 10)
}
延遲

延遲版本顯示,雖然網頁的兩次變更僅相隔 1/100 秒,但瀏覽器繪製了兩次。

延遲初始化

使用者希望網頁應用程式能快速載入,並能迅速回應。不過,使用者對速度緩慢的感知門檻會因動作而異。舉例來說,應用程式不應在滑鼠懸停事件中執行大量運算,因為這可能會在使用者繼續移動滑鼠時造成不良的使用者體驗。不過,使用者習慣在點選按鈕後等待一小段時間。

因此,建議您盡可能延後執行初始化程式碼 (例如,當使用者點選按鈕,啟用應用程式的特定元件時)。

變更前: js var things = $('.ele > .other * div.className'); $('#button').click(function() { things.show() });

套用後: js $('#button').click(function() { $('.ele > .other * div.className').show() });

事件委派

在網頁上分散事件處理常式可能需要較長時間,而且動態替換元素後,還需要重新將事件處理常式附加至新元素,這也可能相當繁瑣。

在這種情況下,解決方法是使用稱為事件委派的技術。許多瀏覽器事件都具有冒泡特性,因此我們不會將個別事件處理常式附加至元素,而是將事件處理常式附加至父項節點,並檢查事件的目標節點,確認是否為所需事件。

在 jQuery 中,這可以輕鬆表示為:

$('#parentNode').delegate('.button', 'click', function() { ... });

不適合使用事件委派的時機

有時情況可能相反:您使用事件委派,但遇到效能問題。基本上,事件委派可讓初始化時間維持不變。不過,每次叫用事件時,都必須檢查事件是否為感興趣的事件。這可能會耗費大量資源,尤其是「mouseover」或「mousemove」等頻繁發生的事件。

常見問題和解決方案

我在$(document).ready做的事需要很長時間

Malte 的個人建議:千萬不要在 $(document).ready 中執行任何操作。請盡量提供最終版本的文件。好的,你可以註冊事件監聽器,但只能使用 ID 選取器和/或事件委派。如果是「mousemove」等耗費資源的事件,請延遲註冊,直到需要時再註冊 (相關元素上的 mouseover 事件)。

如果您真的需要執行某些操作 (例如發出 Ajax 要求來取得實際資料),請顯示精美的動畫;如果動畫是 GIF 動畫或類似格式,建議您將動畫納入為資料 URI。

自從我在網頁中加入 Flash 影片後,一切都變得很慢

在網頁中加入 Flash 一律會稍微降低轉譯速度,因為視窗的最終版面配置必須由瀏覽器和 Flash 外掛程式「協商」。如果無法完全避免在網頁上放置 Flash,請務必將「wmode」Flash 參數設為「window」(預設值)。這會停用 HTML 和 Flash 元素組合功能 (您將無法看到位於 Flash 影片上方的 HTML 元素,且 Flash 影片無法設為透明)。這可能會造成不便,但能大幅提升成效。舉例來說,youtube.com 會謹慎避免在主要影片播放器上方放置圖層。

我將內容儲存至 localStorage,但應用程式現在會卡頓

寫入 localStorage 是同步作業,會涉及硬碟旋轉。請勿在動畫播放期間執行「長時間執行」的同步作業。將 localStorage 的存取權移至程式碼中,確保使用者處於閒置狀態,且沒有任何動畫正在執行。

剖析結果指出 jQuery 選取器速度非常慢

首先,請確認選取器可透過 document.querySelectorAll 執行。您可以在 JavaScript 控制台中測試。如有例外狀況,請重新編寫選取器,不要使用 JavaScript 架構的任何特殊擴充功能。這會大幅提升新式瀏覽器中的選取器速度。

如果這項做法沒有幫助,或者您也想在新式瀏覽器中快速載入網頁,請遵循下列指南:

  • 選取器右側應盡可能具體。
  • 使用不常用的標記名稱做為最右側的選取器部分。
  • 如果還是無法解決問題,請考慮重寫內容,以便使用 ID 選取器

所有這些 DOM 操控作業都需要很長的時間

如果插入、移除和更新大量 DOM 節點,速度可能會非常慢。一般來說,您可以產生大型 HTML 字串,並使用 domNode.innerHTML = newHTML 取代舊內容,藉此進行最佳化。請注意,這可能會對可維護性造成不良影響,並在 IE 中建立記憶體連結,因此請務必小心。

另一個常見問題是,初始化程式碼可能會建立大量 HTML。例如,jQuery 外掛程式會將選取方塊轉換成一堆 div,因為這是設計人員想要的,但他們不瞭解使用者體驗最佳做法。如果真的想讓網頁快速載入,請勿這麼做。而是從伺服器端提供所有標記的最終形式。這種做法同樣會造成許多問題,因此請仔細思考是否要為了速度而做出妥協。

工具

  1. JSPerf - 基準測試 JavaScript 的小片段
  2. Firebug - 用於在 Firefox 中剖析
  3. Google Chrome 開發人員工具 (在 Safari 中稱為 WebInspector)
  4. DOM Monster - 最佳化 DOM 效能
  5. DynaTrace Ajax Edition - 用於 Internet Explorer 的剖析和繪製最佳化

延伸閱讀

  1. Google 速度
  2. Paul Irish 談 jQuery 效能
  3. Extreme JavaScript Performance (Slide Deck)