瀏覽器性能優化-渲染性能

sttm 7年前發布 | 29K 次閱讀 性能優化 前端技術

瀏覽器渲染過程與性能優化 一文中(建議先去看一下這篇文章再來閱讀本文),我們了解與認識了瀏覽器的關鍵渲染路徑以及如何優化頁面的加載速度。在本文中,我們主要關注的是如何提高瀏覽器的渲染性能(瀏覽器進行布局計算、繪制像素等操作)與效率。

很多網頁都使用了看起來效果非常酷炫的動畫與用戶進行交互,這些動畫效果顯著提高了用戶的體驗,但如果因為性能原因導致動畫的每秒幀數太低,反而會讓用戶體驗變得更差(如果一個酷炫的動畫效果運行起來總是經常卡頓或者看起來反應很慢,這些都會讓用戶感覺糟透了)。

一個流暢的動畫需要保持在每秒60幀,換算成毫秒瀏覽器需要在10毫秒左右完成渲染任務(每秒有1000毫秒,1000/60 約等于 16毫秒一幀,但瀏覽器還有其他工作需要占用時間,所以估算為10毫秒),如果能夠理解瀏覽器的渲染過程并發現性能瓶頸對其優化,可以使你的項目變得具有交互性且動畫效果如飄柔般順滑。

本文作者為: SylvanasSun(sylvanas.sun@gmail.com) .轉載請務必將本段話置于文章開頭處(保留超鏈接).

本文首發自SylvanasSun Blog,原文鏈接: https://sylvanassun.github.io/2017/10/08/2017-10-08-BrowserRenderOptimization/

像素管道

所謂像素管道其實就是瀏覽器將渲染樹繪制成像素的流程。管道的每個區域都有可能產生卡頓,即管道中的某一區域如果發生變化,瀏覽器將會進行自動重排,然后重新繪制受影響的區域。

  • JavaScript: 該區域其實指的是實現動畫效果的方法 ,一般使用 JavaScript 來實現動畫,例如 JQuery 的 animate 函數、對一個數據集進行排序或動態添加一些 DOM 節點等。當然,也可以使用其他的方法來實現動畫效果,像 CSS 的 Animation 、 Transition 和 Transform 。

  • Style: 該區域為樣式計算階段,瀏覽器會根據選擇器(就是 CSS 選擇器,如 .td )計算出哪些節點應用哪些 CSS 規則,然后計算出每個節點的最終樣式并應用到節點上。

  • Layout: 該區域為布局計算階段,瀏覽器會在該過程中根據節點的樣式規則來計算它要占據的空間大小以及在屏幕中的位置 。

  • Paint: 該區域為繪制階段,瀏覽器會先創建繪圖調用的列表,然后填充像素 。繪制階段會涉及到文本、顏色、圖像、邊框和陰影,基本上包括了每個可視部分。繪制一般是在多個圖層(用過 Photoshop 等圖片編輯軟件的童鞋一定很眼熟圖層這個詞,這里的圖層的含義其實是差不多的)上完成的。

  • Composite: 該區域為合成階段,瀏覽器將多個圖層按照正確順序繪制到屏幕上。

假設我們修改了一個幾何屬性(例如寬度、高度等影響布局的屬性),這時Layout階段受到了影響,瀏覽器必須檢查所有其他區域的元素,然后自動重排頁面,任何受到影響的部分都需要重新繪制,并且最終繪制的元素還需要重新進行合成(簡單地說就是整個像素管道都要重新執行一遍)。

如果我們只修改了不會影響頁面布局的屬性,例如背景圖片、文字顏色等,那么瀏覽器會跳過布局階段,但仍需要重新繪制。

又或者,我們只修改了一個不影響布局也不影響繪制的屬性,那么瀏覽器將跳過布局與繪制階段,顯然這種改動是性能開銷最小的。

如果想要知道每個 CSS 屬性將會對哪個階段產生怎樣的影響,請去 CSS Triggers ,該網站詳細地說明了每個 CSS 屬性會影響到哪個階段。

使用RequestAnimationFrame函數實現動畫

我們經常使用 JavaScript 來實現動畫效果,然而時機不當或長時間運行的 JavaScript 可能就是導致你性能下降的原因。

避免使用 setTimeout() 或者 setInterval() 函數來實現動畫效果,這種做法的主要問題是 回調將會在幀中的某個時間點運行,這可能會剛好在末尾(會丟失幀導致發生卡頓)。

有些第三方庫仍在使用 setTimeout()&setInterval() 函數來實現動畫效果,這會產生很多不必要的性能下降,例如老版本的 JQuery ,如果你使用的是 JQuery3 ,那么不必為此擔心, JQuery3 已經全面改寫了動畫模塊,采用了 requestAnimationFrame() 函數來實現動畫效果。但如果你使用的是之前版本的 JQuery ,那么就需要 jquery-requestAnimationFrame 來將 setTimeout() 替換為 requestAnimationFrame() 函數。

讀到這里,想必一定會對 requestAnimationFrame() 產生好奇。要想得到一個流暢的動畫,我們希望讓視覺變化發生在每一幀的開頭,而保證 JavaScript 在幀開始時運行的方式則是使用 requestAnimationFrame() 函數,本質上它與 setTimeout() 沒有什么區別,都是在遞歸調用同一個回調函數來不斷更新畫面以達到動畫的效果, requestAnimationFrame() 的使用方法如下:

function updateScreen(time){
    // 這是你的動畫效果函數
}

// 將你的動畫效果函數放入requestAnimationFrame()作為回調函數
requestAnimationFrame(updateScreen);

并不是所有瀏覽器都支持 requestAnimationFrame() 函數,如 IE9 (又是萬惡的 IE ),但基本上現代瀏覽器都會支持這個功能的,如果你需要兼容老舊版本的瀏覽器,可以使用以下函數。

// 本段代碼截取自Paul Irish : https://gist.github.com/paulirish/1579671
(function(){
    var lastTime = 0;
    var vendors = ['ms', 'moz', 'webkit', 'o'];
    for(var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
        window.requestAnimationFrame = window[vendors[x]+'RequestAnimationFrame'];
        window.cancelAnimationFrame = window[vendors[x]+'CancelAnimationFrame'] 
                                   || window[vendors[x]+'CancelRequestAnimationFrame'];
    }

    // 如果瀏覽器不支持,則使用setTimeout()
    if (!window.requestAnimationFrame)
        window.requestAnimationFrame = function(callback, element){
            var currTime = new Date().getTime();
            var timeToCall = Math.max(0, 16 - (currTime - lastTime));
            var id = window.setTimeout(function(){ callback(currTime + timeToCall); }, 
              timeToCall);
            lastTime = currTime + timeToCall;
            return id;
        };

    if (!window.cancelAnimationFrame)
        window.cancelAnimationFrame = function(id){
            clearTimeout(id);
        };
}());

Web Workers

我們知道 JavaScript 是單線程的,但瀏覽器可不是單線程的 。 JavaScript 在瀏覽器的主線程上運行 ,這恰好與樣式計算、布局等許多其他情況下的渲染操作一起運行, 如果 JavaScript 的運行時間過長,就會阻塞這些后續工作,導致幀丟失。

使用 Chrome 開發者工具的 Timeline 功能可以幫助我們查看每個 JavaScript 腳本的運行時間(包括子腳本),幫助我們發現并突破性能瓶頸。

在找到影響性能的 JavaScript 腳本后,我們可以通過 Web Workers 進行優化。 Web Workers 是 HTML5 提出的一個標準,它 可以讓 JavaScript 腳本運行在后臺線程(類似于創建一個子線程),而后臺線程不會影響到主線程中的頁面 。不過, 使用 Web Workers 創建的線程是不能操作 DOM 樹的 (這也是 Web Workers 沒有顛覆 JavaScript 是單線程的原因, JavaScript 之所以一直是單線程設計主要也是因為為了避免多個腳本操作 DOM 樹的同步問題,這會提高很多復雜性),所以它只適合于做一些純計算的工作(數據的排序、遍歷等)。

如果你的 JavaScript 必須要在主線程中執行,那么只能選擇另一種方法。將一個大任務分割為多個小任務(每個占用時間不超過幾毫秒),并且在每幀的 requestAnimationFrame() 函數中運行:

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);

function processTaskList(taskStartTime){
  var taskFinishTime;

  do {
    // 從列表中彈出任務
    var nextTask = taskList.pop();

    // 執行任務
    processTask(nextTask);

    // 如果有足夠的時間進行下一個任務則繼續執行
    taskFinishTime = window.performance.now();
  } while (taskFinishTime - taskStartTime < 3);

  if (taskList.length > 0)
    requestAnimationFrame(processTaskList);

}

創建一個 Web Workers 對象很簡單,只需要調用 Worker() 構造器,然后傳入指定腳本的 URI 。現代主流瀏覽器均支持 Web Workers ,除了 Internet Explorer (又是萬惡的IE),所以我們在下面的示例代碼中還需要檢測瀏覽器是否兼容。

var myWorker;

if (typeof(Worker) !== "undefined") {
    // 支持Web Workers
    myWorker = new Worker("worker.js");
} else {
    // 不支持Web Workers
}

Web Workers 與主線程之間通過 postMessage() 函數來發送信息,使用 onmessage() 事件處理函數來響應消息(主線程與子線程之間并沒有共享數據,只是通過復制數據來交互)。

main.js: 
// 在主線程js中發送數據到myWorker綁定的js腳本線程
myWorker.postMessage("Hello,World");
console.log('Message posted to worker');

worker.js:
// onmessage處理函數允許我們在任何時刻,
// 一旦接收到消息就可以執行一些代碼,代碼中消息本身作為事件的data屬性進行使用。
onmessage = function(data){
    console.log("Message received from main script.");
    console.log("Posting message back to main script.");
    postMessage("Hello~");
}

main.js:
// 主線程使用onmessage接收消息
myWorker.onmessage = function(data){
    console.log("Received message: " + data);
}

如果你需要從主線程中立刻終止一個運行中的worker,可以調用worker的 terminate() 函數:

myWorker.terminate();

myWorker會被立即殺死,不會有任何機會讓它繼續完成剩下的工作。而在worker線程中也可以調用 close() 函數進行關閉:

close();

有關更多的 Web Workers 使用方法,請參考 Using Web Workers - Web APIs | MDN

降低樣式計算的復雜度

每次修改 DOM 和 CSS 都會導致瀏覽器重新計算樣式 ,在很多情況下還會對頁面或頁面的一部分重新進行布局計算。

計算樣式的第一部分是創建一組匹配選擇器(用于計算哪些節點應用哪些樣式),第二部分涉及從匹配選擇器中獲取所有樣式規則,并計算出節點的最終樣式。

通過降低選擇器的復雜性可以提升樣式計算的速度。

下面是一個復雜的 CSS 選擇器:

.box:nth-last-child(-n+1) .title {
  /* styles */
}

瀏覽器如果想要找到應用該樣式的節點,需要先找到有 .title 類的節點,然后其父節點正好是負n個子元素+1個帶 .box 類的節點。瀏覽器計算此結果可能需要大量的時間,但我們可以把選擇器的預期行為更改為一個類:

.final-box-title {
  /* styles */
}

我們只是將 CSS 的命名模塊化(降低選擇器的復雜性),然后只讓瀏覽器簡單地將選擇器與節點進行匹配,這樣瀏覽器計算樣式的效率會提升許多。

BEM 是一種模塊化的 CSS 命名規范,使用這種方法組織 CSS 不僅結構上十分清晰,也對瀏覽器的樣式查找提供了幫助。

BEM 其實就是 Block,Element,Modifier ,它是一種基于組件的開發方式,其背后的思想就是將用戶界面劃分為獨立的塊。這樣即使是使用復雜的 UI 也可以輕松快速地開發,并且模塊化的方式可以提高代碼的復用性。

Block 是一個功能獨立的頁面組件(可以被重用), Block 的命名方式就像寫 Class 名一樣 。如下面的 .button 就是代表 <button> 的 Block 。

.button {
    background-color: red;
}

<button class="button">I'm a button</button>

Element 是一個不能單獨使用的 Block 的復合部分 。可以認為 Element 是 Block 的子節點。

<!-- `search-form`是一個block -->
<form class="search-form">
    <!-- 'search-form__input'是'search-form' block中的一個element -->
    <input class="search-form__input">

    <!-- 'search-form__button'是'search-form' block中的一個element  -->
    <button class="search-form__button">Search</button>
</form>

Modifier 是用于定義 Block 或 Element 的外觀、狀態或行為的實體 。假設,我們有了一個新的需求,對 button 的背景顏色使用綠色,那么我們可以使用 Modifier 對 .button 進行一次擴展:

.button {
    background-color: red;
}

.button--secondary {
    background-color: green;
}

第一次接觸 BEM 的童鞋可能會對這種命名方式感到奇怪,但 BEM 重要的是模塊化與可維護性的思想,至于命名完全可以按照你所能接受的方式修改。限于篇幅,本文就不再繼續探討 BEM 了,感興趣的童鞋可以去看 BEM的官方文檔

避免強制同步布局和布局抖動

瀏覽器每次進行布局計算時幾乎總是會作用到整個 DOM ,如果有大量元素,那么將會需要很長時間才能計算出所有元素的位置與尺寸。

所以我們 應當盡量避免在運行時動態地修改幾何屬性 (寬度、高度等),因為這些改動都會導致瀏覽器重新進行布局計算。如果無法避免,那么要 優先使用 Flexbox ,它會盡量減少布局所需的開銷。

強制同步布局就是使用 JavaScript 強制瀏覽器提前執行布局 。需要先明白一點, 在 JavaScript 運行時,來自上一幀的所有舊布局值都是已知的。

以下代碼為例,它在每一幀的開頭輸出了元素的高度:

requestAnimationFrame(logBoxHeight);

function logBoxHeight(){
  console.log(box.offsetHeight);
}

但如果在請求高度之前,修改了其樣式,就會出現問題,瀏覽器必須先應用樣式,然后進行布局計算,之后才能返回正確的高度。這是不必要的且會產生非常大的開銷。

function logBoxHeight(){
  box.classList.add('super-big');

  console.log(box.offsetHeight);
}

正確的做法,應該利用瀏覽器可以使用上一幀布局值的特性,然后再執行任何寫操作:

function logBoxHeight(){
  console.log(box.offsetHeight);

  box.classList.add('super-big');
}

如果接二連三地發生強制同步布局,那么就會產生布局抖動。以下代碼循環處理一組段落,并設置每個段落的寬度以匹配一個名為“box”的元素的寬度。

function resizeAllParagraphsToMatchBlockWidth(){
  for (var i = 0; i < paragraphs.length; i++) {
    paragraphs[i].style.width = box.offsetWidth + 'px';
  }
}

這段代碼的問題在于每次迭代都會讀取 box.offsetWidth ,然后立即使用此值來更新段落的寬度。在循環的下次迭代中,瀏覽器必須考慮樣式更新這一事實( box.offsetWidth 是在上一次迭代中請求的),因此它必須應用樣式更改,然后執行布局。這會導致每次迭代都會產生強制同步布局,正確的做法應該先讀取值,然后再寫入值。

// Read.
var width = box.offsetWidth;

function resizeAllParagraphsToMatchBlockWidth(){
  for (var i = 0; i < paragraphs.length; i++) {
    // Now write.
    paragraphs[i].style.width = width + 'px';
  }
}

要想輕松地解決這個問題,可以使用 FastDOM 進行批量讀取與寫入,它可以防止強制布局同步與布局抖動。

使用不會觸發布局與繪制的屬性來實現動畫

在像素管道一節中,我們發現有種屬性修改后會跳過布局與繪制階段,這顯然會減少不少性能開銷。目前只有兩種屬性符合這個條件: transform 和 opacity 。

需要注意的是,使用 transform 和 opacity 時,更改這些屬性所在的元素應處于其自身的圖層, 所以我們需要將設置動畫的元素單獨新建一個圖層(這樣做的好處是該圖層上的重繪可以在不影響其他圖層上元素的情況下進行處理。如果你用過 Photoshop ,想必能夠理解多圖層工作的方便之處)。

創建新圖層的最佳方式是使用 will-change 屬性, 該屬性告知瀏覽器該元素會有哪些變化,這樣瀏覽器可以在元素屬性真正發生變化之前提前做好對應的優化準備工作。

.moving-element {
  will-change: transform;
}

// 對于不支持 will-change 但受益于層創建的瀏覽器,需要使用(濫用)3D 變形來強制創建一個新層
.moving-element {
  transform: translateZ(0);
}

但不要認為 will-change 可以提高性能就隨便濫用,使用 will-change 進行預優化與創建圖層都需要額外的內存和管理開銷,隨便濫用只會得不償失。

 

來自:https://sylvanassun.github.io/2017/10/08/2017-10-08-BrowserRenderOptimization/

 

 本文由用戶 sttm 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!