改善 HTML5 應用程式的效能

Malte Ubl
Malte Ubl

簡介

HTML5 提供多項實用工具,可強化網路應用程式的視覺效果。這在動畫領域尤其明顯。不過,這項新功能也帶來了新的挑戰。其實這些挑戰並非新問題,有時不妨向同事 (也就是 Flash 程式設計師) 詢問,看看他們過去如何克服類似問題。

無論如何,在製作動畫時,讓使用者感覺動畫流暢非常重要。我們必須瞭解,單純提高每秒影格數並不能確保動畫流暢,不幸的是,我們的大腦比這更聰明。您將瞭解,每秒 30 張動畫影格 (fps) 的效果遠優於 60 fps,後者會在過程中遺漏幾張影格。使用者不喜歡鋸齒狀的效果。

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

策略

我們絕不會阻止您使用 HTML5 建構精彩絕倫的應用程式。

接著,如果您發現效能稍微改善,請返回這個頁面,瞭解如何改善應用程式的元素。當然,這有助於一開始就正確執行某些工作,但千萬不要讓這項原則妨礙你提高工作效率。

使用 HTML5 提升視覺效果

硬體加速

硬體加速功能是瀏覽器整體轉譯效能的重要里程碑。一般來說,這項技術會將原本由主 CPU 計算的工作卸載至電腦圖形轉接卡中的圖形處理器 (GPU)。這麼做可以大幅提升效能,並減少行動裝置的資源用量。

文件的這些部分可由 GPU 加速

  • 一般版面配置合成
  • CSS3 轉場效果
  • CSS3 3D 轉換
  • 畫布繪圖
  • 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

許多程式庫現在都已推出動畫 API,可在有轉場效果時加以利用,否則則會改用標準 DOM 樣式的動畫。scripty2YUI 轉場效果強化版 jQuery 動畫

CSS3 翻譯

我相信您之前一定有在頁面上為元素的 x/y 位置製作動畫。您可能會操控內嵌樣式的左側和上方屬性。透過 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 轉換 polyfill 外掛程式,讓整個程序自動化。

window.requestAnimationFrame

requestAnimationFrame 是由 Mozilla 推出,並由 WebKit 進行迭代,目的是提供原生 API 來執行動畫,無論是基於 DOM/CSS 或 <canvas> 或 WebGL 皆可。瀏覽器可將同時執行的動畫一起最佳化,整合為單一重新流布局和重新繪製週期,進而提供更高品質的動畫。例如,以 JS 為基礎的動畫與 CSS 轉場或 SVG SMIL 同步。此外,如果您在不可見的分頁中執行動畫循環,瀏覽器就不會繼續執行動畫,這表示 CPU、GPU 和記憶體的用量會減少,進而延長電池續航力。

如要進一步瞭解如何使用 requestAnimationFrame 以及使用的原因,請參閱 Paul Irish 的文章「requestAnimationFrame 可用於智慧動畫」。

剖析

如果發現應用程式的速度可以改善,就該深入剖析,找出哪些最佳化項目可帶來最大效益。最佳化通常會對原始程式碼的可維護性造成負面影響,因此只有在必要時才應套用。設定剖析後,您就能瞭解程式碼的哪些部分在效能改善後,可帶來最大效益。

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 網路檢查器開發工具包含新的「時間軸檢視畫面」,可顯示瀏覽器執行的低階動作時間軸。您可以利用這些資訊來改善 DOM 作業。您應盡量減少瀏覽器在程式碼執行期間必須執行的「動作」數量。

時間軸檢視畫面可能會產生大量資訊。因此,您應嘗試建立可獨立執行的最低測試案例。

DOM 剖析

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

進一步瞭解時間軸檢視畫面DynaTrace Ajax Edition 是另一個可用於剖析 Internet Explorer 的工具。

剖析策略

單獨顯示某個面向

如要分析應用程式,請盡可能找出可能導致效能變慢的功能。接著,請嘗試執行設定檔,只執行與應用程式相關的程式碼部分。這樣一來,剖析資料就不會與與實際問題無關的程式碼路徑混淆,更容易解讀。應用程式各個方面的良好範例包括:

  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 撰寫的「渲染:重繪、重新流動/重新排版、重新樣式化」

重繪和事件迴圈

瀏覽器中的 JavaScript 執行作業會遵循「事件迴圈」模型。根據預設,瀏覽器會處於「閒置」狀態。這個狀態可能會因使用者互動事件或 JavaScript 計時器或 Ajax 回呼等事件而中斷。每當 JavaScript 在這種中斷點執行時,瀏覽器通常會等待 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」等耗用資源的事件,請延遲註冊,直到需要時才註冊 (在相關元素上執行滑鼠懸停事件)。

如果您真的需要執行一些操作,例如發出 Ajax 要求來取得實際資料,然後顯示精美的動畫,建議您將動畫做為資料 URI 加入,前提是該動畫必須是 GIF 動畫或類似內容。

在頁面中加入 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,因為設計人員不顧 UX 最佳做法,希望能這麼做。如果您真的希望網頁載入速度快,請不要這樣做。而是以最終形式從伺服器端提供所有標記。這麼做會帶來許多問題,因此請仔細思考是否值得犧牲速度。

工具

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

延伸閱讀

  1. Google Speed
  2. Paul Irish 談 jQuery 效能
  3. 極致 JavaScript 效能 (簡報)