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

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实例、两个独立的游戏循环,也绑定了两次事件监听器,就出现了元素重复、事件触发两次的问题。

修复方案

  1. 把Bee实例创建、游戏循环启动的逻辑移动到useEffect中,不要直接写在组件渲染函数里
  2. 给useEffect添加清理函数,组件卸载时取消requestAnimationFrame、移除事件监听器,避免内存泄漏
  3. 用useRef存储Bee实例和游戏循环ID,避免重复创建
  4. 若要快速验证问题,可以临时移除项目入口的StrictMode包裹,生产环境下严格模式不会触发重复渲染,不会有该问题

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.09.24 08:15:03