React TS实现随机泼墨效果遇阻:粒子可见、代码无法运行及优化需求
问题:优化Canvas泼墨效果并修复React TS代码运行问题
需求说明
- 实现类似画家泼墨的Color Splash Cannon效果,参考某Codepen示例(点击左上角触发泼墨)
- 原效果粒子运动轨迹不够自然,需通过贝塞尔曲线实现更随机的泼墨形状
- 新增长按鼠标生成更大泼墨的功能
- 已将代码重构为React TS应用,但代码无法运行,需排查问题并实现需求优化
现有代码的核心问题
- Props设计错误:将canvas、context、particles等组件内部状态定义为Props,违反React单向数据流原则(Props只读)
- Canvas操作逻辑混乱:未正确使用useRef获取DOM节点,直接操作props.canvas,且useEffect中引用未定义变量
i - 变量未正确初始化:
context变量未定义就直接使用,props.particles被直接赋值为空数组(Props不可修改) - 事件绑定错误:直接给props.canvas绑定鼠标事件,未在组件生命周期内绑定到真实DOM
- 动画逻辑未启动:
animloop函数未调用,粒子的animate和render流程未执行 - 颜色函数逻辑错误:
randomColorSet返回数组而非合法的颜色值,generate函数操作不存在的DOM元素且引用未定义变量
修复并优化后的完整代码
import React, { useRef, useEffect, useState } from "react"; // 粒子类型定义 interface Particle { x: number; y: number; startX: number; startY: number; controlX: number; controlY: number; endX: number; endY: number; size: number; life: number; maxLife: number; color: string; } function InkSplashCanvas() { const canvasRef = useRef<HTMLCanvasElement>(null); const [particles, setParticles] = useState<Particle[]>([]); const [pressStartTime, setPressStartTime] = useState<number | null>(null); const animationFrameRef = useRef<number>(0); // 适配设备像素比,避免Canvas模糊 const setupCanvas = () => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; const ctx = canvas.getContext("2d"); if (ctx) { ctx.scale(dpr, dpr); } }; // 生成随机颜色(从预设色组中选取渐变或单色) const getRandomColor = () => { const colorGroups = [ ["#FE7E9C", "#ff80df"], ["#93f", "#ff80df"], ["#d8dadb", "#53ceef"], ["#FE7E9C", "#F7BD8D"], ]; const group = colorGroups[Math.floor(Math.random() * colorGroups.length)]; return Math.random() > 0.5 ? group[0] : group[1]; }; // 生成贝塞尔曲线控制点,模拟随机泼墨轨迹 const generateBezierPoints = (x: number, y: number) => { const offsetRange = 50 + Math.random() * 100; return { controlX: x + (Math.random() - 0.5) * offsetRange, controlY: y + (Math.random() - 0.5) * offsetRange, endX: x + (Math.random() - 0.5) * offsetRange * 1.5, endY: y + (Math.random() - 0.5) * offsetRange * 1.5, }; }; // 生成粒子,根据长按时间调整数量和大小 const generateParticles = (x: number, y: number, pressDuration: number) => { const baseCount = 36; const countMultiplier = Math.min(3, 1 + pressDuration / 500); // 长按最长3倍数量 const particleCount = Math.floor(baseCount * countMultiplier); const newParticles: Particle[] = []; for (let i = 0; i < particleCount; i++) { const { controlX, controlY, endX, endY } = generateBezierPoints(x, y); newParticles.push({ x, y, startX: x, startY: y, controlX, controlY, endX, endY, size: 3 + Math.random() * 4 * countMultiplier, life: 200 + Math.random() * 100, maxLife: 200 + Math.random() * 100, color: getRandomColor(), }); } setParticles(prev => [...prev, ...newParticles]); }; // 更新粒子位置(基于贝塞尔曲线插值) const animateParticles = () => { setParticles(prevParticles => { return prevParticles.filter(p => { // 计算生命周期进度(0到1) const progress = 1 - p.life / p.maxLife; // 贝塞尔曲线插值计算当前位置 p.x = Math.pow(1 - progress, 2) * p.startX + 2 * (1 - progress) * progress * p.controlX + Math.pow(progress, 2) * p.endX; p.y = Math.pow(1 - progress, 2) * p.startY + 2 * (1 - progress) * progress * p.controlY + Math.pow(progress, 2) * p.endY; p.life -= 16; // 约60fps的帧间隔 p.size *= 0.98; // 缓慢缩小 return p.life > 0 && p.size > 0.5; }); }); }; // 渲染粒子(随机圆形/椭圆/不规则路径,模拟泼墨) const renderParticles = () => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // 半透明背景叠加,实现拖尾效果 ctx.fillStyle = "rgba(255,255,255,0.05)"; ctx.fillRect(0, 0, canvas.width / window.devicePixelRatio, canvas.height / window.devicePixelRatio); particles.forEach(p => { ctx.fillStyle = p.color; ctx.beginPath(); // 随机绘制圆形或椭圆,增强泼墨随机性 if (Math.random() > 0.3) { const rx = p.size * (0.8 + Math.random() * 0.4); const ry = p.size * (0.8 + Math.random() * 0.4); ctx.ellipse(p.x, p.y, rx, ry, Math.random() * Math.PI, 0, Math.PI * 2); } else { // 不规则路径模拟墨点扩散 ctx.moveTo(p.x, p.y); for (let i = 0; i < 5; i++) { const angle = (Math.PI * 2 * i) / 5; const radius = p.size * (0.5 + Math.random() * 0.5); ctx.lineTo(p.x + Math.cos(angle) * radius, p.y + Math.sin(angle) * radius); } ctx.closePath(); } ctx.fill(); }); }; // 动画循环 const animateLoop = () => { animateParticles(); renderParticles(); animationFrameRef.current = requestAnimationFrame(animateLoop); }; // 鼠标事件处理 const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => { const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; const x = e.clientX - rect.left; const y = e.clientY - rect.top; setPressStartTime(Date.now()); // 立即生成基础粒子 generateParticles(x, y, 0); }; const handleMouseUp = () => { if (!pressStartTime) return; const pressDuration = Date.now() - pressStartTime; const rect = canvasRef.current?.getBoundingClientRect(); if (!rect) return; // 长按补充生成更多粒子 if (pressDuration > 200) { const lastParticle = particles[particles.length - 1]; if (lastParticle) { generateParticles(lastParticle.x, lastParticle.y, pressDuration); } } setPressStartTime(null); }; useEffect(() => { setupCanvas(); animateLoop(); // 清理动画帧 return () => { cancelAnimationFrame(animationFrameRef.current); }; }, []); // 窗口大小变化时重新适配Canvas useEffect(() => { const handleResize = () => setupCanvas(); window.addEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize); }, []); return ( <canvas ref={canvasRef} style={{ width: "100vw", height: "100vh", cursor: "crosshair" }} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} /> ); } export default InkSplashCanvas;
优化说明
- 状态管理修正:改用
useState管理粒子数组和长按时间,useRef获取Canvas节点,符合React状态管理规范 - 自然运动轨迹:使用二次贝塞尔曲线计算粒子路径,模拟真实泼墨的随机扩散效果
- 长按功能实现:记录鼠标按下时长,根据时长生成不同数量和大小的粒子,长按越久泼墨范围越大
- Canvas适配优化:自动适配设备像素比,避免模糊,窗口大小变化时重新调整Canvas尺寸
- 泼墨视觉优化:随机生成圆形、椭圆、不规则路径的粒子,搭配预设渐变色彩组,更接近真实泼墨效果
- 性能优化:使用
requestAnimationFrame实现流畅动画,自动清理动画帧避免内存泄漏
内容的提问来源于stack exchange,提问作者user18943198
相关产品推荐
相关产品推荐

