大多數瀏覽器以每秒 60 次的頻率刷新畫面。只要瀏覽器來不及在 16 毫秒內準備好下一幀,使用者就會感覺卡頓。本章說明如何優化每個階段,甚至跳過部分階段以提升效率。
建構視角的對應章節:瀏覽器渲染流程
Rendering 流程簡述:
- JavaScript:以 JavaScript 改 DOM 與 CSS 來產生動畫。
- Style Calculations:算出每個元素的 Computed Style。
- 版面配置(Layout):計算元素的位置與大小。
- 繪製(Paint):把元素的文字、顏色、圖片畫到多個合成層(Compositing Layer)上。
- 合成(Composite):依正確順序合併 layer。
JavaScript#
修改樣式的方式很多種:直接改 DOM、Animation API、CSS Animations、Transitions 等等。歸根究柢都是改變元素的 style,常見問題就是花太久或在錯誤的時機改。
requestAnimationFrame#
最適合修改 style 的時點是每一幀的開頭。瀏覽器更新頻率會受環境與其他 JavaScript 影響,使用 setTimeout、setInterval 容易出現太晚執行或一幀內改兩次的情況。
function updateScreen(time) {
// 修改 DOM 來產生動畫
}
requestAnimationFrame(updateScreen);requestAnimationFrame 能確保 callback 在每幀開頭執行,且使用者切換分頁時自動暫停。
Worker#
每幀 16 毫秒中扣除其他階段,安全的執行區間大約只剩 4 毫秒。需要繁重計算(排序、搜尋等)時,可把純計算移到 Web Worker,算完再交回主執行緒修改 DOM。也可考慮 WorkerDOM,在 Worker 內實作了大部分 DOM API。
Debounce#
過於頻繁的修改是浪費。Scroll 是最典型的例子,可以暫存資料並避免一幀內重複註冊 requestAnimationFrame:
function onScroll(event) {
// 把動畫所需的值存起來
lastScrollY = window.scrollY;
// 避免一幀內多次修改 DOM
if (scheduledAnimationFrame) return;
scheduledAnimationFrame = true;
requestAnimationFrame(updateScreen);
}
window.addEventListener("scroll", onScroll);Style Calculations#
算出元素的 Computed Style 要先找出所有命中的 selector,再計算最終樣式。Chrome 在計算 Computed Style 時,有約一半時間花在 selector 比對。
Selector 複雜度#
以下兩個 selector 的代價差異明顯:
body.toggled .box-container .box:nth-child(2n) {
background: #000;
}
.black {
background: #000;
}要切換偶數順位 .box 的背景色時,直接加上 class .black 比 :nth-child(2n) 更有效率,元素數量越多差距越明顯:
const container = document.querySelector(".box-container");
const boxes = container.querySelectorAll(".box");
for (let [index, box] of boxes.entries()) {
if (index % 2 === 1) {
box.classList.toggle("black");
}
}Layout#
每次改變樣式,瀏覽器會檢查哪些元素需要重新 Layout,且只要動到一個元素,其下所有子元素都要一併重算。
Layout Thrashing#
某些行為會強制瀏覽器立即進行 Layout。一次沒問題,但放在迴圈裡就會在一次 rendering 中觸發多次 Layout。
const boxes = document.querySelectorAll(".box");
for (let box of boxes) {
const width = box.offsetWidth; // 強制 Layout
box.style.width = `${width + 10}px`; // 修改 Style
}讀寫穿插會讓 Layout 反覆觸發,這就是 Layout Thrashing。改寫成「先讀完、再一起寫」即可避免:
boxWidth += 10;
const boxes = document.querySelectorAll(".box");
for (let box of boxes) {
box.style.width = `${boxWidth}px`;
}FastDOM#
FastDOM 會把「讀寫讀寫讀寫」重排為「讀讀讀寫寫寫」,將多次 Layout 合併。
哪些 style 觸發 Layout#
只要修改的樣式與排版有關都會觸發 Layout,DOM 變更與 resize 也是。但若只改顏色相關屬性就能跳過 Layout 進入 Paint。哪些屬性會觸發可參考 CSS Triggers。
哪些操作強制 Layout#
讀取 offsetWidth、getBoundingClientRect 等屬性會強制瀏覽器立刻完成 Layout,這正是 Layout Thrashing 的元凶。
Paint#
DevTools 的 Rendering、Layers 工具能快速定位 Paint 瓶頸。
Layer 切割#
為了重複利用上次的繪製結果,瀏覽器會自行把元素分到不同的合成層;遇到瓶頸時,也可以用以下兩種寫法手動切:
.layer {
will-change: transform;
}
.more-layer {
transform: translateZ(0);
}降低繪製範圍與複雜度#
重繪範圍是合成層內所有元素的聯集,左上與右下各一個點就會讓整個畫面被重繪。模糊類樣式(如 box-shadow 的 blur-radius)通常代價較高。
哪些 style 觸發 Paint#
除了 transform 與 opacity 外,幾乎任何 style 變動都會觸發繪製;反之只動這兩個屬性就能跳過 Layout 與 Paint。若動畫無法限制在這兩個屬性,可以嘗試 FLIP 技巧:先算出位移再以 transform、opacity 完成;FLIP 還可以做出 position: fixed 與 position: relative 之間的過渡,這是 transition 做不到的。
Compositing#
到合成階段,主要的優化方向就是減少 layer 數量。多數情況下把元素獨立成 layer 確實能提升效能,但這是用空間換時間,每多一層 layer 就要多一份記憶體:
* {
will-change: transform;
transform: translateZ(0);
}不要在沒有測量的情況下把所有元素都丟到新 layer。
原文出處#
- 原書/iThome:https://ithelp.ithome.com.tw/articles/10249554