本文 AI 產出,尚未審核

JavaScript 事件循環與非同步:深入了解 requestAnimationFrame


簡介

在瀏覽器中執行動畫或視覺效果時,效能與流暢度往往是使用者體驗的關鍵。傳統上,我們會使用 setTimeoutsetInterval 來排程畫面更新,但這兩者都無法保證與瀏覽器的繪製週期同步,容易造成畫面卡頓或不必要的 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)之中。簡化的事件循環流程如下:

  1. 宏任務 (macrotask):如 setTimeout、網路請求的回呼等。
  2. 微任務 (microtask):如 Promise.thenMutationObserver
  3. 渲染階段:計算樣式、佈局、繪製。
  4. 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.hiddenvisibilitychange 事件,手動停止或恢復 requestAnimationFrame,減少背景分頁的 CPU/GPU 負擔。


常見陷阱與最佳實踐

陷阱 說明 解決方案
忘記取消 動畫結束後仍持續呼叫 requestAnimationFrame,導致記憶體泄漏或不必要的運算。 在適當時機(如組件 unmount、visibilitychange)使用 cancelAnimationFrame
過度依賴 setTimeout requestAnimationFrame 包在 setTimeout 內,失去同步優勢。 直接在回呼內自行判斷時間或使用 Promise 包裝,不要再套 setTimeout
requestAnimationFrame 內做大量同步運算 會阻塞渲染,導致掉幀。 把繁重計算移至 Web Worker,或拆成多個小步驟分散在多幀。
忽略 timestamp 使用固定步長(如 += 5)會在掉幀時產生速度變慢的感覺。 timestamp 計算實際經過時間,確保動畫速度恆定。
在隱藏分頁仍持續動畫 會消耗電池、CPU。 使用 document.hiddenrequestIdleCallback 配合暫停動畫。

最佳實踐清單

  1. 永遠使用 timestamp 計算動畫進度,避免固定步長。
  2. 在需要時才呼叫 requestAnimationFrame,不要在全局持續輪詢。
  3. 將動畫封裝成可重用的函式或 Promise,提升可讀性與可測試性。
  4. 使用 cancelAnimationFrame 清理不再需要的動畫,尤其在單頁應用(SPA)中。
  5. 結合 visibilitychange,讓背景分頁自動降頻或停止。
  6. 避免在回呼內直接改變大量 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瀏覽器原生提供的動畫排程器,它的設計核心是 與瀏覽器的渲染週期同步,讓開發者能以最少的資源、最高的流暢度完成視覺效果。透過 timestampcancelAnimationFrame 以及 visibilitychange 等機制,我們可以:

  • 實作 等速、彈性 的動畫;
  • 把動畫 封裝成 Promise,方便與其他非同步流程串接;
  • 多動畫背景分頁 等情境下保持效能與資源友好。

在實務開發中,從簡單的位移、旋轉,到複雜的遊戲迴圈或視差滾動,requestAnimationFrame 都是首選工具。只要遵守 避免過度計算、適時取消、善用時間戳 的最佳實踐,便能寫出既 流暢省電 的前端動畫程式碼。

祝你在 JavaScript 的事件循環與非同步世界裡,玩轉 requestAnimationFrame,打造出令人驚艷的使用者體驗! 🚀