如何優化像素管道的 Styles 和 Layout?

更詳細探討如何優化像素管道(Browser Rendering Pipeline)的樣式計算(Recalculate Styles)和版面配置(Recaculate Layout / Reflow)這兩個階段。

Browser Rendering Pipeline

圖片來源:Rendering Performance

簡化 Selector Matching

Selector Matching 是比對哪些樣式規則應該要應用到哪些 DOM Element 的過程。

查找的範例如下。

<div class="box"></div>
<div class="box"></div>
<div class="box b-3"></div>
.box: nth-child(3) .box .b-3;

若樣式規則只是要找一個元素還好,而樣式變更的複雜度是隨著元素個數而線性成長的,若有 10 個要改的元素,成本就是多 10 倍。舉例來說,在查找一個元素的情況下,.box:nth-child(3) 的花費假設是 2ms,.box .b-3 假設是 1ms,若要查找 100 個元素,.box:nth-child(3) 花費 200ms,.box .b-3 花費 100ms,足足多了一倍。這是由於愈複雜的樣式規則,需要在 Render Tree 來回查找的時間就更多。

解法是使用 BEM,或類似的 CSS Class 命名設計模式。BEM 的命名方式順道簡化了樣式規則,並且由於 Class Matching 是現今瀏覽器最快的比對方法,因此 BEM 不僅顧及模組化、重用性與可讀性,還兼顧改善效能。

所以,上面的範例可改寫為

.box--three

不然就是思考怎麼減少 DOM Element 了。

Forced Synchronous Layout

強制同步版面配置(Forced Synchronous Layout,簡稱 FSL)發生的原因是由於在 JavaScript 程式碼中觸發 Layout 階段的 CSS 指令,例如:讀取某個元素的 offsetWidth 值,就會強迫瀏覽器在此幀(Frame)就必須更新,導致瀏覽器馬上計算樣式和配置版面,接著更新樣式,使得瀏覽器進入讀取/寫入的循環。

Forced Synchronous Layout

圖片來源:Browser Rendering Optimization: Styles and Layout

看文字描述還真不好理解,來看範例吧。

以下分別有 A、B、C 三個範例,哪一個不會造成 Forced Synchronous Layout?

(A)

divs.forEach(function (elem, index, arr) {
  if (window.scrollY < 200) {
    element.style.opacity = 0.5;
  }
});

(B)

divs.forEach(function (elem, index, arr) {
  if (elem.offsetHeight < 500) {
    elem.style.maxHeight = '100vh';
  }
});

(C)

var newWidth = container.offsetWidth;

divs.forEach(function (elem, index, arr) {
  element.style.width = newWidth;
});

防雷-第一次… ヽ(∀゚ )人(゚∀゚)人( ゚∀)人(∀゚ )人(゚∀゚)人( ゚∀)ノ

防雷-第二次… ヽ(∀゚ )人(゚∀゚)人( ゚∀)人(∀゚ )人(゚∀゚)人( ゚∀)ノ

防雷-最後提醒… ヽ(∀゚ )人(゚∀゚)人( ゚∀)人(∀゚ )人(゚∀゚)人( ゚∀)ノ

答案是(C)。

(A) 讀取 window.scrollY 會造成 Layout,接著設定透明度,這過程導致瀏覽器進入讀取/寫入循環,進而不停 Forced Synchronous Layout,會造成 Layout Thrashing。

修正如下,window.scrollY 使用前一幀的值即可,不需要在此幀立刻取得最新更新的值。

const positionY = window.scrollY;

divs.forEach(function (elem, index, arr) {
  if (positionY < 200) {
    element.style.opacity = 0.5;
  }
});

(B) 讀取 elem.offsetHeight 會造成 Layout,接著修改樣式,這過程導致瀏覽器進入讀取/寫入循環,進而不停 Forced Synchronous Layout,會造成 Layout Thrashing。

修正如下,先讀取樣式屬性值,然後批次更新樣式。這意味著使用前幀所取得的資料,接著再一次更新完所有的樣式修改。因此,Browser Rendering Pipeline 的「JavaScript/Styles -> Layout -> Paint -> Composite」只會順暢地走過一次。

if (elem.offsetHeight < 500) {
  // 先讀取樣式屬性值,
  divs.forEach(function (elem, index, arr) {
    // 然後批次更新樣式
    elem.style.maxHeight = '100vh';
  });
}

(C) 將讀取 container.offsetWidth 放在迴圈之外,因此 Layout 只會執行一次,接著瀏覽器會批次更新 element 樣式,不會造成 Forced Synchronous Layout 或 Layout Thrashing。

Layout Thrashing

若不斷地 Forced Synchronous Layout,就會導致 Layout Thrashing。

範例如下,點擊「Click Me」後,將藍色區塊的寬度,設定與綠色區塊的寬度相同。

Layout Thrashing Demo

這部分的程式碼是這樣寫的…先讀取 greenBlock 目前的寬,然後再將這個寬的數值,設定給每一個 paragraphs。

const paragraphs = document.querySelectorAll('p');
const clickme = document.getElementById('clickme');
const greenBlock = document.getElementById('block');

clickme.onclick = function(){
  greenBlock.style.width = '600px';

  for (let p = 0; p < paragraphs.length; p++) {
    let blockWidth = greenBlock.offsetWidth;
    paragraphs[p].style.width = `${blockWidth}px`;
  }
};

程式碼與 Demo,或點此下載。

這樣的程式碼導致 Layout Thrashing,從 Chrome DevTools 會在右上角用紅色三角形標記這個 Style Calculations 和 Layout 階段。

造成這個問題的原因是由於在迴圈中讀取 greenBlock.offsetWidth 的值,導致瀏覽器必須不斷做樣式計算和版面配置才能提供接下來 paragraphs[p].style.width 所需的值,說白話就是強迫在此幀取得更新值,並做樣式更新,一直打斷重來的概念。

Forced Reflow is a likely performance bottleneck

超多紅色警示的,大概有兩百多個 XD

這密密麻麻的紅色警示就是不斷的 Style Calculations -> Layout,下圖放大近看。

Layout Thrashing

Details 上會看到警示訊息「Forced reflow is a likely performance bottleneck.」。

Forced Reflow is a likely performance bottleneck.

解法是不要在迴圈裡面讀取 greenBlock.offsetWidth 的值,而是放到 onclick 的 callback 裡存成常數,迴圈讀取這個常數即可,就可避免迫使瀏覽器不斷重新樣式計算和版面配置,改為一次讀取、批次更新。

clickme.onclick = function () {
  greenBlock.style.width = '600px';
  const blockWidth = greenBlock.offsetWidth;

  for (let p = 0; p < paragraphs.length; p++) {
    paragraphs[p].style.width = `${blockWidth}px`;
  }
};

改善後,在 Chrome DevTools 可看到做完 Style Calculations 後,Layout 是一併批次處理完成的,也不再看到警示訊息,可點此下載。

改善 Layout Thrashing

總結

總會有人問「使用 JavaScript 或 CSS 實作動畫,哪一個效能比較好?」,但實際上應從檢視像素管道(Browser Rendering Pipeline)著手,使用 width 或 position 的成本是比較高的,無論使用 JavaScript 或 CSS 都會導致較差的效能;在錯誤時機使用 JavaScript 可能造成強制同步版面配置(Forced Synchronous Layout)或快速連續版面配置(Layout Thrashing)也會導致效能不佳。

參考資料


Layout Thrashing Forced Synchronous Layout 關鍵轉譯路徑 效能調校 轉譯效能 Rendering Performance Critical Rendering Path BEM Chrome DevTools css css3 animations 前端效能 系列文