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

React中useState存储数组状态时canvas场景UI渲染异常

React Canvas文本绘制状态不同步问题修复

问题根因

  • React的state更新是异步批量调度的,原代码在addText里调用setTextArr后立刻执行draw(),此时draw()拿到的是当前渲染闭包里旧的textArr值,不是刚更新的状态,自然不会绘制新元素。
  • 全局数组能正常工作是因为push是同步修改原变量,draw()调用时能立刻读到最新值,但全局变量会导致组件多实例时数据串扰,不符合React开发规范。
  • 原代码拖动文本时直接修改state数组内对象的属性,属于可变更新,React无法检测到这类变更触发重绘。
  • 所有事件处理函数、draw函数都捕获了渲染瞬间的状态快照,状态更新后这些函数不会自动获取最新值,就会出现DevTools显示状态正确、但画布只渲染旧内容/仅渲染第一个元素的现象。
  • 原代码handleMouseMove里的dy变量未定义,本身存在逻辑bug。

修复步骤

  • 废弃全局的textArr、startX、startY变量,文本数组用useState管理,拖动临时坐标用useRef存储,避免闭包陷阱和全局污染
  • 把画布重绘逻辑放到useEffect中,监听textArr、image、画布初始化状态的变化,状态更新后自动触发重绘,不要手动同步调用draw
  • 所有state更新采用不可变写法,禁止直接修改state内的对象/数组属性
  • 补全拖动逻辑里的坐标差计算

修正后完整代码

import { useRef, useEffect, useState, useCallback } from "react";
import { useLocation } from "react-router-dom";
import "./styles/Generator.css";

export default function Generator() {
  const [textArr, setTextArr] = useState([]);
  const [text, setText] = useState("");
  const [isClear, setIsClear] = useState(false);
  const [image, setImage] = useState(null);
  const [offsets, setOffsets] = useState({
    selectedText: -1,
    offsetX: 0,
    offsetY: 0,
  });
  // 拖动临时坐标存在ref里,避免闭包和全局污染
  const startPosRef = useRef({x:0, y:0});
  const { state } = useLocation();
  const { item } = state;
  const canvasRef = useRef(null);
  const contextRef = useRef(null);

  // 画布初始化逻辑
  useEffect(() => {
    const canvas = canvasRef.current;
    setOffsets(prev => ({
      ...prev,
      offsetX: canvas.offsetLeft,
      offsetY: canvas.offsetTop,
    }));
    const context = canvas.getContext("2d");
    contextRef.current = context;
    let asp = item.width / item.height;
    canvas.width = 400;
    canvas.height = 400 / asp;
    const meme = new Image();
    meme.setAttribute("crossOrigin", "anonymous");
    meme.src = item.url;
    meme.onload = () => {
      context.drawImage(meme, 0, 0, canvas.width, canvas.height);
      setImage(meme);
    };
  }, [isClear, item]);

  // 统一重绘逻辑,监听依赖变化自动执行
  useEffect(() => {
    if (!contextRef.current || !image || !canvasRef.current) return;
    const ctx = contextRef.current;
    // 先清空重绘底图
    ctx.drawImage(
      image,
      0,
      0,
      canvasRef.current.width,
      canvasRef.current.height
    );
    ctx.font = "20px arial";
    ctx.fillStyle = "#000";
    // 绘制所有文本
    textArr.forEach(item => {
      ctx.fillText(item.text, item.x, item.y);
    });
  }, [textArr, image]);

  const addText = () => {
    if (text.length < 1) return;
    const y = textArr.length * 20 + 90;
    const newText = {
      text: text,
      x: 50,
      y: y,
    };
    // 预计算文本尺寸
    contextRef.current.font = "20px arial";
    newText.width = contextRef.current.measureText(newText.text).width;
    newText.height = 20;
    // 不可变更新state
    setTextArr(prev => [...prev, newText]);
    setText("");
  };

  // 文本命中检测
  const textHittest = useCallback((x, y, textIndex) => {
    const targetText = textArr[textIndex];
    if (!targetText) return false;
    return (
      x >= targetText.x &&
      x <= targetText.x + targetText.width &&
      y >= targetText.y - targetText.height &&
      y <= targetText.y
    );
  }, [textArr]);

  const handleMouseDown = (e) => {
    e.preventDefault();
    const mouseX = parseInt(e.clientX - offsets.offsetX);
    const mouseY = parseInt(e.clientY - offsets.offsetY);
    startPosRef.current = {x: mouseX, y: mouseY};
    // 查找选中的文本
    for (let i = 0; i < textArr.length; i++) {
      if (textHittest(mouseX, mouseY, i)) {
        setOffsets(prev => ({...prev, selectedText: i}));
        return;
      }
    }
  };

  const handleMouseMove = (e) => {
    if (offsets.selectedText < 0) return;
    e.preventDefault();
    const mouseX = parseInt(e.pageX - offsets.offsetX);
    const mouseY = parseInt(e.pageY - offsets.offsetY);
    // 计算拖动偏移量
    const dx = mouseX - startPosRef.current.x;
    const dy = mouseY - startPosRef.current.y;
    startPosRef.current = {x: mouseX, y: mouseY};
    // 不可变更新文本位置
    setTextArr(prev => prev.map((item, idx) => {
      if (idx !== offsets.selectedText) return item;
      return {
        ...item,
        x: item.x + dx,
        y: item.y + dy
      };
    }));
  };

  const handleMouseUp = (e) => {
    e.preventDefault();
    setOffsets(prev => ({...prev, selectedText: -1}));
  };

  const handleMouseOut = (e) => {
    e.preventDefault();
    setOffsets(prev => ({...prev, selectedText: -1}));
  };

  const download = () => {
    let img = canvasRef.current
      .toDataURL("image/png", 1.0)
      .replace("image/png", "image/octet-stream");
    const link = document.createElement("a");
    link.download = "my-image.png";
    link.href = img;
    link.click();
  };

  return (
    <div className="generator">
      <div className="generator__container">
        <div className="generator__header">
          <h1 className="home__title">Meme-Generator</h1>
          <a href="/" className="home__link-top">
            back
          </a>
        </div>
        <div className="generator__content">
          <div className="generator__left">
            <canvas
              onMouseDown={handleMouseDown}
              onMouseMove={handleMouseMove}
              onMouseUp={handleMouseUp}
              onMouseOut={handleMouseOut}
              ref={canvasRef}
              className="canvas"
            />
            <div className="generator__btn-container">
              <button onClick={download} className="generator__btn">
                Download
              </button>
            </div>
          </div>
          <div className="generator__right">
            <h3 className="generator__sub-title">Add Text to Meme</h3>
            <label className="generator__label" htmlFor="text1">
              Enter Text
            </label>
            <input
              value={text}
              className="generator__input"
              type="text"
              placeholder="Enter text.."
              onChange={(e) => setText(e.target.value)}
            />
            <button
              onClick={addText}
              className="generator__btn generator__btn--text"
            >
              Add Text
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

额外说明

  • 所有state更新优先用函数式写法(比如setState(prev => [...prev, newVal])),可以避免闭包导致拿到旧状态的问题
  • 不要在事件处理函数里直接依赖state更新后的结果做同步操作,把依赖状态的副作用统一放到useEffect里处理
  • Canvas属于命令式绘制的DOM节点,和React声明式的状态更新逻辑结合时,一定要把重绘逻辑和状态绑定,不要手动触发绘制,否则很容易出现状态和视图不同步的问题

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.29 02:33:07