文章中英模式
常見的前端面試題目 - 用戶交互優化 - 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.7mssetTimeout vs requestAnimationFrame
| 特性 | setTimeout | requestAnimationFrame |
|---|---|---|
| 執行時機 | 指定時間後 (不精確) | 瀏覽器重繪前 (與顯示器同步) |
| 背景標籤頁 | 繼續執行 (浪費資源) | 自動暫停 (節省資源) |
| 動畫流暢度 | 易掉幀 | 減少掉幀 |
| 電池消耗 | 較高 | 較低 |
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