JavaScript 事件循環與非同步:深入了解 requestAnimationFrame
簡介
在瀏覽器中執行動畫或視覺效果時,效能與流暢度往往是使用者體驗的關鍵。傳統上,我們會使用 setTimeout 或 setInterval 來排程畫面更新,但這兩者都無法保證與瀏覽器的繪製週期同步,容易造成畫面卡頓或不必要的 CPU、GPU 負擔。
requestAnimationFrame(簡稱 rAF)正是為了解決這些問題而設計的 API。它會在瀏覽器即將執行 repaint(重繪)前呼叫回呼函式,讓開發者的動畫程式碼自動與螢幕刷新頻率(通常是 60 fps)對齊,從而獲得更平滑、更省資源的渲染效果。
本篇文章將從 核心概念、實作範例、常見陷阱 以及 最佳實踐 等多個面向,完整說明 requestAnimationFrame 的使用方式與實務應用,幫助初學者快速上手,也讓中階開發者更深入掌握其底層運作與最佳化技巧。
核心概念
1. 為什麼需要 requestAnimationFrame?
| 方法 | 特性 | 缺點 |
|---|---|---|
setTimeout / setInterval |
任意時間間隔(毫秒) | 1️⃣ 不會與瀏覽器的 repaint 同步 2️⃣ 當頁面不在前景(例如切換分頁)仍會執行,浪費資源 |
requestAnimationFrame |
在瀏覽器即將 repaint 前執行 | ✅ 自動與螢幕刷新頻率同步 ✅ 當頁面隱藏時自動暫停,降低功耗 |
重點:
requestAnimationFrame只在瀏覽器「可見」且「有繪製需求」時呼叫,讓動畫在背景分頁時自動降頻或停止,對行動裝置尤為重要。
2. requestAnimationFrame 的呼叫方式
// 基本語法
const id = requestAnimationFrame(callback);
// 取消排程
cancelAnimationFrame(id);
callback會收到一個 timestamp 參數,代表呼叫時的高解析度時間(單位為毫秒),可用來計算動畫的進度。requestAnimationFrame會回傳一個唯一的 ID,透過cancelAnimationFrame可以在不需要時提前終止。
3. 與事件循環的關係
requestAnimationFrame 的回呼 不會 立即執行,而是被放入 "animation frame" 任務隊列,此隊列位於瀏覽器的 渲染階段(rendering phase)之中。簡化的事件循環流程如下:
- 宏任務 (macrotask):如
setTimeout、網路請求的回呼等。 - 微任務 (microtask):如
Promise.then、MutationObserver。 - 渲染階段:計算樣式、佈局、繪製。
- Animation Frame 任務:執行所有已註冊的
requestAnimationFrame回呼。
因此,requestAnimationFrame 的回呼會在 所有微任務完成且瀏覽器即將渲染 時執行,確保畫面更新的時機最佳化。
4. 使用 timestamp 計算動畫
let start = null;
function step(timestamp) {
if (!start) start = timestamp; // 記錄第一次的時間點
const elapsed = timestamp - start; // 已經過的時間(ms)
// 假設每秒移動 100px
const distance = (elapsed / 1000) * 100;
box.style.transform = `translateX(${distance}px)`;
if (distance < 500) { // 目標距離 500px
requestAnimationFrame(step); // 繼續下一幀
}
}
requestAnimationFrame(step);
- 透過
timestamp可以避免使用固定的setTimeout(16),確保即使瀏覽器掉幀(例如性能瓶頸)時,動畫仍會「補足」缺失的時間,使動畫速度保持一致。
5. 多個 requestAnimationFrame 的協同
瀏覽器會把同一幀內所有註冊的 requestAnimationFrame 回呼一次性執行,順序依註冊先後。這讓我們可以 將不同的動畫拆成多個函式,而不必擔心它們會互相衝突。
function rotate(timestamp) {
const angle = (timestamp / 10) % 360;
square.style.transform = `rotate(${angle}deg)`;
requestAnimationFrame(rotate);
}
function fade(timestamp) {
const opacity = (Math.sin(timestamp / 500) + 1) / 2; // 0~1 波動
circle.style.opacity = opacity;
requestAnimationFrame(fade);
}
// 同時啟動兩個動畫
requestAnimationFrame(rotate);
requestAnimationFrame(fade);
兩段動畫會在同一幀內分別執行,互不干擾,且仍受瀏覽器的渲染節奏控制。
6. 兼容性與 Polyfill
requestAnimationFrame 已在所有主流瀏覽器支援多年,若需支援 IE9 以下舊版瀏覽器,可使用以下 Polyfill:
window.requestAnimationFrame =
window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
function (callback) { return setTimeout(() => callback(Date.now()), 1000 / 60); };
window.cancelAnimationFrame =
window.cancelAnimationFrame ||
window.webkitCancelAnimationFrame ||
window.mozCancelAnimationFrame ||
clearTimeout;
提醒:即使有 Polyfill,舊版瀏覽器仍會以
setTimeout的方式模擬,失去與瀏覽器渲染同步的優勢,建議仍以現代瀏覽器為主要目標。
程式碼範例
以下提供 5 個實用範例,從基礎到進階,展示 requestAnimationFrame 在不同情境下的應用。
範例 1:最簡單的動畫迴圈
let pos = 0;
function move(timestamp) {
pos += 2; // 每幀向右移動 2px
box.style.left = pos + 'px';
if (pos < 300) requestAnimationFrame(move);
}
requestAnimationFrame(move);
說明:只要
pos未達目標,就持續呼叫requestAnimationFrame,形成無限迴圈。
範例 2:使用 timestamp 產生等速動畫
let start = null;
function slide(timestamp) {
if (!start) start = timestamp;
const progress = (timestamp - start) / 1000; // 以秒為單位
const max = 400; // 目標距離
const current = Math.min(progress * 200, max); // 200px/s
ball.style.transform = `translateX(${current}px)`;
if (current < max) requestAnimationFrame(slide);
}
requestAnimationFrame(slide);
重點:使用
timestamp計算實際經過時間,可保證即使掉幀,動畫仍以 200 px/s 的速度前進。
範例 3:結合 Promise 產生「動畫完成」的回傳
function animateTo(element, targetX, duration = 1000) {
return new Promise(resolve => {
const start = performance.now();
const initX = parseFloat(getComputedStyle(element).transform.split(',')[4]) || 0;
const delta = targetX - initX;
function step(timestamp) {
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
const ease = progress < 0.5
? 2 * progress * progress // easeInQuad
: -1 + (4 - 2 * progress) * progress; // easeOutQuad
element.style.transform = `translateX(${initX + delta * ease}px)`;
if (progress < 1) requestAnimationFrame(step);
else resolve(); // 動畫結束
}
requestAnimationFrame(step);
});
}
// 使用方式
animateTo(box, 500).then(() => console.log('動畫完成!'));
說明:把動畫封裝成
Promise,讓呼叫端能以await或.then的方式取得結束時機,方便與其他非同步流程串接。
範例 4:多動畫同步(協調多個元素)
const elems = document.querySelectorAll('.dot');
let start = null;
function sync(timestamp) {
if (!start) start = timestamp;
const t = (timestamp - start) / 1000; // 秒
elems.forEach((el, i) => {
const offset = i * 0.2; // 每個元素延遲 0.2 秒
const phase = Math.sin((t - offset) * Math.PI);
const y = 50 + phase * 30; // 垂直振盪
el.style.transform = `translateY(${y}px)`;
});
requestAnimationFrame(sync);
}
requestAnimationFrame(sync);
技巧:利用
Math.sin產生波形,並加入 phase offset,讓多個元素呈現 波浪式同步動畫。
範例 5:在背景分頁時自動暫停與恢復
let animId;
let lastTime = 0;
function render(timestamp) {
const delta = timestamp - lastTime;
// 只在前景時更新
if (!document.hidden) {
// 更新動畫狀態…
box.style.transform = `rotate(${timestamp / 10}deg)`;
lastTime = timestamp;
}
animId = requestAnimationFrame(render);
}
// 當頁面隱藏或顯示時控制動畫
document.addEventListener('visibilitychange', () => {
if (document.hidden) cancelAnimationFrame(animId);
else animId = requestAnimationFrame(render);
});
animId = requestAnimationFrame(render);
要點:利用
document.hidden與visibilitychange事件,手動停止或恢復requestAnimationFrame,減少背景分頁的 CPU/GPU 負擔。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 忘記取消 | 動畫結束後仍持續呼叫 requestAnimationFrame,導致記憶體泄漏或不必要的運算。 |
在適當時機(如組件 unmount、visibilitychange)使用 cancelAnimationFrame。 |
過度依賴 setTimeout |
把 requestAnimationFrame 包在 setTimeout 內,失去同步優勢。 |
直接在回呼內自行判斷時間或使用 Promise 包裝,不要再套 setTimeout。 |
在 requestAnimationFrame 內做大量同步運算 |
會阻塞渲染,導致掉幀。 | 把繁重計算移至 Web Worker,或拆成多個小步驟分散在多幀。 |
忽略 timestamp |
使用固定步長(如 += 5)會在掉幀時產生速度變慢的感覺。 |
以 timestamp 計算實際經過時間,確保動畫速度恆定。 |
| 在隱藏分頁仍持續動畫 | 會消耗電池、CPU。 | 使用 document.hidden 或 requestIdleCallback 配合暫停動畫。 |
最佳實踐清單
- 永遠使用
timestamp計算動畫進度,避免固定步長。 - 在需要時才呼叫
requestAnimationFrame,不要在全局持續輪詢。 - 將動畫封裝成可重用的函式或 Promise,提升可讀性與可測試性。
- 使用
cancelAnimationFrame清理不再需要的動畫,尤其在單頁應用(SPA)中。 - 結合
visibilitychange,讓背景分頁自動降頻或停止。 - 避免在回呼內直接改變大量 DOM,先收集變更,再一次性寫入(Batch DOM 操作)。
實際應用場景
| 場景 | 為何使用 requestAnimationFrame |
|---|---|
| 視差滾動(Parallax Scrolling) | 滾動事件往往觸發頻繁,使用 rAF 可在每幀一次性計算所有層的位移,避免卡頓。 |
| 遊戲開發 | 需要在 60 fps 內更新角色位置、碰撞檢測等,rAF 與 performance.now() 提供高解析度時間基礎。 |
| 圖表動畫(Chart.js、D3.js) | 逐步顯示資料點或過渡效果,使用 rAF 可確保過渡平滑且與渲染同步。 |
| 自訂滑動條或拖曳交互 | 拖曳過程中即時更新 UI,rAF 能確保視覺更新與滑鼠事件同步。 |
| Loading Spinner / 進度指示 | 透過 rAF 以時間驅動旋轉或變形,省去 setInterval 的額外計時器開銷。 |
| 動畫化 CSS 變數 | 直接在 JavaScript 中改變 --custom-var,配合 rAF 可即時觸發 CSS 的過渡。 |
總結
requestAnimationFrame 是 瀏覽器原生提供的動畫排程器,它的設計核心是 與瀏覽器的渲染週期同步,讓開發者能以最少的資源、最高的流暢度完成視覺效果。透過 timestamp、cancelAnimationFrame 以及 visibilitychange 等機制,我們可以:
- 實作 等速、彈性 的動畫;
- 把動畫 封裝成 Promise,方便與其他非同步流程串接;
- 在 多動畫、背景分頁 等情境下保持效能與資源友好。
在實務開發中,從簡單的位移、旋轉,到複雜的遊戲迴圈或視差滾動,requestAnimationFrame 都是首選工具。只要遵守 避免過度計算、適時取消、善用時間戳 的最佳實踐,便能寫出既 流暢 又 省電 的前端動畫程式碼。
祝你在 JavaScript 的事件循環與非同步世界裡,玩轉 requestAnimationFrame,打造出令人驚艷的使用者體驗! 🚀