前言
最近在做一个可视化项目时,需要实现在 Canvas 上绘制矩形并支持拖拽功能。这个看似简单的需求,实际上涉及了不少前端技术细节。今天就来分享一下如何从零开始实现一个支持绘制、拖拽、自定义光标的 Canvas 应用。
最终实现的效果:
- 🎨 点击空白区域绘制彩色矩形
- 🖱️ 点击已有矩形进行拖拽移动
- 🎯 精确的相对位置拖拽(不会跳到左上角)

技术栈
- 原生 JavaScript
- HTML5 Canvas API
- CSS 自定义光标
- SVG 图标
核心实现
1. Canvas 初始化与高 DPI 适配
javascript
function init() {
const w = 800,
h = 500
cvs.width = w * devicePixelRatio
cvs.height = h * devicePixelRatio
cvs.style.width = w + 'px'
cvs.style.height = h + 'px'
}
关键点 :通过devicePixelRatio
适配高 DPI 屏幕,避免图形模糊。
2. 矩形类设计
javascript
class Rectangle {
constructor(startX, startY, color) {
this.startX = startX
this.startY = startY
this.color = color
this.endX = startX
this.endY = startY
this.dragOffsetX = 0 // 拖拽偏移量
this.dragOffsetY = 0
}
// 获取矩形边界
get minX() {
return Math.min(this.startX, this.endX)
}
get minY() {
return Math.min(this.startY, this.endY)
}
get maxX() {
return Math.max(this.startX, this.endX)
}
get maxY() {
return Math.max(this.startY, this.endY)
}
// 检测点击是否在矩形内
isInside(x, y) {
return this.minX <= x && this.maxX >= x && this.minY <= y && this.maxY >= y
}
}
3. 拖拽功能的核心难点
问题:拖拽时矩形跳到左上角
最初的 naive 实现:
javascript
// ❌ 错误实现
move(newX, newY) {
this.startX = newX
this.startY = newY
// 矩形会跳到鼠标位置的左上角
}
解决方案:记录相对偏移量
javascript
// ✅ 正确实现
move(newX, newY) {
const width = this.maxX - this.minX
const height = this.maxY - this.minY
// 考虑鼠标点击位置的偏移
this.startX = newX - this.dragOffsetX
this.startY = newY - this.dragOffsetY
this.endX = this.startX + width
this.endY = this.startY + height
}
4. 事件处理逻辑
javascript
cvs.onmousedown = (e) => {
const startX = e.offsetX
const startY = e.offsetY
const shape = getShape(startX, startY)
if (shape) {
// 拖拽模式:记录偏移量
shape.dragOffsetX = startX - shape.minX
shape.dragOffsetY = startY - shape.minY
const rect = cvs.getBoundingClientRect()
window.onmousemove = (e) => {
const ex = e.clientX - rect.left
const ey = e.clientY - rect.top
shape.move(ex, ey)
}
} else {
// 绘制模式:创建新矩形
const shape = new Rectangle(startX, startY, colorPicker.value)
shapes.push(shape)
// ... 绘制逻辑
}
}
5. 自定义彩色光标
系统默认光标无法修改颜色,解决方案是使用 SVG 自定义光标:
css
.custom-crosshair {
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%23ff0000" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg>')
12 12, crosshair;
}
.custom-grab {
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%230000ff" stroke-width="2"><path d="M9 11V7a3 3 0 0 1 6 0v4"/><path d="M12 12v9"/></svg>')
12 12, grab;
}
技巧:
- 使用
data:image/svg+xml
内联 SVG %23
是#
的 URL 编码12 12
指定光标热点位置
6. 智能光标切换
javascript
cvs.onmousemove = (e) => {
const x = e.offsetX
const y = e.offsetY
const shape = getShape(x, y)
if (shape) {
cvs.classList.remove('custom-crosshair')
cvs.classList.add('custom-grab')
} else {
cvs.classList.remove('custom-grab')
cvs.classList.add('custom-crosshair')
}
}
踩坑记录
坑 1:坐标系统混乱
Canvas 有多套坐标系统:
e.offsetX/Y
:相对于 Canvas 元素e.clientX/Y
:相对于视口- Canvas 内部坐标需要乘以
devicePixelRatio
坑 2:事件监听器清理
javascript
window.onmouseup = () => {
window.onmousemove = null // 必须清理
window.onmouseup = null // 避免内存泄漏
}
坑 3:拖拽偏移计算
关键是在mousedown
时就计算好偏移量,而不是在mousemove
时才计算。
性能优化
1. requestAnimationFrame 渲染循环
javascript
function draw() {
requestAnimationFrame(draw)
ctx.clearRect(0, 0, cvs.width, cvs.height)
for (const shape of shapes) {
shape.draw(ctx)
}
}
2. 事件委托
使用window
监听mousemove
避免频繁绑定/解绑事件。
3. 碰撞检测优化
javascript
function getShape(x, y) {
// 从后往前遍历,优先选择上层图形
for (let i = shapes.length - 1; i >= 0; i--) {
if (shapes[i].isInside(x, y)) {
return shapes[i]
}
}
return null
}
扩展思路
基于这个基础框架,可以扩展出更多功能:
- 多选功能:Ctrl+点击支持多选
- 缩放旋转:添加控制点实现图形变换
- 撤销重做:使用命令模式
- 图层管理:Z-index 排序
- 导出功能:toDataURL 导出图片
- 框选功能:矩形选择区域
总结
Canvas 拖拽看似简单,实际上需要考虑:
- 坐标系统转换
- 事件处理机制
- 拖拽偏移计算
- 性能优化
- 用户体验细节
通过这个项目,我们不仅实现了基础功能,还学会了如何处理复杂的交互逻辑。Canvas 的强大之处在于给了我们完全的控制权,但也需要我们自己处理所有的细节。
希望这篇文章对正在学习 Canvas 开发的小伙伴有所帮助!如果你有任何问题或者更好的实现方案,欢迎在评论区讨论。