You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React中Canvas转盘组件重复渲染及最终跳动问题排查

React 转盘实现问题排查与修复

问题1:useEffect 初始执行两次

这是 React 18+ 开发模式下StrictMode的默认行为——它会刻意执行两次挂载阶段的副作用(包括空依赖数组的useEffect),用来检测未正确清理的副作用(比如重复注册事件、内存泄漏)。

解决方法:

  • 开发环境可暂时关闭StrictMode(移除index.js里的<StrictMode>包裹),生产环境不会出现该问题;
  • 给useEffect添加清理逻辑,避免重复初始化转盘:在回调里返回清理函数,移除点击事件监听并取消动画帧循环。

问题2:转盘停止后小幅转动,出现两个结果

核心问题是停止逻辑时机错误,加上角度未校准到扇区固定位置:

  1. 当angVel < angVelMin时直接设置isSpinning=false,但当前帧的ang += angVel已执行,导致角度偏移,最后一次rotate()会触发结果变更;
  2. 停止时未将角度校准到扇区中心,指针可能停在扇区边缘,后续微小角度变化会切换扇区。

修改后的完整代码

import React, { useEffect, useRef } from "react";
import "./SpinWheel.css";

function SpinWheel() {
  const sectors = [
    { color: "#f82", label: "Stack" },
    { color: "#0bf", label: "10" },
    { color: "#fb0", label: "200" },
    { color: "#0fb", label: "50" },
    { color: "#b0f", label: "100" },
    { color: "#f0b", label: "5" },
  ];

  const rand = (m, M) => Math.random() * (M - m) + m;
  const tot = sectors.length;
  const wheel = useRef(null);
  const spin = useRef(null);
  const animationFrameId = useRef(null);

  useEffect(() => {
    const ctx = wheel.current.getContext("2d");
    const elSpin = spin.current;
    
    let isSpinning = false;
    let isAccelerating = false;
    let angVelMax = 0;
    let angVel = 0;
    let ang = 0;
    const dia = ctx.canvas.width;
    const rad = dia / 2;
    const PI = Math.PI;
    const TAU = 2 * PI;
    const arc = TAU / sectors.length;
    const friction = 0.991;
    const angVelMin = 0.002;

    const getIndex = () => Math.floor(tot - (ang / TAU) * tot) % tot;

    const drawSector = (sector, i) => {
      const sectorAng = arc * i;
      ctx.save();
      ctx.beginPath();
      ctx.fillStyle = sector.color;
      ctx.moveTo(rad, rad);
      ctx.arc(rad, rad, rad, sectorAng, sectorAng + arc);
      ctx.lineTo(rad, rad);
      ctx.fill();
      ctx.translate(rad, rad);
      ctx.rotate(sectorAng + arc / 2);
      ctx.textAlign = "right";
      ctx.fillStyle = "#fff";
      ctx.font = "bold 30px sans-serif";
      ctx.fillText(sector.label, rad - 10, 10);
      ctx.restore();
    };

    const rotate = () => {
      const sector = sectors[getIndex()];
      ctx.canvas.style.transform = `rotate(${ang - PI / 2}rad)`;
      elSpin.textContent = !angVel ? sector.label : "SPIN";
      elSpin.style.background = sector.color;
    };

    const frame = () => {
      if (!isSpinning) return;

      // 先判断是否满足停止条件
      let shouldStop = false;
      if (!isAccelerating && angVel < angVelMin) {
        shouldStop = true;
      }

      if (shouldStop) {
        isSpinning = false;
        angVel = 0;
        // 校准角度到当前扇区中心
        const currentIndex = getIndex();
        ang = (tot - currentIndex - 0.5) * arc;
        ang %= TAU;
        rotate();
        return;
      }

      if (angVel >= angVelMax) isAccelerating = false;

      if (isAccelerating) {
        angVel ||= angVelMin;
        angVel *= 1.06;
      } else {
        angVel *= friction;
      }

      ang += angVel;
      ang %= TAU;
      rotate();
      
      animationFrameId.current = requestAnimationFrame(frame);
    };

    const engine = () => {
      animationFrameId.current = requestAnimationFrame(frame);
    };

    const handleSpinClick = () => {
      if (isSpinning) return;
      isSpinning = true;
      isAccelerating = true;
      angVelMax = rand(0.25, 0.4);
    };

    elSpin.addEventListener("click", handleSpinClick);
    sectors.forEach(drawSector);
    rotate();
    engine();

    // 清理副作用
    return () => {
      elSpin.removeEventListener("click", handleSpinClick);
      if (animationFrameId.current) {
        cancelAnimationFrame(animationFrameId.current);
      }
    };
  }, []);

  return (
    <div id="wheelOfFortune">
      <canvas id="wheel" ref={wheel} width="300" height="300"></canvas>
      <div id="spin" ref={spin}>SPIN</div>
    </div>
  );
}

export default SpinWheel;

关键修改说明

  • 添加副作用清理:移除点击事件监听并取消动画帧,避免重复初始化导致的多次事件绑定和动画循环;
  • 停止时角度校准:计算当前扇区的中心角度,直接修正ang值,确保指针精准指向扇区中心;
  • 调整停止逻辑顺序:先判断停止条件,满足则校准角度并终止帧,避免无效角度更新;
  • 保存动画帧ID:用useRef存储动画帧ID,确保能正确取消循环。

内容的提问来源于stack exchange,提问作者aachukay

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.10 06:55:16