魯斯前端布魯斯前端

文章中英模式

常見的前端面試題目 - 用戶交互優化 - RequestAnimationFrame 避免動畫掉幀

深入解析 RequestAnimationFrame API 如何優化前端動畫效能、避免掉幀問題,及與傳統計時器的關鍵區別。附帶實用範例與面試常見問題解析。

影片縮圖

懶得看文章?那就來看影片吧

基本概念

RequestAnimationFrame (rAF) 是瀏覽器提供的用於優化動畫效能的 API,它讓你的動畫能在最適合的時間點執行,確保流暢的視覺體驗。與傳統的 setTimeout 和 setInterval 相比,rAF 可以有效避免動畫掉幀問題。

傳統計時器動畫:
可能在任意時間執行 → 與瀏覽器渲染週期不同步 → 視覺不流暢、掉幀

RequestAnimationFrame:
與瀏覽器渲染流程同步 → 在下一次重繪(Repaint)前執行 → 動畫流暢、效能更好

瀏覽器渲染流程與掉幀原理

瀏覽器的渲染流程通常每秒執行約 60 次(60FPS),若每個渲染週期超過約 16.7ms (1000ms/60),就會出現「掉幀」現象。

瀏覽器執行時期的渲染週期:
每一幀 (約每 16.6ms)

→ 處理 JS 任務佇列(micro/macro tasks)
→ 執行 requestAnimationFrame callbacks ✅
→ 開始 layout(reflow)& paint(repaint)
→ Composite(合成顯示)
→ 下一幀循環

理想情況 (60FPS):
[畫面1] → [畫面2] → [畫面3] → [畫面4] → [畫面5]
16.7ms    16.7ms    16.7ms    16.7ms    16.7ms

掉幀情況:
[畫面1] → [掉幀!] → [畫面3] → [畫面4] → [畫面5]
  25ms      X        16.7ms    16.7ms    16.7ms

setTimeout vs requestAnimationFrame

特性setTimeoutrequestAnimationFrame
執行時機指定時間後 (不精確)瀏覽器重繪前 (與顯示器同步)
背景標籤頁繼續執行 (浪費資源)自動暫停 (節省資源)
動畫流暢度易掉幀減少掉幀
電池消耗較高較低

requestAnimationFrame 基本用法

// 基本使用方式
function animate() {
  // 更新動畫狀態
  element.style.left = `${position++}px`;
  
  // 遞迴調用,創建動畫循環
  requestAnimationFrame(animate);
}

// 開始動畫
requestAnimationFrame(animate);

// 取消動畫
const animationId = requestAnimationFrame(animate);
cancelAnimationFrame(animationId);

實際應用案例:平滑滾動

以下是使用 requestAnimationFrame 實現平滑滾動到頁面頂部的範例:

// 平滑滾動到頁面頂部
function scrollToTop() {
  const currentPosition = window.pageYOffset;
  
  if (currentPosition > 0) {
    // 以每次滾動距離的 10% 速度平滑滾動
    window.scrollTo(0, currentPosition - currentPosition / 10);
    requestAnimationFrame(scrollToTop);
  }
}

// 點擊按鈕啟動平滑滾動
document.querySelector('.scroll-top-button').addEventListener('click', function() {
  requestAnimationFrame(scrollToTop);
});

高級應用:動畫性能優化技巧

1. 避免佈局抖動 (Layout Thrashing)

減少強制重排和重繪是提升動畫效能的關鍵,應該分離讀取和寫入操作:

// 不好的做法:多個元素交替讀寫,導致多次強制重排
function badAnimation() {
  const boxes = document.querySelectorAll('.box');
  
  boxes.forEach(box => {
    // 讀取 DOM (強制瀏覽器計算最新佈局)
    const width = box.offsetWidth;
    
    // 寫入 DOM (改變佈局)
    box.style.width = `${width + 1}px`;
    
    // 再次讀取 (又強制重新計算佈局)
    const height = box.offsetHeight;
    
    // 再次寫入 (又改變佈局)
    box.style.height = `${height + 1}px`;
  });
  
  requestAnimationFrame(badAnimation);
}

// 好的做法:先批量讀取所有數據,再批量寫入
function goodAnimation() {
  const boxes = document.querySelectorAll('.box');
  const measurements = [];
  
  // 讀取階段 - 一次性讀取所有需要的數據
  boxes.forEach(box => {
    measurements.push({
      width: box.offsetWidth,
      height: box.offsetHeight,
      element: box
    });
  });
  
  // 寫入階段 - 一次性完成所有 DOM 修改
  measurements.forEach(data => {
    data.element.style.width = `${data.width + 1}px`;
    data.element.style.height = `${data.height + 1}px`;
  });
  
  requestAnimationFrame(goodAnimation);
}

/*
佈局抖動視覺化比較:

不好的做法 (多次重排):
┌─────────────────────────────────┐
│ 讀取元素1尺寸 → 重排 #1         │
│ 修改元素1尺寸                   │
│ 讀取元素1高度 → 重排 #2         │
│ 修改元素1高度                   │
│ 讀取元素2尺寸 → 重排 #3         │
│ 修改元素2尺寸                   │
│ 讀取元素2高度 → 重排 #4         │
│ 修改元素2高度                   │
│ ...                             │
└─────────────────────────────────┘
結果:一幀內多次強制重排,性能下降

好的做法 (批量處理):
┌─────────────────────────────────┐
│ 讀取所有元素尺寸 → 重排 #1      │
│ 修改所有元素尺寸和高度          │
└─────────────────────────────────┘
結果:一幀內只有一次重排,性能提升
*/

2. 使用 CSS transform 代替修改位置

優先使用影響合成層而非佈局的屬性:

// 不好的做法:修改 left/top (觸發佈局)
function inefficientMove() {
  box.style.left = `${xPos}px`;
  box.style.top = `${yPos}px`;
}

// 好的做法:使用 transform (只觸發合成)
function efficientMove() {
  box.style.transform = `translate(${xPos}px, ${yPos}px)`;
}

常見面試題與解答

(一)requestAnimationFrame 與 setTimeout/setInterval 的主要區別是什麼?

A: requestAnimationFrame 與瀏覽器的繪製週期同步,確保動畫在最佳時間執行;而 setTimeout/setInterval 是固定時間間隔,不考慮瀏覽器渲染流程,容易造成掉幀。另外,rAF 在非可見標籤頁會自動暫停,節省資源;還會自動適應不同刷新率的顯示器。

(二)為什麼 requestAnimationFrame 比 setTimeout 更適合實現動畫?

A: rAF 能減少掉幀現象,降低能源消耗,且與瀏覽器重繪同步;而 setTimeout 的計時不夠精確,可能導致一個週期內多次重繪或完全錯過重繪時機。

(三)如何使用 requestAnimationFrame 實現動畫節流?

// 使用 flag 變數控制是否已有待處理的動畫幀
let ticking = false;

function onScroll(e) {
  // 檢查是否已經有排程的動畫幀
  if (!ticking) {
    // 使用 rAF 確保處理函數在下一次重繪前執行
    // 而不是每次滾動事件觸發都執行(可能一幀內觸發多次)
    requestAnimationFrame(() => {
      doSomethingWithScrollPosition(); // 處理滾動位置的實際邏輯
      ticking = false; // 重設 flag,允許下一幀處理
    });
    ticking = true; // 標記已有待處理的動畫幀
  }
  // 如果 ticking 為 true,則忽略此次滾動事件
  // 直到前一個 rAF 執行完成
}

// 監聽滾動事件
window.addEventListener('scroll', onScroll);

/*
┌─────────────────────────────────────────────────┐
│ 無節流時:                                        │
│ 滾動事件 → 處理 → 滾動事件 → 處理 → 滾動事件 → 處理 │
│ (可能一幀內觸發10+次,造成效能問題)                │
│                                                 │
│ 使用 rAF 節流後:                                 │
│ 滾動事件 → 滾動事件 → 滾動事件 → rAF(處理一次) → ... │
│ └─── 一個渲染週期/幀 ─────┘                      │
└─────────────────────────────────────────────────┘
*/

(四)requestAnimationFrame 在不同刷新率的顯示器上會有什麼表現?

A: rAF 會自動適應顯示器的刷新率。在 60Hz 的顯示器上約每 16.7ms 執行一次,而在 120Hz 的顯示器上約每 8.3ms 執行,確保動畫在各種設備上都有最佳表現。

(五)requestAnimationFrame 與 Event Loop 的關係是什麼?

A: requestAnimationFrame 在事件循環中有特殊的執行時機。它會在每一幀的微任務(microtasks)執行完畢後、渲染前被調用,具體順序為:

1. 執行同步代碼
2. 執行微任務 (Promise, MutationObserver 等)
3. 執行 requestAnimationFrame 回調
4. 執行渲染 (Layout, Paint, Composite)
5. 執行宏任務 (setTimeout, setInterval 等)
6. 重複步驟 1-5