喂!我是 Wei

Front-End Engineer

Be a Problem Solver.

⌘K

導覽

所有文章緣起互動小功能

文章分類

目錄
CSS transition 為什麼做不到requestAnimationFrame 的思維速度設計:怎麼達到「10 圈 / 8 秒」狀態機設計tickRef 模式:讓 rAF 自我呼叫不出錯tick 主迴圈緩停算法:動態 easinggetAlignDelta:計算目標角度差值dynamicEaseOut:讓起步速度平滑接續handleStart 與 handleStopCSS transition 與 rAF 的選擇成品展示

相關文章

用 Vercel AI SDK v6 在 Next.js 加上 AI 聊天助手(免費用 Groq)

2026年3月20日

用 Canvas 做刮刮樂:destination-out 的原理與實現

2026年3月11日

從 jQuery 到 TypeScript:打造轉盤抽獎元件

2026年3月9日

最新文章
全部 →
前端 CI/CD 與正式環境除錯:從 Pull Request 到事故排查
2026-06-24
即時資料怎麼選?Polling、SSE、WebSocket 比較
2026-06-23
前端系統設計:如何拆元件、資料流與大型專案架構?
2026-06-22
無障礙不是加 ARIA:語意化 HTML、鍵盤操作與焦點管理
2026-06-21
CSS 與 RWD 面試整理:Flexbox、Grid、定位與層疊脈絡
2026-06-19
← 返回文章列表

用 requestAnimationFrame 實作可中途停止的轉盤

2026年3月10日·約 8 分鐘閱讀·
TypeScriptReactAnimationrequestAnimationFrame

上一篇把轉盤從 jQuery 改寫成 TypeScript + Canvas,動畫交給 CSS transition 跑完固定 8 秒。這個方案乾淨、夠用,但業主還有一個需求沒實作:

玩家可以在 8 秒內自行按停,停在當下的位置,緩速滑行到對應獎項。

這個需求讓 CSS transition 直接出局。


CSS transition 為什麼做不到

CSS transition 的運作方式:你給它一個起點和終點,它負責動畫,你不需要管中間過程。

這在「固定跑完」的場景完全夠用。但「中途停止」需要兩件事:

  1. 知道此刻轉到幾度了:才能從當前位置計算緩停路徑
  2. 動態改變終點:玩家按停的時機不同,終點就不同

CSS transition 兩件事都做不到——你沒辦法在動畫途中讀出當前值,也沒辦法平滑地改變終點(強行改只會產生跳動)。

requestAnimationFrame 的思維

requestAnimationFrame(rAF)的方式完全不同:你自己逐幀計算,每幀更新角度,讓瀏覽器在下一次繪製前執行你的函式。

瀏覽器準備繪製下一幀
  → 執行你的 tick 函式
    → 更新 currentAngle
    → 呼叫下一個 requestAnimationFrame
  → 瀏覽器繪製
  → 執行你的 tick 函式
    → 更新 currentAngle
    → 呼叫下一個 requestAnimationFrame
  → ...

因為每一幀都在你的掌控裡,你可以:

  • 隨時讀出當前角度(angleRef.current)
  • 隨時切換行為:加速 → 巡航 → 緩停

速度設計:怎麼達到「10 圈 / 8 秒」

業主要求至少旋轉 10 圈、持續 8 秒。要在 rAF 裡達到這個效果,關鍵是巡航速度的設定。

@60fps,8 秒 = 480 幀。要在 480 幀內轉 10 圈(3600°):

3600° ÷ 480 幀 ≈ 7.5 deg/frame

實際取 CRUISE_SPEED = 8,略高於下限,讓視覺上看起來夠快:

const CRUISE_SPEED = 8; // deg/frame,@60fps ≈ 10.7 圈 / 8 秒

起步時不要瞬間跳到巡航速度,每幀逐步加速:

speedRef.current = Math.min(speedRef.current + 0.4, CRUISE_SPEED);
angleRef.current += speedRef.current;

狀態機設計

整個互動流程用四個 phase 描述:

idle → spinning → stopping → done
  • idle:等待開始
  • spinning:巡航中,玩家可隨時按停,或等 8 秒自動觸發
  • stopping:緩速滑行到目標獎項
  • done:停定,顯示結果

phase 同時存在 useState(驅動 UI)和 phaseRef(在 rAF tick 裡讀,避免 stale closure):

const [phase, setPhase] = useState<Phase>("idle");
const phaseRef = useRef<Phase>("idle");
 
// 切換 phase 時兩個都要更新
phaseRef.current = "spinning";
setPhase("spinning");

tickRef 模式:讓 rAF 自我呼叫不出錯

rAF 的使用方式是:在 tick 裡更新角度,然後呼叫下一個 requestAnimationFrame(tick),讓它持續跑。

問題出在「tick 呼叫自己」這件事──在 React 裡,tick 通常是 useCallback,而 useCallback 產生的函式在建立當下就把所有用到的變數「封存」起來(這就是 closure)。如果 tick 直接寫 requestAnimationFrame(tick),跑的永遠是第一次建立的舊版本,不管後來狀態怎麼變。

解法很直觀:不要叫 tick 呼叫自己,改成叫它呼叫一個 ref。ref 的特性是永遠指向最新的值,所以每次執行都保證是最新版本的 tick:

const tickRef = useRef<() => void>(() => {});
 
const tick = useCallback(() => {
  // ...
  requestAnimationFrame(tickRef.current!); // 呼叫 ref,不是直接呼叫自己
}, []);
 
// tick 每次更新,就把 ref 也更新
useEffect(() => {
  tickRef.current = tick;
}, [tick]);

這樣 rAF 每幀執行的,都是最新版本的 tick。

tick 主迴圈

const tick = useCallback(() => {
  const p = phaseRef.current;
 
  if (p === "spinning") {
    // 加速到巡航速度後保持
    speedRef.current = Math.min(speedRef.current + 0.4, CRUISE_SPEED);
    angleRef.current += speedRef.current;
    setDisplayAngle(angleRef.current);
    rafRef.current = requestAnimationFrame(tickRef.current!);
    return;
  }
 
  if (p === "stopping") {
    stopFrameRef.current += 1;
    const t = Math.min(stopFrameRef.current / stopFramesRef.current, 1);
    const eased = dynamicEaseOut(t, stopExpRef.current);
    angleRef.current = stopOriginRef.current + eased * stopDeltaRef.current;
    setDisplayAngle(angleRef.current);
 
    if (t < 1) {
      rafRef.current = requestAnimationFrame(tickRef.current!);
    } else {
      phaseRef.current = "done";
      setPhase("done");
      setResult(prizeRef.current);
    }
  }
}, []);

緩停算法:動態 easing

緩停最難的地方是:玩家按停時的速度不一定——如果玩家在輪盤剛加速完就按停,起速很高;如果等了一陣子才按,速度已經在巡航了。不管哪種情況,緩停都不能有跳動感。

getAlignDelta:計算目標角度差值

從當前位置(正規化到 0–360)到目標獎項,最短需要轉多少度:

function getAlignDelta(prizeName: string, fromNormalized: number): number {
  const prize = PRIZES.find((p) => p.name === prizeName);
  if (!prize) return 0;
  // 從該獎項的多個角度區間中隨機選一個,再隨機落點
  const [min, max] = prize.range[Math.floor(Math.random() * prize.range.length)];
  const target = Math.floor(Math.random() * (max - min + 1)) + min;
  let delta = target - fromNormalized;
  if (delta < 0) delta += 360; // 確保往前轉
  return delta;
}

dynamicEaseOut:讓起步速度平滑接續

標準 ease-out 是 f(t) = 1 - (1-t)^n,n 越大曲線越陡、起步越快。

關鍵是讓 f'(0)(起步速度)等於當前的巡航速度,這樣就沒有跳動。對 f 微分:

f'(0) = n

再換算成「每幀行進的角度」:

n = v0 × T / totalDelta

其中 v0 是當前速度(deg/frame)、T 是預定緩停幀數、totalDelta 是緩停總行程。

function dynamicEaseOut(t: number, n: number) {
  return 1 - Math.pow(1 - t, n);
}
 
// beginDecel:收到停止指令時計算緩停路徑
const beginDecel = useCallback(() => {
  clearTimeout(autoStopTimerRef.current);
 
  const norm = ((angleRef.current % 360) + 360) % 360;
  const alignDelta = getAlignDelta(prizeRef.current, norm);
 
  const v0 = speedRef.current;
  const T = stopFramesRef.current;           // 預定 120 幀
  const totalDelta = 2 * 360 + alignDelta;   // 額外 2 圈 + 對齊差值
 
  const n = Math.max((v0 * T) / totalDelta, 0.5);
 
  stopOriginRef.current = angleRef.current;
  stopDeltaRef.current = totalDelta;
  stopFrameRef.current = 0;
  stopExpRef.current = n;
 
  phaseRef.current = "stopping";
  setPhase("stopping");
}, []);

緩停固定多跑 2 圈再對齊,是為了保留視覺上的「滑行」感,不會讓輪盤感覺像突然剎車。

handleStart 與 handleStop

const handleStart = useCallback(() => {
  if (phaseRef.current !== "idle" && phaseRef.current !== "done") return;
 
  speedRef.current = 0;
  setResult(null);
 
  // mock API:直接拿到中獎結果,實際應由後端回傳
  prizeRef.current = selectedPrize;
 
  phaseRef.current = "spinning";
  setPhase("spinning");
  rafRef.current = requestAnimationFrame(tickRef.current!);
 
  // 8 秒後若玩家還沒按停,自動觸發
  clearTimeout(autoStopTimerRef.current);
  autoStopTimerRef.current = setTimeout(() => {
    if (phaseRef.current === "spinning") beginDecel();
  }, AUTO_STOP_MS);
}, [selectedPrize, beginDecel]);
 
const handleStop = useCallback(() => {
  if (phaseRef.current !== "spinning") return;
  beginDecel();
}, [beginDecel]);

CSS transition 與 rAF 的選擇

CSS transitionrequestAnimationFrame
實作複雜度低高
中途停止✗✓
讀取當前角度✗✓
動態調整終點✗✓
效能瀏覽器優化,極佳自行管理,需注意

功能需求決定方案選擇。如果動畫是「一次決定終點跑完」,CSS transition 更乾淨;一旦需要「中途介入、動態終點」,rAF 是唯一選項。


成品展示

分享:XLinkedIn
← 上一篇從 jQuery 到 TypeScript:打造轉盤抽獎元件
下一篇 →用 Canvas 做刮刮樂:destination-out 的原理與實現