使用 React + Konva 构建交互式立方体绘制工具
项目概述
最近在学习 Konva.js,正好实战一下,本文将详细介绍如何使用 React 和 Konva.js 构建一个交互式的立方体绘制工具。该工具允许用户通过四次点击来绘制一个3D立方体的投影,具有实时预览和多立方体支持功能。
技术栈
- React 19.1.0 - 前端框架
- Konva 9.3.22 - 2D Canvas 图形库
- react-konva 19.0.7 - React 的 Konva 绑定
- Vite - 构建工具
核心功能特性
1. 状态驱动的绘制流程
应用采用状态机模式管理绘制过程:
javascript
// 绘制状态:0-等待第一个点,1-绘制矩形,2-等待第二个点,3-等待第三个点,4-绘制立方体,5-完成
const [drawingState, setDrawingState] = useState(0);
2. 四步绘制流程详解
第一步:确定第一个矩形的起始点
当用户第一次点击画布时,应用记录起始点并进入动态矩形绘制状态:
javascript
if (drawingState === 0) {
// 第一个点
setPoints([pos]);
setDrawingState(1);
}
此时鼠标移动会触发动态矩形的实时预览:
javascript
// 在 handleMouseMove 中
if (drawingState === 1 && points.length === 1) {
const corners = calculateRectCorners(points[0], pos);
setRectCorners(corners);
}
// 动态矩形渲染
const renderDynamicRect = () => {
if (drawingState === 1 && points.length === 1) {
const width = Math.abs(mousePos.x - points[0].x);
const height = Math.abs(mousePos.y - points[0].y);
const x = Math.min(points[0].x, mousePos.x);
const y = Math.min(points[0].y, mousePos.y);
return (
<Rect
x={x} y={y} width={width} height={height}
stroke="blue" strokeWidth={2}
dash={[5, 5]} fill="transparent"
/>
);
}
return null;
};
第二步:完成第一个矩形(前面)
第二次点击确定矩形的对角点,完成前面矩形的绘制:
javascript
else if (drawingState === 1) {
// 第二个点,确定矩形
const newPoints = [...points, pos];
setPoints(newPoints);
const corners = calculateRectCorners(points[0], pos);
setRectCorners(corners);
setDrawingState(2);
}
此时渲染固定的矩形,并显示从矩形四角到鼠标的虚线预览:
javascript
// 固定矩形渲染
const renderFixedRect = () => {
if (drawingState === 2 && rectCorners.length === 4) {
const minX = Math.min(...rectCorners.map(p => p.x));
const maxX = Math.max(...rectCorners.map(p => p.x));
const minY = Math.min(...rectCorners.map(p => p.y));
const maxY = Math.max(...rectCorners.map(p => p.y));
return (
<Rect
x={minX} y={minY}
width={maxX - minX} height={maxY - minY}
stroke="black" strokeWidth={2}
fill="rgba(173, 216, 230, 0.3)"
/>
);
}
return null;
};
// 虚线连接预览
const renderDashedLines = () => {
if (drawingState === 2 && rectCorners.length === 4) {
return rectCorners.map((corner, index) => (
<Line
key={`dynamic-${index}`}
points={[corner.x, corner.y, mousePos.x, mousePos.y]}
stroke="gray" strokeWidth={1} dash={[3, 3]}
/>
));
}
return null;
};
第三步:确定第二个矩形的起始点
第三次点击确定后面矩形的起始点:
javascript
else if (drawingState === 2) {
// 第三个点
const newPoints = [...points, pos];
setPoints(newPoints);
setDrawingState(3);
}
此时开始显示完整的立方体预览,包括前面矩形、后面矩形和连接线:
javascript
const renderDynamicCube = () => {
if (drawingState === 3 && points.length >= 3 && rectCorners.length === 4) {
const point3 = points[2];
const point4 = mousePos; // 当前鼠标位置作为第四个点
// 计算第二个矩形的四个角点
const backCorners = calculateRectCorners(point3, point4);
return (
<>
{/* 前面的矩形(已确定) */}
<Rect
x={Math.min(...rectCorners.map(p => p.x))}
y={Math.min(...rectCorners.map(p => p.y))}
width={Math.max(...rectCorners.map(p => p.x)) - Math.min(...rectCorners.map(p => p.x))}
height={Math.max(...rectCorners.map(p => p.y)) - Math.min(...rectCorners.map(p => p.y))}
fill="rgba(173, 216, 230, 0.3)"
stroke="black" strokeWidth={2}
/>
{/* 后面的矩形(动态生成) */}
<Rect
x={Math.min(...backCorners.map(p => p.x))}
y={Math.min(...backCorners.map(p => p.y))}
width={Math.max(...backCorners.map(p => p.x)) - Math.min(...backCorners.map(p => p.x))}
height={Math.max(...backCorners.map(p => p.y)) - Math.min(...backCorners.map(p => p.y))}
fill="rgba(255, 255, 0, 0.2)"
stroke="blue" strokeWidth={2}
/>
{/* 连接前后两个矩形对应角点的线 */}
{rectCorners.map((corner, index) => (
<Line
key={`cube-edge-${index}`}
points={[corner.x, corner.y, backCorners[index].x, backCorners[index].y]}
stroke="green" strokeWidth={2} dash={[3, 3]}
/>
))}
</>
);
}
return null;
};
第四步:完成立方体绘制
第四次点击确定后面矩形的对角点,完成整个立方体的绘制:
javascript
else if (drawingState === 3) {
// 第四个点,完成立方体
// 计算第二个矩形的四个角点
const backCorners = calculateRectCorners(points[2], pos);
// 保存完成的立方体(包含两个矩形的角点)
const newCube = {
frontRect: [...rectCorners], // 第一个矩形
backRect: [...backCorners] // 第二个矩形
};
setCompletedCubes([...completedCubes, newCube]);
// 重置状态,准备绘制下一个立方体
setPoints([]);
setRectCorners([]);
setDrawingState(0);
}
完成的立方体会被永久保存并渲染:
javascript
const renderCompletedCubes = () => {
return completedCubes.map((cube, cubeIndex) => {
const { frontRect, backRect } = cube;
return (
<React.Fragment key={`cube-${cubeIndex}`}>
{/* 前面的矩形 */}
<Rect
x={Math.min(...frontRect.map(p => p.x))}
y={Math.min(...frontRect.map(p => p.y))}
width={Math.max(...frontRect.map(p => p.x)) - Math.min(...frontRect.map(p => p.x))}
height={Math.max(...frontRect.map(p => p.y)) - Math.min(...frontRect.map(p => p.y))}
fill="rgba(173, 216, 230, 0.5)"
stroke="black" strokeWidth={3}
/>
{/* 后面的矩形 */}
<Rect
x={Math.min(...backRect.map(p => p.x))}
y={Math.min(...backRect.map(p => p.y))}
width={Math.max(...backRect.map(p => p.x)) - Math.min(...backRect.map(p => p.x))}
height={Math.max(...backRect.map(p => p.y)) - Math.min(...backRect.map(p => p.y))}
fill="rgba(255, 255, 0, 0.3)"
stroke="black" strokeWidth={3}
/>
{/* 连接前后两个矩形对应角点的线 */}
{frontRect.map((corner, index) => (
<Line
key={`completed-edge-${cubeIndex}-${index}`}
points={[corner.x, corner.y, backRect[index].x, backRect[index].y]}
stroke="black" strokeWidth={3}
/>
))}
</React.Fragment>
);
});
};
3. 实时预览功能
- 动态矩形预览:第一次点击后,矩形跟随鼠标实时变化
- 虚线连接预览:第二次点击后,显示从矩形四角到鼠标的虚线
- 立方体预览:第三次点击后,实时显示完整的立方体结构
核心实现解析
状态管理架构
应用使用多个 React 状态来管理绘制过程:
javascript
const [drawingState, setDrawingState] = useState(0); // 当前绘制阶段
const [points, setPoints] = useState([]); // 用户点击的点
const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); // 实时鼠标位置
const [rectCorners, setRectCorners] = useState([]); // 第一个矩形的四个角点
const [completedCubes, setCompletedCubes] = useState([]); // 已完成的立方体
关键算法:矩形角点计算
这是整个应用的核心算法,将两个对角点转换为矩形的四个角点:
javascript
const calculateRectCorners = (point1, point2) => {
return [
{ x: point1.x, y: point1.y }, // 左上角
{ x: point2.x, y: point1.y }, // 右上角
{ x: point2.x, y: point2.y }, // 右下角
{ x: point1.x, y: point2.y } // 左下角
];
};
这个函数确保了无论用户如何拖拽,都能正确计算出矩形的四个角点,为后续的立方体连接提供准确的坐标。
事件处理系统
鼠标移动处理
javascript
const handleMouseMove = (e) => {
const pos = e.target.getStage().getPointerPosition();
setMousePos(pos);
// 状态1:动态绘制矩形
if (drawingState === 1 && points.length === 1) {
const corners = calculateRectCorners(points[0], pos);
setRectCorners(corners);
}
};
鼠标点击处理
点击事件根据当前状态执行不同的逻辑,这是状态机模式的典型应用。每次点击都会推进绘制流程到下一个阶段。
状态转换流程图
scss
状态 0 (等待第一个点)
↓ 第一次点击
状态 1 (绘制矩形) ← 鼠标移动时实时预览矩形
↓ 第二次点击
状态 2 (等待第三个点) ← 鼠标移动时显示虚线连接
↓ 第三次点击
状态 3 (等待第四个点) ← 鼠标移动时预览完整立方体
↓ 第四次点击
状态 0 (重置,可绘制下一个立方体)
数据结构设计
点的存储
javascript
// points 数组存储用户点击的点
[
{ x: 100, y: 100 }, // 第一个点
{ x: 200, y: 200 }, // 第二个点
{ x: 150, y: 150 }, // 第三个点
// 第四个点直接用于计算,不存储
]
立方体数据结构
javascript
// 完成的立方体存储格式
{
frontRect: [
{ x: 100, y: 100 }, // 前面矩形的四个角点
{ x: 200, y: 100 },
{ x: 200, y: 200 },
{ x: 100, y: 200 }
],
backRect: [
{ x: 150, y: 150 }, // 后面矩形的四个角点
{ x: 250, y: 150 },
{ x: 250, y: 250 },
{ x: 150, y: 250 }
]
}
渲染系统
应用采用模块化的渲染方式:
- renderDynamicRect() - 动态矩形渲染
- renderFixedRect() - 固定矩形渲染
- renderDashedLines() - 虚线连接渲染
- renderDynamicCube() - 动态立方体渲染
- renderCompletedCubes() - 完成立方体渲染
- renderPoints() - 点击点渲染
视觉设计亮点
颜色方案
- 前面矩形 :浅蓝色填充
rgba(173, 216, 230, 0.3)
- 后面矩形 :浅黄色填充
rgba(255, 255, 0, 0.2)
- 连接线:绿色虚线表示预览,黑色实线表示完成
交互反馈
- 鼠标样式:十字光标提示绘制模式
- 点标记:蓝色圆点标记前两个点,红色圆点标记第三个点
- 虚线预览:提供清晰的视觉引导
技术难点与解决方案
1. 3D 投影的 2D 表示
挑战:如何在2D画布上表现3D立方体的空间感?
解决方案:
- 使用两个平行矩形表示立方体的前后面
- 通过四条连接线表示立方体的深度和透视关系
- 采用不同的颜色和透明度增强层次感
javascript
// 前面矩形:较深的颜色,表示距离观察者更近
fill="rgba(173, 216, 230, 0.5)"
// 后面矩形:较浅的颜色,表示距离观察者更远
fill="rgba(255, 255, 0, 0.3)"
// 连接线:表示立方体的边,创造深度感
{frontRect.map((corner, index) => (
<Line
points={[corner.x, corner.y, backRect[index].x, backRect[index].y]}
stroke="black" strokeWidth={3}
/>
))}
2. 实时预览的性能优化
挑战:鼠标移动时频繁的重渲染可能导致性能问题。
解决方案:
- 使用条件渲染,只在特定状态下渲染预览元素
- 将渲染逻辑拆分为独立的函数,便于 React 优化
- 避免在渲染函数中进行复杂计算
javascript
// 条件渲染避免不必要的计算
const renderDynamicRect = () => {
if (drawingState === 1 && points.length === 1) {
// 只在需要时进行计算和渲染
return <Rect ... />;
}
return null; // 其他状态下不渲染
};
3. 状态一致性管理
挑战:确保鼠标移动和点击事件的状态始终同步。
解决方案:
- 使用统一的状态管理,所有组件共享同一个状态源
- 在状态转换时同时更新相关的所有状态
- 使用严格的状态检查避免异常情况
javascript
// 状态转换时的完整更新
if (drawingState === 3) {
const backCorners = calculateRectCorners(points[2], pos);
const newCube = {
frontRect: [...rectCorners],
backRect: [...backCorners]
};
// 原子性更新:同时更新多个相关状态
setCompletedCubes([...completedCubes, newCube]);
setPoints([]); // 清空点击点
setRectCorners([]); // 清空矩形角点
setDrawingState(0); // 重置绘制状态
}
4. 坐标系统的处理
挑战:处理用户可能以任意方向拖拽矩形的情况。
解决方案:
- 使用
Math.min
和Math.max
确保矩形坐标的正确性 - 计算角点时考虑所有可能的拖拽方向
javascript
// 处理任意方向的拖拽
const width = Math.abs(mousePos.x - points[0].x);
const height = Math.abs(mousePos.y - points[0].y);
const x = Math.min(points[0].x, mousePos.x);
const y = Math.min(points[0].y, mousePos.y);
5. 多立方体的层次管理
挑战:多个立方体重叠时的显示顺序和交互问题。
解决方案:
- 使用数组顺序控制渲染层次(后绘制的在上层)
- 为每个立方体分配唯一的 key 值
- 完成的立方体使用更粗的线条和更高的透明度
javascript
// 渲染顺序:先渲染完成的立方体,再渲染当前绘制的
{renderCompletedCubes()} // 底层
{renderDynamicCube()} // 顶层
最终效果

完整代码
js
import React, { useState } from "react";
import { Stage, Layer, Rect, Line, Circle } from "react-konva";
import './App.css'
export default function CubeDrawer() {
// 绘制状态:0-等待第一个点,1-绘制矩形,2-等待第二个点,3-等待第三个点,4-绘制立方体,5-完成
const [drawingState, setDrawingState] = useState(0);
// 存储点击的点
const [points, setPoints] = useState([]);
// 当前鼠标位置
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// 矩形的四个角点
const [rectCorners, setRectCorners] = useState([]);
// 完成的立方体
const [completedCubes, setCompletedCubes] = useState([]);
// 计算矩形的四个角点
const calculateRectCorners = (point1, point2) => {
return [
{ x: point1.x, y: point1.y }, // 左上
{ x: point2.x, y: point1.y }, // 右上
{ x: point2.x, y: point2.y }, // 右下
{ x: point1.x, y: point2.y } // 左下
];
};
// 处理鼠标移动
const handleMouseMove = (e) => {
const pos = e.target.getStage().getPointerPosition();
setMousePos(pos);
// 状态1:动态绘制矩形
if (drawingState === 1 && points.length === 1) {
const corners = calculateRectCorners(points[0], pos);
setRectCorners(corners);
}
};
// 处理鼠标点击
const handleMouseClick = (e) => {
const pos = e.target.getStage().getPointerPosition();
if (drawingState === 0) {
// 第一个点
setPoints([pos]);
setDrawingState(1);
} else if (drawingState === 1) {
// 第二个点,确定矩形
const newPoints = [...points, pos];
setPoints(newPoints);
const corners = calculateRectCorners(points[0], pos);
setRectCorners(corners);
setDrawingState(2);
} else if (drawingState === 2) {
// 第三个点
const newPoints = [...points, pos];
setPoints(newPoints);
setDrawingState(3);
} else if (drawingState === 3) {
// 第四个点,完成立方体
// 计算第二个矩形的四个角点
const backCorners = calculateRectCorners(points[2], pos);
// 保存完成的立方体(包含两个矩形的角点)
const newCube = {
frontRect: [...rectCorners], // 第一个矩形
backRect: [...backCorners] // 第二个矩形
};
setCompletedCubes([...completedCubes, newCube]);
// 重置状态
setPoints([]);
setRectCorners([]);
setDrawingState(0);
}
};
// 渲染动态矩形
const renderDynamicRect = () => {
if (drawingState === 1 && points.length === 1) {
const width = Math.abs(mousePos.x - points[0].x);
const height = Math.abs(mousePos.y - points[0].y);
const x = Math.min(points[0].x, mousePos.x);
const y = Math.min(points[0].y, mousePos.y);
return (
<Rect
x={x}
y={y}
width={width}
height={height}
stroke="blue"
strokeWidth={2}
dash={[5, 5]}
fill="transparent"
/>
);
}
return null;
};
// 渲染确定的矩形(只在状态2时显示,状态3时由动态立方体处理)
const renderFixedRect = () => {
if (drawingState === 2 && rectCorners.length === 4) {
const minX = Math.min(...rectCorners.map(p => p.x));
const maxX = Math.max(...rectCorners.map(p => p.x));
const minY = Math.min(...rectCorners.map(p => p.y));
const maxY = Math.max(...rectCorners.map(p => p.y));
return (
<Rect
x={minX}
y={minY}
width={maxX - minX}
height={maxY - minY}
stroke="black"
strokeWidth={2}
fill="rgba(173, 216, 230, 0.3)"
/>
);
}
return null;
};
// 渲染虚线连接(第三个点到矩形四个角)
const renderDashedLines = () => {
if (drawingState === 2 && rectCorners.length === 4) {
// 鼠标移动时的虚线(只在状态2时显示)
return rectCorners.map((corner, index) => (
<Line
key={`dynamic-${index}`}
points={[corner.x, corner.y, mousePos.x, mousePos.y]}
stroke="gray"
strokeWidth={1}
dash={[3, 3]}
/>
));
}
// 状态3时不显示红色虚线,因为已经在绘制立方体了
return null;
};
// 渲染动态立方体(第三次点击后,鼠标移动时)
const renderDynamicCube = () => {
if (drawingState === 3 && points.length >= 3 && rectCorners.length === 4) {
const point3 = points[2];
const point4 = mousePos; // 当前鼠标位置作为第四个点
// 计算第二个矩形的四个角点(基于第三个点和第四个点)
const backCorners = calculateRectCorners(point3, point4);
return (
<>
{/* 前面的矩形(已确定的第一个矩形) */}
<Rect
x={Math.min(...rectCorners.map(p => p.x))}
y={Math.min(...rectCorners.map(p => p.y))}
width={Math.max(...rectCorners.map(p => p.x)) - Math.min(...rectCorners.map(p => p.x))}
height={Math.max(...rectCorners.map(p => p.y)) - Math.min(...rectCorners.map(p => p.y))}
fill="rgba(173, 216, 230, 0.3)"
stroke="black"
strokeWidth={2}
/>
{/* 后面的矩形(动态生成的第二个矩形) */}
<Rect
x={Math.min(...backCorners.map(p => p.x))}
y={Math.min(...backCorners.map(p => p.y))}
width={Math.max(...backCorners.map(p => p.x)) - Math.min(...backCorners.map(p => p.x))}
height={Math.max(...backCorners.map(p => p.y)) - Math.min(...backCorners.map(p => p.y))}
fill="rgba(255, 255, 0, 0.2)"
stroke="blue"
strokeWidth={2}
/>
{/* 连接前后两个矩形对应角点的线 */}
{rectCorners.map((corner, index) => (
<Line
key={`cube-edge-${index}`}
points={[corner.x, corner.y, backCorners[index].x, backCorners[index].y]}
stroke="green"
strokeWidth={2}
dash={[3, 3]}
/>
))}
</>
);
}
return null;
};
// 渲染完成的立方体
const renderCompletedCubes = () => {
return completedCubes.map((cube, cubeIndex) => {
const { frontRect, backRect } = cube;
return (
<React.Fragment key={`cube-${cubeIndex}`}>
{/* 前面的矩形 */}
<Rect
x={Math.min(...frontRect.map(p => p.x))}
y={Math.min(...frontRect.map(p => p.y))}
width={Math.max(...frontRect.map(p => p.x)) - Math.min(...frontRect.map(p => p.x))}
height={Math.max(...frontRect.map(p => p.y)) - Math.min(...frontRect.map(p => p.y))}
fill="rgba(173, 216, 230, 0.5)"
stroke="black"
strokeWidth={3}
/>
{/* 后面的矩形 */}
<Rect
x={Math.min(...backRect.map(p => p.x))}
y={Math.min(...backRect.map(p => p.y))}
width={Math.max(...backRect.map(p => p.x)) - Math.min(...backRect.map(p => p.x))}
height={Math.max(...backRect.map(p => p.y)) - Math.min(...backRect.map(p => p.y))}
fill="rgba(255, 255, 0, 0.3)"
stroke="black"
strokeWidth={3}
/>
{/* 连接前后两个矩形对应角点的线 */}
{frontRect.map((corner, index) => (
<Line
key={`completed-edge-${cubeIndex}-${index}`}
points={[corner.x, corner.y, backRect[index].x, backRect[index].y]}
stroke="black"
strokeWidth={3}
/>
))}
</React.Fragment>
);
});
};
// 渲染点击的点
const renderPoints = () => {
return points.map((point, index) => (
<Circle
key={`point-${index}`}
x={point.x}
y={point.y}
radius={4}
fill={index === 2 ? "red" : "blue"}
stroke="black"
strokeWidth={1}
/>
));
};
return (
<div>
<Stage
width={800}
height={600}
onMouseMove={handleMouseMove}
onClick={handleMouseClick}
style={{ border: "1px solid #ccc", cursor: "crosshair" }}
>
<Layer>
{/* 渲染完成的立方体 */}
{renderCompletedCubes()}
{/* 渲染当前绘制的矩形 */}
{renderDynamicRect()}
{renderFixedRect()}
{/* 渲染虚线连接 */}
{renderDashedLines()}
{/* 渲染动态立方体 */}
{renderDynamicCube()}
{/* 渲染点击的点 */}
{renderPoints()}
</Layer>
</Stage>
</div>
);
}
扩展可能性
- 撤销/重做功能:添加操作历史管理
- 立方体编辑:支持选中和修改已绘制的立方体
- 导出功能:将绘制结果导出为图片或SVG
- 3D 渲染:集成 Three.js 实现真正的3D效果
- 颜色自定义:允许用户自定义立方体颜色
- 网格对齐:添加网格和对齐功能