React中Canvas转盘组件重复渲染及最终跳动问题排查
React 转盘实现问题排查与修复
问题1:useEffect 初始执行两次
这是 React 18+ 开发模式下StrictMode的默认行为——它会刻意执行两次挂载阶段的副作用(包括空依赖数组的useEffect),用来检测未正确清理的副作用(比如重复注册事件、内存泄漏)。
解决方法:
- 开发环境可暂时关闭
StrictMode(移除index.js里的<StrictMode>包裹),生产环境不会出现该问题; - 给
useEffect添加清理逻辑,避免重复初始化转盘:在回调里返回清理函数,移除点击事件监听并取消动画帧循环。
问题2:转盘停止后小幅转动,出现两个结果
核心问题是停止逻辑时机错误,加上角度未校准到扇区固定位置:
- 当
angVel < angVelMin时直接设置isSpinning=false,但当前帧的ang += angVel已执行,导致角度偏移,最后一次rotate()会触发结果变更; - 停止时未将角度校准到扇区中心,指针可能停在扇区边缘,后续微小角度变化会切换扇区。
修改后的完整代码
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
相关产品推荐
相关产品推荐

