React中mouseenter/mouseleave事件重复触发 画布元素重复问题求助
React canvas蜜蜂动画重复渲染、事件触发两次问题
问题现象
开发canvas蜜蜂飞行动画,实现蜜蜂随机漫游、鼠标靠近时跟随移动的效果,遇到两个异常:
- React版本实现中,绑定的
mouseenter、mouseleave事件监听器每次都会触发两次,原生JavaScript实现的相同逻辑中事件仅触发一次 - 关闭画布清空逻辑后,画布上同时渲染出两只蜜蜂和两个目标点,所有元素都被重复生成
相关代码
App组件
import Canvas from "./canvas/Canvas"; import "./App.css"; import { useRef, useEffect, useState } from "react"; function App() { const appRef = useRef(); const [loaded, setLoaded] = useState(false); useEffect(() => { if (appRef) { setLoaded(true); } return () => setLoaded(false); }, []); return ( <div className="App" ref={appRef}> {loaded ? ( <Canvas width={appRef.current.clientWidth} height={appRef.current.clientHeight} /> ) : ( <></> )} </div> ); } export default App;
Canvas组件
import Bee from "./bee"; import { useState, useCallback, useEffect } from "react"; import "./canvas.css"; const Canvas = ({ width, height }) => { const [canvas, setCanvas] = useState(); const [context, setContext] = useState(); const canvasCallback = useCallback((node) => { if (node) { setCanvas(node); } }, []); useEffect(() => { if (canvas) { setContext(canvas.getContext("2d")); canvas.width = width; canvas.height = height; } }, [canvas]); if (context) { // Game loop // let fps = 60; // let now; // let then = Date.now(); // let delta; const bee = new Bee(context, canvas.width, canvas.height, canvas); const gameLoop = () => { // let interval = 1000 / fps; window.requestAnimationFrame(gameLoop); // now = Date.now(); // delta = now - then; // if (delta > interval) { // then = now - (delta % interval); bee.move(); // } }; window.requestAnimationFrame(gameLoop); //Game loop end } return <canvas id="canvas" ref={canvasCallback} />; }; export default Canvas;
Bee类代码
import beeImg from "./bee.png"; import beeLeftImg from "./bee-left.png"; function Bee(ctx, width, height, parent) { this.currentPos = { x: width / 5, y: height / 3 }; this.beeWidth = 32; this.beeHeight = 32; this.velocity = { x: 0.1, y: 0 }; this.acc = { x: 0, y: 0 }; this.direction = { x: Math.random() * width, y: Math.random() * height }; this.mouse = { x: 0, y: 0 }; this.mouseInside = false; let loadedR = false; let loadedL = false; let img = new Image(); img.src = beeImg; img.onload = () => { loadedR = true; }; let imgLeft = new Image(); imgLeft.src = beeLeftImg; imgLeft.onload = () => { loadedL = true; }; this.createBee = (x, y) => { if (this.velocity.x >= 0 && loadedR) { ctx.clearRect(0, 0, width, height, this.bee); ctx.drawImage(img, x, y, this.beeHeight, this.beeHeight); } if (this.velocity.x < 0 && loadedL) { ctx.clearRect(0, 0, width, height, this.bee); ctx.drawImage(imgLeft, x, y, this.beeHeight, this.beeHeight); } }; window.addEventListener("mousemove", (e) => { this.mouse = { x: e.clientX, y: e.clientY }; }); parent.addEventListener("mouseenter", (e) => { console.log(e, "enter"); e.stopPropagation() }); parent.addEventListener("mouseleave", (e) => { console.log(e, "left"); e.stopPropagation() }); let goTo = (x, y) => { let v = { x: this.currentPos.x - x, y: this.currentPos.y - y, }; let mag = Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2)); let normalize = { x: v.x / mag, y: v.y / mag }; this.acc.x -= normalize.x; this.acc.y -= normalize.y; this.velocity.x += this.acc.x / 50; this.velocity.y += this.acc.y / 50; this.currentPos.x += this.velocity.x; this.currentPos.y += this.velocity.y; this.acc.x = 0; this.acc.y = 0; if (this.currentPos.x >= width - this.beeWidth) { this.currentPos.x = width - this.beeWidth; this.velocity.x = 0; } if (this.currentPos.x <= 0) { this.currentPos.x = 0; this.velocity.x = 0; } if (this.currentPos.y >= height - this.beeHeight) { this.currentPos.y = height - this.beeHeight; this.velocity.y = 0; } if (this.currentPos.y <= 0) { this.currentPos.y = 0; this.velocity.y = 0; } this.createBee(this.currentPos.x, this.currentPos.y); ctx.beginPath(); ctx.arc(this.direction.x, this.direction.y, 10, 0, 2 * Math.PI); ctx.stroke(); }; this.move = () => { let mouseV = { x: this.currentPos.x - this.mouse.x, y: this.currentPos.y - this.mouse.y, }; let mouseMag = Math.sqrt(Math.pow(mouseV.x, 2) + Math.pow(mouseV.y, 2)); if (mouseMag < 200) { goTo(this.mouse.x, this.mouse.y); } else { let dirV = { x: this.currentPos.x - this.direction.x, y: this.currentPos.y - this.direction.y, }; let dirMag = Math.sqrt(Math.pow(dirV.x, 2) + Math.pow(dirV.y, 2)); if (dirMag <= 100) { this.direction = { x: Math.random() * width, y: Math.random() * height, }; } goTo(this.direction.x, this.direction.y); } }; } export default Bee;
原生JavaScript实现版本(测试需自行替换图片路径)
if (canvas.getContext) { const ctx = canvas.getContext("2d"); canvas.width = window.innerWidth; canvas.height = window.innerHeight; function Bee(ctx, width, height) { this.currentPos = { x: width / 5, y: height / 3 }; this.beeWidth = 32; this.beeHeight = 32; this.velocity = { x: 0.1, y: 0 }; this.acc = { x: 0, y: 0 }; this.direction = { x: width / 2, y: height / 2 }; this.mouse = { x: 0, y: 0 }; this.createBee = (x, y) => { // ctx.clearRect(0, 0, width, height, this.bee); // ctx.beginPath(); // ctx.arc(x, y, 10, 0, 2 * Math.PI); // ctx.stroke(); const img = new Image(); img.src = "./bee.png"; const imgLeft = new Image(); imgLeft.src = "./bee-left.png"; if (this.velocity.x >= 0) { img.onload = () => { ctx.clearRect(0, 0, width, height, this.bee); ctx.drawImage(img, x, y, this.beeHeight, this.beeHeight); }; } if (this.velocity.x < 0) { imgLeft.onload = () => { ctx.clearRect(0, 0, width, height, this.bee); ctx.drawImage(imgLeft, x, y, this.beeHeight, this.beeHeight); }; } }; window.addEventListener("mousemove", (e) => { this.mouse = { x: e.clientX, y: e.clientY }; }); canvas.addEventListener("mouseenter", (e) => { console.log(e, "enter"); // e.stopPropagation() }); canvas.addEventListener("mouseleave", (e) => { console.log(e, "left"); // e.stopPropagation() }); let goTo = (x, y) => { let v = { x: this.currentPos.x - x, y: this.currentPos.y - y, }; let mag = Math.sqrt(Math.pow(v.x, 2) + Math.pow(v.y, 2)); let normalize = { x: v.x / mag, y: v.y / mag }; this.acc.x -= normalize.x; this.acc.y -= normalize.y; this.velocity.x += this.acc.x / 50; this.velocity.y += this.acc.y / 50; this.currentPos.x += this.velocity.x; this.currentPos.y += this.velocity.y; this.acc.x = 0; this.acc.y = 0; if (this.currentPos.x >= width - this.beeWidth) { this.currentPos.x = width - this.beeWidth; this.velocity.x = 0; } if (this.currentPos.x <= 0) { this.currentPos.x = 0; this.velocity.x = 0; } if (this.currentPos.y >= height - this.beeHeight) { this.currentPos.y = height - this.beeHeight; this.velocity.y = 0; } if (this.currentPos.y <= 0) { this.currentPos.y = 0; this.velocity.y = 0; } this.createBee(this.currentPos.x, this.currentPos.y); ctx.beginPath(); ctx.arc(this.direction.x, this.direction.y, 10, 0, 2 * Math.PI); ctx.stroke(); }; this.move = () => { let mouseV = { x: this.currentPos.x - this.mouse.x, y: this.currentPos.y - this.mouse.y, }; let mouseMag = Math.sqrt(Math.pow(mouseV.x, 2) + Math.pow(mouseV.y, 2)); if (mouseMag < 200) { goTo(this.mouse.x, this.mouse.y); } else { let dirV = { x: this.currentPos.x - this.direction.x, y: this.currentPos.y - this.direction.y, }; let dirMag = Math.sqrt(Math.pow(dirV.x, 2) + Math.pow(dirV.y, 2)); if (dirMag <= 100) { this.direction = { x: Math.random() * width, y: Math.random() * height, }; } goTo(this.direction.x, this.direction.y); } }; } const bee = new Bee(ctx, 700, 700); // Game loop let fps = 60; let now; let then = Date.now(); let delta; const gameLoop = () => { let interval = 1000 / fps; window.requestAnimationFrame(gameLoop); now = Date.now(); delta = now - then; if (delta > interval) { then = now - (delta % interval); bee.move(); } }; window.requestAnimationFrame(gameLoop); //Game loop end }
问题原因
核心是React 18+ 严格模式(StrictMode)的开发环境特性:组件会被重复渲染两次,用于检测副作用泄漏。代码把Bee实例创建、游戏循环启动、事件绑定的逻辑直接写在了Canvas组件的渲染函数体中,每次渲染都会执行一次,最终生成了两个Bee实例、两个独立的游戏循环,也绑定了两次事件监听器,就出现了元素重复、事件触发两次的问题。
修复方案
- 把Bee实例创建、游戏循环启动的逻辑移动到
useEffect中,不要直接写在组件渲染函数里 - 给
useEffect添加清理函数,组件卸载时取消requestAnimationFrame、移除事件监听器,避免内存泄漏 - 用
useRef存储Bee实例和游戏循环ID,避免重复创建 - 若要快速验证问题,可以临时移除项目入口的
StrictMode包裹,生产环境下严格模式不会触发重复渲染,不会有该问题
内容的提问来源于stack exchange,提问作者Lygis
相关产品推荐
相关产品推荐

