如何優化像素管道的 Styles 和 Layout?
19 Jul 2018更詳細探討如何優化像素管道(Browser Rendering Pipeline)的樣式計算(Recalculate Styles)和版面配置(Recaculate Layout / Reflow)這兩個階段。
簡化 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)就必須更新,導致瀏覽器馬上計算樣式和配置版面,接著更新樣式,使得瀏覽器進入讀取/寫入的循環。
圖片來源: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」後,將藍色區塊的寬度,設定與綠色區塊的寬度相同。
這部分的程式碼是這樣寫的…先讀取 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`;
}
};
這樣的程式碼導致 Layout Thrashing,從 Chrome DevTools 會在右上角用紅色三角形標記這個 Style Calculations 和 Layout 階段。
造成這個問題的原因是由於在迴圈中讀取 greenBlock.offsetWidth
的值,導致瀏覽器必須不斷做樣式計算和版面配置才能提供接下來 paragraphs[p].style.width
所需的值,說白話就是強迫在此幀取得更新值,並做樣式更新,一直打斷重來的概念。
超多紅色警示的,大概有兩百多個 XD
這密密麻麻的紅色警示就是不斷的 Style Calculations -> Layout,下圖放大近看。
Details 上會看到警示訊息「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 是一併批次處理完成的,也不再看到警示訊息,可點此下載。
總結
總會有人問「使用 JavaScript 或 CSS 實作動畫,哪一個效能比較好?」,但實際上應從檢視像素管道(Browser Rendering Pipeline)著手,使用 width 或 position 的成本是比較高的,無論使用 JavaScript 或 CSS 都會導致較差的效能;在錯誤時機使用 JavaScript 可能造成強制同步版面配置(Forced Synchronous Layout)或快速連續版面配置(Layout Thrashing)也會導致效能不佳。