各位,把手里的键盘先放一放,把那杯刚泡好的枸杞茶端稳了。今天我们要聊的话题,可能会让你们那个“洁癖”般的 React 灵魂稍微有点不适。
我们要讨论的是:React 与 Canvas 画布的“孽缘”。
想象一下,React 是个精致的管家,它负责把家里(DOM)收拾得井井有条,所有的家具(DOM 节点)的位置、样式都由它说了算,这就是所谓的“声明式”。而 Canvas 呢?它是个暴躁的画师,手里拿着笔直接在墙上(屏幕)上乱涂乱画,这就是“命令式”。
当你试图让 React 这个管家去指挥 Canvas 这个画师时,你就会发现:这就像让一个只会做俯卧撑的体操运动员去弹钢琴,既痛苦,又容易砸了脚。
今天,我就要带你们剖析这场混乱的“生命周期同步”大戏,教你们如何在这场风暴中,既不把 CPU 烧干,也不让画面撕裂。
第一幕:React 的洁癖与 Canvas 的脏活
首先,我们要搞清楚为什么同步这么难。
React 的核心机制是“虚拟 DOM”和“重渲染”。只要你的状态(State)变了,React 就会认为整个世界都变了,于是它重新计算虚拟 DOM,然后打补丁更新真实 DOM。这个过程很快,对吧?但在 Canvas 面前,这简直是灾难。
Canvas 里没有“椅子”或者“桌子”这种概念。它只有一堆像素。React 更新了一个状态,它不知道 Canvas 内部该擦掉哪个像素,该画哪个像素。它只知道:“嘿,Canvas,数据变了,你看着办。”
如果你只是简单地在 useEffect 里写 ctx.fillRect(...),那你就是在给 Canvas 下达“自杀式命令”。
错误示范:
import React, { useState, useEffect, useRef } from 'react';
const BadCanvas = () => {
const [count, setCount] = useState(0);
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const draw = () => {
// 这里的逻辑是:每次 count 变化,就清空并重画
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 100, 100);
};
draw(); // 调用一次
}, [count]); // 依赖 count
return (
<div>
<button onClick={() => setCount(c => c + 1)}>点我</button>
<canvas ref={canvasRef} width={300} height={300} />
</div>
);
};
后果是什么?
如果你点 100 下按钮,React 就会疯狂触发 useEffect 100 次。每次都会调用 clearRect 然后画一个矩形。对于 Canvas 来说,这就像你刚擦干净黑板,老师又让你写了一遍。虽然对于这么小的矩形看不出来,但如果你的场景是 3D 渲染,每秒 60 帧都要这么干,你的 CPU 就会像便秘一样,卡得死死的。
正确姿势:
我们要把“渲染逻辑”和“状态更新逻辑”剥离开。React 只管告诉 Canvas “数据变了”,Canvas 自己决定什么时候重绘。
第二幕:2D 画布的生命周期大戏
让我们进入正题。在 2D Canvas 中,我们要处理三个关键时刻:挂载、更新、卸载。
1. 挂载:建立契约
当组件第一次出现在屏幕上时,Canvas 还是个空空如也的 <canvas> 标签。我们需要给它安上“画笔”(Context),并设置好画布的尺寸。
注意,Canvas 的尺寸和 CSS 的尺寸是两码事。这是新手最容易踩的坑。
const CanvasComponent = () => {
const canvasRef = useRef(null);
const ctxRef = useRef(null); // 缓存 Context,避免每次都 get
useEffect(() => {
const canvas = canvasRef.current;
// 1. 获取 Context,这是画布的灵魂
const ctx = canvas.getContext('2d');
ctxRef.current = ctx;
// 2. 设置画布的实际像素尺寸
// 千万别写成 canvas.width = canvas.clientWidth
// 因为 clientWidth 包含 padding 和 border,会导致模糊
canvas.width = 500;
canvas.height = 500;
// 3. 绘制初始画面
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, 500, 500);
// 返回清理函数(虽然 Canvas 没法真正卸载,但我们可以停止动画循环)
return () => {
console.log('组件卸载,画笔收起');
};
}, []); // 空依赖数组,只在挂载时执行一次
return <canvas ref={canvasRef} style={{ border: '1px solid black' }} />;
};
2. 更新:脏检查与 RAF
现在,我们有了画笔,有了画布。接下来,我们要把 React 的状态同步进去。
假设我们有一个粒子系统,React 存储粒子的位置(状态),Canvas 负责把它们画出来。
const ParticleSystem = () => {
const [particles, setParticles] = useState([
{ id: 1, x: 50, y: 50, color: 'red' },
{ id: 2, x: 200, y: 200, color: 'blue' },
]);
const canvasRef = useRef(null);
const ctxRef = useRef(null);
const animationIdRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctxRef.current = ctx;
canvas.width = 600;
canvas.height = 600;
// 启动渲染循环
const animate = () => {
const ctx = ctxRef.current;
if (!ctx) return;
// 1. 清空画布 (每一帧都要清空,这是 Canvas 的铁律)
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 2. 遍历状态,绘制
particles.forEach(p => {
ctx.fillStyle = p.color;
ctx.beginPath();
ctx.arc(p.x, p.y, 10, 0, Math.PI * 2);
ctx.fill();
});
// 3. 请求下一帧
animationIdRef.current = requestAnimationFrame(animate);
};
animate();
// 4. 清理工作:取消动画帧,防止内存泄漏
return () => {
cancelAnimationFrame(animationIdRef.current);
};
}, [particles]); // 关键点:依赖数组是 particles
const addParticle = () => {
setParticles(prev => [...prev, {
id: Date.now(),
x: Math.random() * 500,
y: Math.random() * 500,
color: `hsl(${Math.random() * 360}, 70%, 50%)`
}]);
};
return (
<div>
<button onClick={addParticle}>生成粒子</button>
<canvas ref={canvasRef} width={600} height={600} style={{ background: '#eee' }} />
</div>
);
};
这里的门道在哪里?
看依赖数组 [particles]。只要 React 觉得 particles 变了(比如你点了按钮,数组长度变了,或者位置变了),useEffect 就会重新运行。
但是!注意看 animate 函数。它没有写在 useEffect 里面,而是定义在外面。为什么?
因为如果 animate 在 useEffect 里,每次 particles 变化,React 都会启动一个新的动画循环,旧的循环还在跑。这就像你有两个闹钟同时响,最后你会疯掉。
我们希望的是:React 负责告诉数据变了,Canvas 的 requestAnimationFrame 循环负责利用新数据重绘。
第三幕:3D 画布的“降维打击”
讲完了 2D,我们来看看 3D。3D 的 Canvas 是 WebGL。WebGL 是一门“语言”,而 Three.js 是这门语言的“翻译官”。
如果你要手动在原生 WebGL 里同步 React 状态,那基本上就是在重写 Three.js。这通常是不推荐的,除非你的需求极其变态(比如为了极致的包体积优化)。
绝大多数情况下,我们使用 react-three-fiber (R3F)。R3F 是 React 和 Three.js 的“联姻”,它把 WebGL 的命令式操作封装成了 React 的声明式组件。
R3F 的生命周期同步逻辑:
在 R3F 中,同步状态非常优雅。你不需要手动写 requestAnimationFrame,R3F 内部帮你搞定了。
import React, { useState, useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
// 定义一个 3D 物体组件
const Box = ({ position, color }) => {
// useFrame 是 R3F 提供的钩子,每一帧都会调用
// 这里我们可以在每一帧修改物体的属性
const meshRef = useRef();
useFrame((state, delta) => {
// 这里可以做复杂的数学运算
if(meshRef.current) {
meshRef.current.rotation.x += delta;
meshRef.current.rotation.y += delta;
}
});
return (
<mesh ref={meshRef} position={position}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={color} />
</mesh>
);
};
const Scene = () => {
const [active, setActive] = useState(false);
const [pos, setPos] = useState([0, 0, 0]);
// 当状态改变时,R3F 会自动更新场景图
return (
<group position={pos}>
<Box position={[1, 0, 0]} color="orange" />
<Box position={[-1, 0, 0]} color="blue" />
<button onClick={() => setActive(!active)}>
{active ? "停止旋转" : "开始旋转"}
</button>
</group>
);
};
export default function App() {
return (
<Canvas camera={{ position: [0, 0, 5] }}>
<Scene />
</Canvas>
);
}
看懂了吗?
在 R3F 中,React 的状态(active, pos)直接绑定到了 3D 对象的属性上(position, color)。
- 挂载: R3F 初始化 WebGL 上下文,创建场景。
- 更新: React 更新状态 -> R3F 检测到属性变化 -> 更新 Three.js 的对象 -> Three.js 在下一帧渲染时应用变换。
- 卸载: 组件卸载,Three.js 对象从场景中移除。
这看起来很简单,但背后的原理是:R3F 维护了一个“状态到场景图的映射”。当你改变 React 状态时,R3F 会遍历这个映射,调用 Three.js 的 API。
但是! 这里有一个经典的性能陷阱。
如果你在 Scene 组件里放了一万个 Box,然后你只改变了一个 Box 的颜色。R3F 会怎么处理?
如果使用的是“全量同步”,R3F 会重新计算所有 10000 个物体的属性。这虽然比 React 原生 DOM 的全量 Diff 快,但依然很慢。
优化方案:使用 React.memo 和 ref
对于 3D 场景,我们尽量减少不必要的重新渲染。
const OptimizedBox = React.memo(({ position, color }) => {
const meshRef = useRef();
useFrame((state, delta) => {
meshRef.current.rotation.x += delta;
});
return (
<mesh ref={meshRef} position={position}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={color} />
</mesh>
);
});
React.memo 会阻止 OptimizedBox 在父组件重渲染时重新渲染,除非它的 props(位置和颜色)真的变了。这大大提高了效率。
第四幕:高阶同步模式与反模式
现在,我们要深入一点。在实际生产环境中,情况往往比上面的 Demo 复杂得多。我们会遇到数据源、事件监听、以及 WebGL 上下文的复用问题。
1. 模式一:Ref 代理
有时候,我们需要在 Canvas 内部处理一些逻辑,但又要告诉 React 结果。这时候,我们可以利用 useRef 作为“中间人”。
场景: 玩家在 Canvas 上点击,Canvas 计算出点击位置,然后更新 React 状态。
const InteractiveCanvas = () => {
const [hoverPos, setHoverPos] = useState(null);
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 监听鼠标移动
const handleMouseMove = (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 1. Canvas 内部逻辑:计算颜色
// 比如:如果 x > 100,画绿色,否则画红色
// 但我们不需要把颜色存到 React 状态里,那太重了
// 我们只需要把位置传给 React
// 2. 更新 React 状态
setHoverPos({ x, y });
};
canvas.addEventListener('mousemove', handleMouseMove);
return () => canvas.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<div>
<canvas
ref={canvasRef}
width={400}
height={400}
style={{ background: 'lightgray' }}
/>
<div>当前鼠标位置: {hoverPos ? `(${hoverPos.x}, ${hoverPos.y})` : '...'}</div>
</div>
);
};
2. 模式二:WebGL 上下文复用(避免丢失上下文)
这是一个非常硬核的问题。如果你在 Canvas 内部使用 document.createElement('canvas') 动态创建 Canvas,或者频繁地销毁和创建 Canvas,浏览器可能会因为上下文丢失而让你崩溃。
正确做法:
始终使用 React 的 ref 持有同一个 DOM 元素,不要频繁地 appendChild 和 removeChild Canvas 节点。
3. 模式三:数据驱动视图
无论你是写 2D 还是 3D,请记住:数据是真理,渲染是谎言。
React 状态应该只包含“数据”。渲染逻辑应该只负责“读取数据并绘制”。
// ❌ 错误:在渲染循环里修改状态
const BadLoop = () => {
const [pos, setPos] = useState({x: 0, y: 0});
const canvasRef = useRef(null);
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
const animate = () => {
// 糟糕!你在每一帧都修改了状态
// 这会导致 React 每一帧都触发重渲染
// 这就是“状态爆炸”
setPos(prev => ({x: prev.x + 1, y: prev.y + 1}));
ctx.clearRect(0,0, 400, 400);
ctx.fillRect(pos.x, pos.y, 50, 50);
requestAnimationFrame(animate);
}
animate();
}, []);
// ...
}
// ✅ 正确:状态只在外部改变,渲染循环只读
const GoodLoop = () => {
const [pos, setPos] = useState({x: 0, y: 0});
const canvasRef = useRef(null);
useEffect(() => {
const ctx = canvasRef.current.getContext('2d');
const animate = () => {
// 乖乖读数据,别改数据
ctx.clearRect(0,0, 400, 400);
ctx.fillRect(pos.x, pos.y, 50, 50);
requestAnimationFrame(animate);
}
animate();
}, [pos]); // 只有当 pos 真的变了,useEffect 才会重新绑定数据,但不会重启循环
const handleClick = () => {
// 只有这里修改状态
setPos(prev => ({x: Math.random() * 300, y: Math.random() * 300}));
}
return (
<>
<button onClick={handleClick}>移动方块</button>
<canvas ref={canvasRef} width={400} height={400} />
</>
);
}
第五幕:3D 场景中的性能优化与内存泄漏
现在我们讲点“重口味”的。3D 场景里的同步,不仅仅是 React 状态变了,还有 GPU 的状态。
1. 纹理同步
在 2D Canvas 里,你画一张图,它就在内存里。但在 3D 里,你有纹理。
如果你用 React 的 useState 存储一张大图(比如一张 4K 的照片),然后每一帧都把它传给 Three.js 的纹理,那你的 GPU 会哭的。
优化:
使用 useRef 存储 Texture 对象。只在图片加载完成或者图片真正改变时更新纹理。
const TextureLoader = ({ imageUrl }) => {
const textureRef = useRef(null);
const textureLoader = new THREE.TextureLoader();
useEffect(() => {
if (!imageUrl) return;
// 加载纹理
textureLoader.load(imageUrl, (texture) => {
textureRef.current = texture;
});
return () => {
// 卸载时释放内存,非常重要!
if (textureRef.current) {
textureRef.current.dispose();
}
};
}, [imageUrl]);
return <primitive object={textureRef.current} attach="map" />;
};
2. 事件监听器的生命周期
这是 Canvas 开发者的“噩梦”。在 Canvas 上监听 click 或 mousemove,不像 DOM 那样简单。
你需要在组件挂载时 addEventListener,在组件卸载时 removeEventListener。
const CanvasInteraction = () => {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
// 监听点击
const handleClick = (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
console.log(`Clicked at ${x}, ${y}`);
};
canvas.addEventListener('click', handleClick);
return () => {
canvas.removeEventListener('click', handleClick);
};
}, []);
return <canvas ref={canvasRef} width={500} height={500} />;
};
3. 批量更新
React 的 Virtual DOM 机制有一个优化叫“批量更新”。如果你在同一个事件处理函数里多次调用 setState,React 会把它们合并成一次渲染。
在 Canvas 里,这非常重要。如果你在一个 onClick 里同时改变 10 个粒子的位置,不要让 Canvas 重绘 10 次。让 React 合并这 10 次状态更新,然后 Canvas 只重绘 1 次。
第六幕:实战演练——构建一个“反应式”粒子引擎
好了,理论讲够了,我们来做点实战的。假设我们要构建一个粒子系统,粒子受鼠标吸引,并且颜色随速度变化。
这涉及到:
- React 管理所有粒子的数据。
- Canvas 负责高性能渲染。
- 鼠标事件监听。
import React, { useState, useEffect, useRef } from 'react';
const ParticleEngine = () => {
const canvasRef = useRef(null);
const ctxRef = useRef(null);
const particlesRef = useRef([]); // 使用 ref 存储粒子数据,避免 React 重渲染
const mousePosRef = useRef({ x: 0, y: 0 });
const animationIdRef = useRef(null);
// 初始化粒子数据
useEffect(() => {
const particles = [];
for (let i = 0; i < 500; i++) {
particles.push({
x: Math.random() * 800,
y: Math.random() * 600,
vx: (Math.random() - 0.5) * 2,
vy: (Math.random() - 0.5) * 2,
size: Math.random() * 3 + 1,
color: `hsl(${Math.random() * 360}, 70%, 50%)`
});
}
particlesRef.current = particles;
}, []);
// 核心渲染循环
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
ctxRef.current = ctx;
canvas.width = 800;
canvas.height = 600;
const animate = () => {
const ctx = ctxRef.current;
if (!ctx) return;
// 1. 拖尾效果:不完全清空,而是覆盖一层半透明背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const particles = particlesRef.current;
const mouse = mousePosRef.current;
// 2. 更新并绘制
for (let i = 0; i < particles.length; i++) {
let p = particles[i];
// 物理逻辑:鼠标吸引
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 200) {
p.vx += dx * 0.001;
p.vy += dy * 0.001;
}
// 摩擦力
p.vx *= 0.98;
p.vy *= 0.98;
// 更新位置
p.x += p.vx;
p.y += p.vy;
// 边界反弹
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
// 计算颜色:基于速度
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
const hue = (speed * 20) % 360;
// 绘制
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${hue}, 80%, 60%)`;
ctx.fill();
}
animationIdRef.current = requestAnimationFrame(animate);
};
animate();
return () => cancelAnimationFrame(animationIdRef.current);
}, []);
// 鼠标事件监听
useEffect(() => {
const canvas = canvasRef.current;
const handleMouseMove = (e) => {
const rect = canvas.getBoundingClientRect();
mousePosRef.current = {
x: e.clientX - rect.left,
y: e.clientY - rect.top
};
};
canvas.addEventListener('mousemove', handleMouseMove);
return () => canvas.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<p>移动鼠标吸引粒子,观察颜色变化</p>
<canvas ref={canvasRef} style={{ border: '1px solid white' }} />
</div>
);
};
export default ParticleEngine;
解析这个代码:
- 数据隔离:
particlesRef.current存储数据。React 不会因为粒子位置变了就触发useEffect。useEffect只在组件挂载或卸载时运行一次。 - 渲染循环:
animate函数一直在跑。它从ref里读数据,计算物理,画图。 - 交互同步:
mousemove事件更新mousePosRef。渲染循环每一帧都会读取这个新位置,产生视觉反馈。 - 性能:没有在渲染循环里调用
setState。没有在渲染循环里创建新对象。
这就是高效同步的精髓:React 负责数据的“源”,Canvas 负责数据的“流”。
第七幕:React Three Fiber 的进阶技巧
既然提到了 3D,我们就不能只停留在表面。R3F 提供了很多高级模式来处理状态同步。
1. useResource
有时候,我们需要在渲染循环中访问 Three.js 的资源(比如 Mesh,或者 Texture),但普通的 useRef 在 R3F 的渲染上下文中可能不太好用。useResource 是专门为此设计的。
import { useFrame, useResource, Canvas } from '@react-three/fiber';
const DynamicLight = () => {
const [lightRef] = useResource(); // 获取光源引用
useFrame(() => {
// 每一帧旋转光源
if (lightRef.current) {
lightRef.current.position.x = Math.sin(Date.now() * 0.001) * 5;
}
});
return <pointLight ref={lightRef} position={[0, 0, 0]} intensity={1} color="white" />;
};
2. useThree (访问全局 Three.js 状态)
useThree 钩子允许你访问渲染器、相机、场景等全局对象。这可以用来做一些特殊的同步操作,比如根据相机的距离动态改变物体的材质。
import { useFrame, useThree } from '@react-three/fiber';
const AdaptiveMesh = () => {
const meshRef = useRef();
const { camera } = useThree();
useFrame(() => {
const distance = camera.position.distanceTo(meshRef.current.position);
// 根据距离改变颜色
if (meshRef.current.material) {
meshRef.current.material.color.setHSL(distance * 0.1, 1, 0.5);
}
});
return <mesh ref={meshRef}><sphereGeometry args={[1, 16, 16]} /><meshStandardMaterial /></mesh>;
};
第八幕:总结——如何优雅地驾驭野兽
好了,各位听众,时间差不多了。我们来总结一下在 React 生命周期中高效同步 Canvas 状态的“心法”。
- 认清角色:React 是数据管理员,Canvas 是绘图员。管理员发号施令,绘图员干活。不要让绘图员去管数据,也不要让管理员去拿画笔。
- 生命周期分离:
useEffect(Mount/Unmount):初始化 Context,绑定事件监听器,启动动画循环。render(JSX):只负责渲染 UI(如果是混合模式),或者只负责读取状态。requestAnimationFrame/useFrame:负责利用状态进行绘制。这是核心!
- 避免重渲染:使用
useRef缓存 Context、Canvas 对象和动画 ID。使用React.memo优化 3D 组件。 - 清理垃圾:组件卸载时,一定要
cancelAnimationFrame,一定要removeEventListener,一定要dispose纹理。否则,你的应用会像漏水的浴缸一样,内存迟早会爆。 - 批量更新:利用 React 的批量更新机制,尽量减少状态变更的频率。
最后,我想说,React + Canvas 是一把双刃剑。用好了,它能创造出令人惊叹的交互体验,性能吊打传统 DOM 操作;用不好,它就是 CPU 的粉碎机和内存的坟墓。
记住,代码是写给人看的,顺便给机器运行。保持逻辑清晰,保持幽默感(虽然写代码的时候通常没空笑),你就能成为那个驾驭 Canvas 的资深专家。
好了,今天的讲座就到这里。如果你们在实战中遇到了什么“Bug”,记得多读几遍这篇讲义,或者——别管了,直接去写代码,Debug 是最好的老师。
谢谢大家!