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
相关产品推荐
相关产品推荐

