大多數瀏覽器以每秒 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 影響,使用 setTimeoutsetInterval 容易出現太晚執行或一幀內改兩次的情況。

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#

讀取 offsetWidthgetBoundingClientRect 等屬性會強制瀏覽器立刻完成 Layout,這正是 Layout Thrashing 的元凶。

Paint#

DevTools 的 Rendering、Layers 工具能快速定位 Paint 瓶頸。

Layer 切割#

為了重複利用上次的繪製結果,瀏覽器會自行把元素分到不同的合成層;遇到瓶頸時,也可以用以下兩種寫法手動切:

.layer {
  will-change: transform;
}
.more-layer {
  transform: translateZ(0);
}

降低繪製範圍與複雜度#

重繪範圍是合成層內所有元素的聯集,左上與右下各一個點就會讓整個畫面被重繪。模糊類樣式(如 box-shadowblur-radius)通常代價較高。

哪些 style 觸發 Paint#

除了 transformopacity 外,幾乎任何 style 變動都會觸發繪製;反之只動這兩個屬性就能跳過 Layout 與 Paint。若動畫無法限制在這兩個屬性,可以嘗試 FLIP 技巧:先算出位移再以 transformopacity 完成;FLIP 還可以做出 position: fixedposition: relative 之間的過渡,這是 transition 做不到的。

Compositing#

到合成階段,主要的優化方向就是減少 layer 數量。多數情況下把元素獨立成 layer 確實能提升效能,但這是用空間換時間,每多一層 layer 就要多一份記憶體:

* {
  will-change: transform;
  transform: translateZ(0);
}

不要在沒有測量的情況下把所有元素都丟到新 layer。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10249554