喂!我是 Wei

Front-End Engineer

Be a Problem Solver.

⌘K

導覽

所有文章緣起互動小功能

文章分類

目錄
原始 jQuery 版本核心邏輯幾個值得注意的設計細節改寫為 TypeScript + React輪盤繪製:Canvas API角度計算邏輯狀態管理:useRef vs useState完整旋轉函式jQuery vs TypeScript:對照整理效果展示

相關文章

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

2026年3月11日

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

2026年3月20日

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

2026年3月10日

最新文章
全部 →
前端 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
← 返回文章列表

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

2026年3月9日·約 8 分鐘閱讀·
JavaScriptTypeScriptReactCanvas

這是幾年前接的一個外包專案,功能是一個轉盤抽獎遊戲,讓使用者點擊後輪盤開始旋轉,最終停在某個獎項上。

業主當時提了幾個明確需求:

  • 旋轉至少 10 圈 :轉太少圈視覺上沒有「在抽獎」的感覺
  • 最少旋轉 8 秒後才停止:拉長期待感,不能一下就結束
  • 玩家可以在 8 秒內自行按停:讓玩家有參與感,「我自己停的」

當時以 jQuery + 原生 CSS animation 實作。現在把它重新用 TypeScript + React + Canvas API 改寫一遍,把它包成可以直接放進文章的元件。

以下就是當年外包的原始版本,使用靜態輪盤圖片 + CSS transform 旋轉,點擊中間按鈕就可以試玩:

輪盤
指針

原始 jQuery 版本

核心邏輯

原版用 jQuery 做三件事:

  1. 監聽按鈕點擊
  2. 計算目標角度,設定 CSS transform
  3. 用 setTimeout 等動畫結束後重設狀態
原始 jQuery 版本(main.js)
let currentAngle = 0;
let totalAngle = 0;
let isSpinning = false;
 
const prizes = [
  { name: "頭獎", range: [[350, 368]] },
  { name: "二獎", range: [[140, 158]] },
  { name: "三獎", range: [[230, 248], [80, 98]] },
  // ...
];
 
function getTargetAngle(_prize) {
  let prize = prizes.find((p) => p.name === _prize);
  let [min, max] = prize.range[Math.floor(Math.random() * prize.range.length)];
  let rawTarget = Math.floor(Math.random() * (max - min + 1)) + min;
  let targetAngle = rawTarget - currentAngle;
  if (targetAngle < 0) targetAngle += 360;
  return targetAngle;
}
 
$(".btn_start").click(() => {
  if (isSpinning) return;
  isSpinning = true;
 
  const _prize = fetchRandomPrizeApi();
  let targetAngle = getTargetAngle(_prize);
  let extraRotations = 10 * 360;
  totalAngle += extraRotations + targetAngle;
 
  $(".lunpan").css({
    transition: "transform 8s cubic-bezier(.21,.02,.14,.98)",
    transform: `rotate(${totalAngle}deg)`,
  });
 
  setTimeout(() => {
    isSpinning = false;
    currentAngle = totalAngle % 360;
    $(".lunpan").css({ transition: "none", transform: `rotate(${currentAngle}deg)` });
    totalAngle = currentAngle;
  }, 8000);
});

幾個值得注意的設計細節

角度累積:totalAngle 不斷往上加(10圈 + 目標角度),這樣 CSS transition 才能產生「持續旋轉」的視覺效果。動畫結束後再把角度正規化回 0–360,避免數值無限膨脹。

指針方向:原版用的是一張實體輪盤圖片,prizes 的 range 是依照圖片上每個獎項對應的角度硬編碼進去的。後端回傳獲獎名稱,前端再查對應角度。

jQuery 的問題:狀態散落成全域變數(currentAngle、isSpinning),DOM 操作與邏輯混雜,沒有型別保護,也很難封裝成可重用的模組。


改寫為 TypeScript + React

輪盤繪製:Canvas API

不依賴外部圖片,改用 Canvas API 直接畫出輪盤:

drawWheel(在 useEffect 中呼叫一次)
function drawWheel(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext("2d");
  if (!ctx) return;
 
  const size = canvas.width;
  const cx = size / 2;
  const cy = size / 2;
  const outerR = size / 2 - 4;
  const innerR = 28;
  const SEG_ANGLE = 360 / PRIZES.length;
 
  ctx.clearRect(0, 0, size, size);
 
  for (let i = 0; i < PRIZES.length; i++) {
    // -90° 讓第一格從 12 點鐘方向開始
    const startRad = ((-90 + i * SEG_ANGLE) * Math.PI) / 180;
    const endRad = ((-90 + (i + 1) * SEG_ANGLE) * Math.PI) / 180;
    const midRad = (startRad + endRad) / 2;
 
    // 扇形
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.arc(cx, cy, outerR, startRad, endRad);
    ctx.closePath();
    ctx.fillStyle = PRIZES[i].color;
    ctx.fill();
 
    // 文字:旋轉到扇形中心方向再繪製
    const textR = (outerR + innerR) / 2 + 8;
    ctx.save();
    ctx.translate(cx + textR * Math.cos(midRad), cy + textR * Math.sin(midRad));
    ctx.rotate(midRad + Math.PI / 2);
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.fillStyle = PRIZES[i].textColor;
    ctx.font = `bold ${Math.round(size / 15)}px sans-serif`;
    ctx.fillText(PRIZES[i].name, 0, 0);
    ctx.restore();
  }
}

角度計算邏輯

以 12 格輪盤為例,每格剛好 360 / 12 = 30°,從 12 點鐘順時針排列:

索引 i格子中心角
015°
145°
275°
3105°
……

中心角公式:(i + 0.5) × 30°

要旋轉幾度才能對準指針?

把輪盤順時針轉 θ 度之後,指針(固定在頂端)指向的是輪盤上「360 - θ」的位置。

反推:要讓第 i 格的中心 (i + 0.5) × 30° 對準指針:

360 - θ = (i + 0.5) × 30
θ = 360 - (i + 0.5) × 30

幾個例子(數字都剛好是整數,方便理解):

目標 i中心角需旋轉 θ
015°345°
3105°255°
6195°165°
9285°75°

通用公式:

const targetBase = (360 - (prizeIdx + 0.5) * SEG_ANGLE + 360) % 360;

(+ 360) % 360 是為了避免 JavaScript % 對負數回傳負值。)

加入隨機偏移 + 補差值

每次都停在格子正中央會很假,加一個 ±70% 格寬的隨機偏移:

const variation = (Math.random() - 0.5) * SEG_ANGLE * 0.7;
const targetInRound = ((targetBase + variation) % 360 + 360) % 360;
 
// 從目前角度補差值,再多轉 10 圈
let delta = targetInRound - currentAngleRef.current % 360;
if (delta < 0) delta += 360;  // 確保往前轉
totalAngleRef.current = currentAngleRef.current + 10 * 360 + delta;

整合成函式:

計算目標旋轉角度
const SEG_ANGLE = 360 / PRIZES.length;
 
function calcTargetAngle(prizeIdx: number, currentAngle: number): number {
  const targetBase = (360 - (prizeIdx + 0.5) * SEG_ANGLE + 360) % 360;
  const variation = (Math.random() - 0.5) * SEG_ANGLE * 0.7;
  const targetInRound = ((targetBase + variation) % 360 + 360) % 360;
  let delta = targetInRound - currentAngle % 360;
  if (delta < 0) delta += 360;
  return currentAngle + 10 * 360 + delta;
}

狀態管理:useRef vs useState

狀態設計
// 旋轉角度用 ref:不需要觸發 re-render,setTimeout 內也能正確讀值
const currentAngleRef = useRef(0);
const totalAngleRef = useRef(0);
const isSpinning = useRef(false);
 
// 需要更新畫面的部分才用 state
const [spinning, setSpinning] = useState(false);
const [cssTransform, setCssTransform] = useState("rotate(0deg)");
const [cssTransition, setCssTransition] = useState("none");
const [result, setResult] = useState<Prize | null>(null);

旋轉角度不需要驅動 UI 重繪,用 useRef 讓閉包中的 setTimeout 也能拿到最新的值。只有 CSS transform 字串、是否旋轉中、顯示結果這些才用 useState。

完整旋轉函式

handleSpin
const handleSpin = useCallback(() => {
  if (isSpinning.current) return;
  isSpinning.current = true;
  setSpinning(true);
  setShowResult(false);
 
  // 實際場景:const prizeIdx = await fetchPrizeFromApi();
  const prizeIdx = Math.floor(Math.random() * SEG_COUNT); // Demo 用
 
  const targetBase = (360 - (prizeIdx + 0.5) * SEG_ANGLE + 360) % 360;
  const variation = (Math.random() - 0.5) * SEG_ANGLE * 0.7;
  const targetInRound = ((targetBase + variation) % 360 + 360) % 360;
 
  let delta = targetInRound - currentAngleRef.current % 360;
  if (delta < 0) delta += 360;
 
  totalAngleRef.current = currentAngleRef.current + 10 * 360 + delta;
 
  // 設定 CSS transition + transform → 觸發動畫
  setCssTransition("transform 8s cubic-bezier(.21,.02,.14,.98)");
  setCssTransform(`rotate(${totalAngleRef.current}deg)`);
 
  setTimeout(() => {
    isSpinning.current = false;
 
    // 動畫結束後正規化角度,避免數值無限累積
    currentAngleRef.current = totalAngleRef.current % 360;
    totalAngleRef.current = currentAngleRef.current;
 
    setCssTransition("none");
    setCssTransform(`rotate(${currentAngleRef.current}deg)`);
    setSpinning(false);
    setResult(PRIZES[prizeIdx]);
    setShowResult(true);
  }, 8100);
}, []);

jQuery vs TypeScript:對照整理

項目jQuery 版本TypeScript 版本
狀態管理全域變數useRef / useState
DOM 操作$(".lunpan").css(...)CSS transform state → inline style
輪盤圖形靜態圖片Canvas API 動態繪製
型別安全無Prize 型別、完整 TypeScript
可重用性耦合頁面 HTML獨立 React 元件
獎項對應硬編碼像素角度數學公式動態計算索引
中獎決定後端 API 回傳後端 API 回傳(Demo 用前端亂數)

從 jQuery 改成 TypeScript 最大的改變,不是語法,而是思維的轉換:從「找到 DOM 元素,直接修改它」,變成「宣告狀態,讓 React 負責同步到畫面」。


效果展示


目前這個版本用 CSS transition 固定跑完 8 秒,邏輯簡單清晰。但業主還有一個需求沒實作:玩家可以在 8 秒內自行按停。這個需求讓整個動畫架構必須重新設計——CSS transition 根本做不到,需要換成 requestAnimationFrame。

下一篇會從這個限制出發,完整說明 rAF 的逐幀控制思維,以及如何設計動態緩停算法。

分享:XLinkedIn
← 上一篇Discord Bot 監控與告警:Bot 掛掉時自動發通知到頻道
下一篇 →用 requestAnimationFrame 實作可中途停止的轉盤