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

SylvanasSun發表於2017-10-08

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

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

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

本文作者為: SylvanasSun(sylvanas.sun@gmail.com).轉載請務必將本段話置於文章開頭處(保留超連結).
本文首發自SylvanasSun Blog,原文連結: sylvanassun.github.io/2017/10/08/…

畫素管道


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

畫素管道
畫素管道

  • JavaScript:該區域其實指的是實現動畫效果的方法,一般使用JavaScript來實現動畫,例如JQueryanimate函式、對一個資料集進行排序或動態新增一些DOM節點等。當然,也可以使用其他的方法來實現動畫效果,像CSSAnimationTransitionTransform

  • 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 WorkersHTML5提出的一個標準,它可以讓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

降低樣式計算的複雜度


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

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

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

下面是一個複雜的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的複合部分。可以認為ElementBlock的子節點。

<!-- `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是用於定義BlockElement的外觀、狀態或行為的實體。假設,我們有了一個新的需求,對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進行批量讀取與寫入,它可以防止強制佈局同步與佈局抖動。

使用不會觸釋出局與繪製的屬性來實現動畫


在畫素管道一節中,我們發現有種屬性修改後會跳過佈局與繪製階段,這顯然會減少不少效能開銷。目前只有兩種屬性符合這個條件:transformopacity

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

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

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

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

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

參考文獻


相關文章