阮一峰:網頁性能管理詳解

jopen 9年前發布 | 9K 次閱讀 網頁性能

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

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

阮一峰:網頁性能管理詳解

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

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

一、網頁生成的過程

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

阮一峰:網頁性能管理詳解

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

  1. HTML 代碼轉化成 DOM
  2. CSS 代碼轉化成 CSSOM(CSS Object Model)
  3. 結合 DOM 和 CSSOM,生成一棵渲染樹(包含每個節點的視覺信息)
  4. 生成布局(layout),即將所有渲染樹的所有節點進行平面合成
  5. 將布局繪制(paint)在屏幕上

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

"生成布局"(flow)和"繪制"(paint)這兩步,合稱為"渲染"(render)。

阮一峰:網頁性能管理詳解

二、重排和重繪

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

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

  1. 修改 DOM
  2. 修改樣式表
  3. 用戶事件(比如鼠標懸停、頁面滾動、輸入框鍵入文字、改變窗口大小等等)

重新渲染,就需要重新生成布局和重新繪制。前者叫做"重排"(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 元素設置背景色以后,第二行要求瀏覽器給出該元素的位置,所以瀏覽器不得不立即重排。

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

  1. offsetTop/offsetLeft/offsetWidth/offsetHeight
  2. scrollTop/scrollLeft/scrollWidth/scrollHeight
  3. clientTop/clientLeft/clientWidth/clientHeight
  4. 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 支持。

九、參考鏈接

  1. Domenico De Felice, How browsers work
  2. Stoyan Stefanov, Rendering: repaint, reflow/relayout, restyle
  3. Addy Osmani, Improving Web App Performance With the Chrome DevTools Timeline and Profiles
  4. Tom Wiltzius, Jank Busting for Better Rendering Performance
  5. Paul Lewis, Using requestIdleCallback
 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!