Web網頁效能管理詳解

阮一峰的網路日誌發表於2015-09-17

你遇到過效能很差的網頁嗎?

這種網頁響應非常緩慢,佔用大量的 CPU 和記憶體,瀏覽起來常常有卡頓,頁面的動畫效果也不流暢。

你會有什麼反應?我猜想,大多數使用者會關閉這個頁面,改為訪問其他網站。作為一個開發者,肯定不願意看到這種情況,怎樣才能提高效能呢?

本文將詳細介紹效能問題的出現原因,以及解決方法。

一、網頁生成的過程

要理解網頁效能為什麼不好,就要了解網頁是怎麼生成的。

網頁的生成過程,大致可以分成五步。

  • HTML 程式碼轉化成 DOM
  • CSS 程式碼轉化成 CSSOM(CSS Object Model)
  • 結合 DOM 和 CSSOM,生成一棵渲染樹(包含每個節點的視覺資訊)
  • 生成佈局(layout),即將所有渲染樹的所有節點進行平面合成
  • 將佈局繪製(paint)在螢幕上

這五步裡面,第一步到第三步都非常快,耗時的是第四步和第五步。

“生成佈局”(flow)和”繪製”(paint)這兩步,合稱為”渲染”(render)。

二、重排和重繪

網頁生成的時候,至少會渲染一次。使用者訪問的過程中,還會不斷重新渲染。

以下三種情況,會導致網頁重新渲染。

  • 修改 DOM
  • 修改樣式表
  • 使用者事件(比如滑鼠懸停、頁面滾動、輸入框鍵入文字、改變視窗大小等等)

重新渲染,就需要重新生成佈局和重新繪製。前者叫做”重排”(reflow),後者叫做”重繪”(repaint)。

需要注意的是,”重繪”不一定需要”重排”,比如改變某個網頁元素的顏色,就只會觸發”重繪”,不會觸發”重排”,因為佈局沒有改變。但是,”重排”必然導致”重繪”,比如改變一個網頁元素的位置,就會同時觸發”重排”和”重繪”,因為佈局改變了。

三、對於效能的影響

重排和重繪會不斷觸發,這是不可避免的。但是,它們非常耗費資源,是導致網頁效能低下的根本原因。

提高網頁效能,就是要降低”重排”和”重繪”的頻率和成本,儘量少觸發重新渲染。

前面提到,DOM 變動和樣式變動,都會觸發重新渲染。但是,瀏覽器已經很智慧了,會盡量把所有的變動集中在一起,排成一個佇列,然後一次性執行,儘量避免多次重新渲染。

div.style.color = 'blue';
div.style.marginTop = '30px';

上面程式碼中,div 元素有兩個樣式變動,但是瀏覽器只會觸發一次重排和重繪。

如果寫得不好,就會觸發兩次重排和重繪。

div.style.color = 'blue';
var margin = parseInt (div.style.marginTop);
div.style.marginTop = (margin + 10) + 'px';

上面程式碼對 div 元素設定背景色以後,第二行要求瀏覽器給出該元素的位置,所以瀏覽器不得不立即重排。

一般來說,樣式的寫操作之後,如果有下面這些屬性的讀操作,都會引發瀏覽器立即重新渲染。

  • offsetTop/offsetLeft/offsetWidth/offsetHeight
  • scrollTop/scrollLeft/scrollWidth/scrollHeight
  • clientTop/clientLeft/clientWidth/clientHeight
  • getComputedStyle ()

所以,從效能角度考慮,儘量不要把讀操作和寫操作,放在一個語句裡面。

// bad
div.style.left = div.offsetLeft + 10 + "px";
div.style.top = div.offsetTop + 10 + "px";

// good
var left = div.offsetLeft;
var top  = div.offsetTop;
div.style.left = left + 10 + "px";
div.style.top = top + 10 + "px";

一般的規則是:

  1. 樣式表越簡單,重排和重繪就越快。
  2. 重排和重繪的 DOM 元素層級越高,成本就越高。
  3. table 元素的重排和重繪成本,要高於 div 元素

四、提高效能的九個技巧

有一些技巧,可以降低瀏覽器重新渲染的頻率和成本。

第一條是上一節說到的,DOM 的多個讀操作(或多個寫操作),應該放在一起。不要兩個讀操作之間,加入一個寫操作。

第二條,如果某個樣式是通過重排得到的,那麼最好快取結果。避免下一次用到的時候,瀏覽器又要重排。

第三條,不要一條條地改變樣式,而要通過改變 class,或者 csstext 屬性,一次性地改變樣式。

// bad
var left = 10;
var top = 10;
el.style.left = left + "px";
el.style.top  = top  + "px";

// good 
el.className += " theclassname";

// good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";

第四條,儘量使用離線 DOM,而不是真實的網面 DOM,來改變元素樣式。比如,操作 Document Fragment 物件,完成後再把這個物件加入 DOM。再比如,使用 cloneNode () 方法,在克隆的節點上進行操作,然後再用克隆的節點替換原始節點。

第五條,先將元素設為 display: none (需要 1 次重排和重繪),然後對這個節點進行 100 次操作,最後再恢復顯示(需要 1 次重排和重繪)。這樣一來,你就用兩次重新渲染,取代了可能高達 100 次的重新渲染。

第六條,position 屬性為 absolute 或 fixed 的元素,重排的開銷會比較小,因為不用考慮它對其他元素的影響。

第七條,只在必要的時候,才將元素的 display 屬性為可見,因為不可見的元素不影響重排和重繪。另外,visibility : hidden 的元素只對重排有影響,不影響重繪。

第八條,使用虛擬 DOM 的指令碼庫,比如 React 等。

第九條,使用 window.requestAnimationFrame ()、window.requestIdleCallback () 這兩個方法調節重新渲染(詳見後文)。

五、重新整理率

很多時候,密集的重新渲染是無法避免的,比如 scroll 事件的回撥函式和網頁動畫。

網頁動畫的每一幀(frame)都是一次重新渲染。每秒低於 24 幀的動畫,人眼就能感受到停頓。一般的網頁動畫,需要達到每秒 30 幀到 60 幀的頻率,才能比較流暢。如果能達到每秒 70 幀甚至 80 幀,就會極其流暢。

大多數顯示器的重新整理頻率是 60Hz,為了與系統一致,以及節省電力,瀏覽器會自動按照這個頻率,重新整理動畫(如果可以做到的話)。

所以,如果網頁動畫能夠做到每秒 60 幀,就會跟顯示器同步重新整理,達到最佳的視覺效果。這意味著,一秒之內進行 60 次重新渲染,每次重新渲染的時間不能超過 16.66 毫秒。

一秒之間能夠完成多少次重新渲染,這個指標就被稱為”重新整理率”,英文為 FPS(frame per second)。60 次重新渲染,就是 60FPS。

六、開發者工具的 Timeline 皮膚

Chrome 瀏覽器開發者工具的 Timeline 皮膚,是檢視”重新整理率”的最佳工具。這一節介紹如何使用這個工具。

首先,按下 F12 開啟”開發者工具”,切換到 Timeline 皮膚。

左上角有一個灰色的圓點,這是錄製按鈕,按下它會變成紅色。然後,在網頁上進行一些操作,再按一次按鈕完成錄製。

Timeline 皮膚提供兩種檢視方式:橫條的是”事件模式”(Event Mode),顯示重新渲染的各種事件所耗費的時間;豎條的是”幀模式”(Frame Mode),顯示每一幀的時間耗費在哪裡。

先看”事件模式”,你可以從中判斷,效能問題發生在哪個環節,是 JavaScript 的執行,還是渲染?

不同的顏色表示不同的事件。

  • 藍色:網路通訊和 HTML 解析
  • 黃色:JavaScript 執行
  • 紫色:樣式計算和佈局,即重排
  • 綠色:重繪

哪種色塊比較多,就說明效能耗費在那裡。色塊越長,問題越大。

幀模式(Frames mode)用來檢視單個幀的耗時情況。每幀的色柱高度越低越好,表示耗時少。

你可以看到,幀模式有兩條水平的參考線。

下面的一條是 60FPS,低於這條線,可以達到每秒 60 幀;上面的一條是 30FPS,低於這條線,可以達到每秒 30 次渲染。如果色柱都超過 30FPS,這個網頁就有效能問題了。

此外,還可以檢視某個區間的耗時情況。

或者點選每一幀,檢視該幀的時間構成。

七、window.requestAnimationFrame ()

有一些 JavaScript 方法可以調節重新渲染,大幅提高網頁效能。

其中最重要的,就是 window.requestAnimationFrame () 方法。它可以將某些程式碼放到下一次重新渲染時執行。

function doubleHeight (element) {
  var currentHeight = element.clientHeight;
  element.style.height = (currentHeight * 2) + 'px';
}
elements.forEach (doubleHeight);

上面的程式碼使用迴圈操作,將每個元素的高度都增加一倍。可是,每次迴圈都是,讀操作後面跟著一個寫操作。這會在短時間內觸發大量的重新渲染,顯然對於網頁效能很不利。

我們可以使用window.requestAnimationFrame (),讓讀操作和寫操作分離,把所有的寫操作放到下一次重新渲染。

function doubleHeight (element) {
  var currentHeight = element.clientHeight;
  window.requestAnimationFrame (function () {
    element.style.height = (currentHeight * 2) + 'px';
  });
}
elements.forEach (doubleHeight);

頁面滾動事件(scroll)的監聽函式,就很適合用 window.requestAnimationFrame () ,推遲到下一次重新渲染。

$(window) .on ('scroll', function() {
   window.requestAnimationFrame (scrollHandler);
});

當然,最適用的場合還是網頁動畫。下面是一個旋轉動畫的例子,元素每一幀旋轉 1 度。

var rAF = window.requestAnimationFrame;

var degrees = 0;
function update () {
  div.style.transform = "rotate (" + degrees + "deg)";
  console.log ('updated to degrees ' + degrees);
  degrees = degrees + 1;
  rAF (update);
}
rAF (update);

八、window.requestIdleCallback ()

還有一個函式 window.requestIdleCallback (),也可以用來調節重新渲染。

它指定只有當一幀的末尾有空閒時間,才會執行回撥函式。

requestIdleCallback (fn);

上面程式碼中,只有當前幀的執行時間小於 16.66ms 時,函式 fn 才會執行。否則,就推遲到下一幀,如果下一幀也沒有空閒時間,就推遲到下下一幀,以此類推。

它還可以接受第二個引數,表示指定的毫秒數。如果在指定的這段時間之內,每一幀都沒有空閒時間,那麼函式 fn 將會強制執行。

requestIdleCallback (fn, 5000);

上面的程式碼表示,函式 fn 最遲會在 5000 毫秒之後執行。

函式 fn 可以接受一個 deadline 物件作為引數。

requestIdleCallback (function someHeavyComputation (deadline) {
  while(deadline.timeRemaining () > 0) {
    doWorkIfNeeded ();
  }

  if(thereIsMoreWorkToDo) {
    requestIdleCallback (someHeavyComputation);
  }
});

上面程式碼中,回撥函式 someHeavyComputation 的引數是一個 deadline 物件。

deadline 物件有一個方法和一個屬性:timeRemaining () 和 didTimeout。

(1)timeRemaining () 方法

timeRemaining () 方法返回當前幀還剩餘的毫秒。這個方法只能讀,不能寫,而且會動態更新。因此可以不斷檢查這個屬性,如果還有剩餘時間的話,就不斷執行某些任務。一旦這個屬性等於0,就把任務分配到下一輪requestIdleCallback

前面的示例程式碼之中,只要當前幀還有空閒時間,就不斷呼叫 doWorkIfNeeded 方法。一旦沒有空閒時間,但是任務還沒有全執行,就分配到下一輪requestIdleCallback

(2)didTimeout 屬性

deadline 物件的 didTimeout 屬性會返回一個布林值,表示指定的時間是否過期。這意味著,如果回撥函式由於指定時間過期而觸發,那麼你會得到兩個結果。

  • timeRemaining 方法返回0
  • didTimeout 屬性等於 true

因此,如果回撥函式執行了,無非是兩種原因:當前幀有空閒時間,或者指定時間到了。

function myNonEssentialWork (deadline) {
  while ((deadline.timeRemaining () > 0 || deadline.didTimeout) && tasks.length > 0)
    doWorkIfNeeded ();

  if (tasks.length > 0)
    requestIdleCallback (myNonEssentialWork);
}

requestIdleCallback (myNonEssentialWork, 5000);

上面程式碼確保了,doWorkIfNeeded 函式一定會在將來某個比較空閒的時間(或者在指定時間過期後)得到反覆執行。

requestIdleCallback 是一個很新的函式,剛剛引入標準,目前只有 Chrome 支援。

九、參考連結

相關文章