HTML5 Canvas 是一个强大的绘图工具,它允许我们在网页上绘制复杂的图形、动画和游戏。然而,Canvas 与 DOM 或 SVG 不同,它本质上是一个"哑"画布,它只是一个像素缓冲区。你画上去的矩形、圆形或文本,在 Canvas 看来都只是像素点,它本身并不"知道"那里有一个"对象"。
这就带来了一个核心挑战:Canvas 元素本身只能监听到整个画布的鼠标事件(如 click, mousemove),但它无法告诉你用户具体点击了哪个图形。
要创建丰富的交互式体验(如拖拽图形、点击按钮、悬停提示),我们必须自己构建一个事件系统。这个系统需要在 Canvas 的像素之上,抽象出一个"对象"层,并管理这些对象上的事件。
1. 从"坐标"到"对象"
我们的目标是实现类似 DOM 的事件绑定:
JavaScript
javascript
// 我们想要这个
const myRect = new Rect({ x: 10, y: 10, width: 50, height: 50 });
stage.add(myRect);
myRect.on('click', (event) => {
console.log('矩形被点击了!');
});
// 而不是这个
canvas.addEventListener('click', (e) => {
const x = e.offsetX;
const y = e.offsetY;
// 手动检查 x, y 是否在 myRect 的范围内...
// 如果有 1000 个图形怎么办?
});
要实现这一目标,我们的事件系统必须解决三个核心问题:
- 场景管理:如何跟踪画布上所有"对象"(图形)的位置和状态?
- 命中检测 :当鼠标在 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , y ) (x, y) </math>(x,y) 坐标点发生事件时,如何快速判断哪个图形被"击中"了?
- 事件分发:当一个图形被击中时,如何触发它上面绑定的回调函数,并模拟事件冒泡等行为?
2. 系统架构
一个完整的 Canvas 事件系统通常包含以下几个核心组件:
2.1 场景图(Scene Graph)或显示列表(Display List)
这是我们管理所有图形的"数据库"。最简单的是一个数组(显示列表),按照绘制顺序存储所有图形对象。
JavaScript
ini
// 简单的显示列表
const children = [shape1, shape2, shape3];
更高级的实现是一个树状结构(场景图),允许图形分组(Group),这对于实现事件冒泡和复杂的坐标变换至关重要。
每个图形对象(Shape)至少应包含:
- 位置和尺寸属性(
x,y,width,height,radius等)。 - 一个
draw(ctx)方法,用于在 Canvas 上下文中绘制自己。 - 一个
isPointInside(x, y)方法,用于命中检测。
2.2 原生事件侦听器(Native Event Listener)
这是系统的入口。我们需要在 <canvas> 元素上绑定原生的浏览器事件。
JavaScript
kotlin
class Stage {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.children = []; // 我们的显示列表
this._initListeners();
}
_initListeners() {
// 监听我们关心的所有事件
this.canvas.addEventListener('click', this._handleEvent.bind(this));
this.canvas.addEventListener('mousemove', this._handleEvent.bind(this));
this.canvas.addEventListener('mousedown', this._handleEvent.bind(this));
this.canvas.addEventListener('mouseup', this._handleEvent.bind(this));
// 还可以包括 touchstart, touchend, touchmove 等
}
_handleEvent(e) {
// 统一处理所有事件
// ...
}
}
2.3 命中检测(Hit Detection)
这是事件系统的核心算法。当 _handleEvent 被触发时,我们需要获取鼠标坐标,然后遍历我们的显示列表,找出哪个图形被"击中"了。
关键点:Z-Index(堆叠顺序)
Canvas 是"后来者居上"的。后绘制的图形会覆盖先绘制的。因此,当事件发生时,我们应该优先检测最上层(最后绘制)的图形。
这意味着我们应该反向遍历显示列表:
JavaScript
ini
_handleEvent(e) {
const x = e.offsetX;
const y = e.offsetY;
const eventType = e.type; // 'click', 'mousemove' 等
let targetShape = null;
// 从后向前遍历(最上层的图形最先被检测)
for (let i = this.children.length - 1; i >= 0; i--) {
const shape = this.children[i];
if (shape.isPointInside(x, y)) {
targetShape = shape;
// 找到了!停止遍历
break;
}
}
if (targetShape) {
// 找到了目标,现在分发事件
this._dispatchEvent(targetShape, eventType, e);
}
}
命中检测的策略
isPointInside(x, y) 方法的实现方式各不相同:
-
几何算法(AABB)
-
矩形(Axis-Aligned Bounding Box, AABB) :最简单的。
JavaScript
kotlinisPointInside(x, y) { return x >= this.x && x <= this.x + this.width && y >= this.y && y <= this.y + this.height; } -
圆形:计算点到圆心的距离。
JavaScript
kotlinisPointInside(x, y) { const dx = x - this.x; // this.x, this.y 是圆心 const dy = y - this.y; return (dx * dx + dy * dy) <= (this.radius * this.radius); } -
多边形 :通常使用 射线投射算法(Ray-casting Algorithm)。
-
-
Canvas API 路径检测(isPointInPath)
Canvas 2D 上下文提供了一个强大的方法:isPointInPath(x, y) 和 isPointInStroke(x, y)。
它允许你"重播"一个图形的绘制路径,然后询问浏览器某个点是否在该路径的内部或描边上。
JavaScript
kotlinisPointInside(x, y) { // 必须在传入的 context 上下文中重绘路径 // 注意:这里我们不能真的 "draw",只是 "build path" this.ctx.beginPath(); this.ctx.rect(this.x, this.y, this.width, this.height); // ... 或者 arc, moveTo, lineTo 等 this.ctx.closePath(); return this.ctx.isPointInPath(x, y); }- 优点:极其精确,能处理任何复杂的路径(贝塞尔曲线、不规则多边形等),甚至可以检测描边。
- 缺点:可能存在性能开销,因为它需要重新构建路径(尽管不实际渲染像素)。
性能提示 :在大型场景中(数千个对象),
mousemove事件上的命中检测开销巨大。一个常见的优化是:
先进行快速的**包围盒(Bounding Box)**检测。
如果包围盒命中,再进行精确的(如
isPointInPath)检测。对于非常大的场景,使用**空间索引(如 Quadtree 四叉树)**来快速剔除不在鼠标附近的
对象,避免遍历整个列表。
2.4 事件分发器(Event Dispatcher)
当我们通过命中检测找到了 targetShape 后,需要一种机制来"触发"该图形上的自定义事件。这通常通过在 Shape 基类上实现一个简单的"发布-订阅"模式来完成。
JavaScript
javascript
// 在 Shape 基类中添加
class Shape {
constructor() {
this._listeners = {}; // 存储事件回调
}
// 订阅事件
on(eventType, callback) {
if (!this._listeners[eventType]) {
this._listeners[eventType] = [];
}
this._listeners[eventType].push(callback);
}
// 发布事件
fire(eventType, eventObject) {
const callbacks = this._listeners[eventType];
if (callbacks) {
callbacks.forEach(callback => {
callback(eventObject);
});
}
}
}
现在,我们完善 Stage 类中的 _dispatchEvent 方法:
JavaScript
javascript
// 在 Stage 类中
_dispatchEvent(targetShape, eventType, nativeEvent) {
// 我们可以创建一个自定义的事件对象,封装更多信息
const customEvent = {
target: targetShape, // 触发事件的原始图形
nativeEvent: nativeEvent, // 原始浏览器事件
x: nativeEvent.offsetX,
y: nativeEvent.offsetY,
// ... 其他需要的信息
};
// 直接在目标图形上触发事件
targetShape.fire(eventType, customEvent);
}
3. 进阶功能
一个基础的事件系统已经成型,但要实现如 DOM 般强大的交互,我们还需要更多功能。
3.1 事件冒泡(Event Bubbling)
在 DOM 中,点击一个子元素,事件会向上传播到父元素。如果我们的场景图(Scene Graph)是一个树状结构(Group 包含 Shape),我们也应该模拟这个行为。
当 targetShape 被击中时,我们不仅要 targetShape.fire(),还应该沿着它的 parent 链向上,依次触发父级的事件,直到根节点(Stage)或者事件被停止。
JavaScript
javascript
// 在 _dispatchEvent 中
_dispatchEvent(targetShape, eventType, nativeEvent) {
const customEvent = {
target: targetShape,
nativeEvent: nativeEvent,
// ...
_stopped: false, // 冒泡停止标记
stopPropagation: function() {
this._stopped = true;
}
};
let currentTarget = targetShape;
while (currentTarget && !customEvent._stopped) {
// 触发当前目标上的事件
currentTarget.fire(eventType, customEvent);
// 移动到父级
currentTarget = currentTarget.parent;
}
}
3.2 拖拽事件(Drag and Drop)
拖拽不是一个单一事件,而是一个事件序列:mousedown -> mousemove -> mouseup。
我们需要在 Stage 层面管理拖拽状态:
JavaScript
kotlin
// 在 Stage 类中添加
_initListeners() {
// ... 其他事件
this.canvas.addEventListener('mousedown', this._onMouseDown.bind(this));
this.canvas.addEventListener('mousemove', this._onMouseMove.bind(this));
this.canvas.addEventListener('mouseup', this._onMouseUp.bind(this));
this._draggingTarget = null; // 当前正在拖拽的对象
this._dragStartPos = { x: 0, y: 0 }; // 拖拽起始位置
}
_onMouseDown(e) {
const target = this._findHitTarget(e.offsetX, e.offsetY);
if (target) {
// 检查图形是否可拖拽 (e.g., shape.draggable = true)
if (target.draggable) {
this._draggingTarget = target;
this._dragStartPos.x = e.offsetX - target.x;
this._dragStartPos.y = e.offsetY - target.y;
// 分发 'dragstart' 事件
this._dispatchEvent(target, 'dragstart', e);
}
// 分发 'mousedown' 事件
this._dispatchEvent(target, 'mousedown', e);
}
}
_onMouseMove(e) {
if (this._draggingTarget) {
// 如果正在拖拽
const target = this._draggingTarget;
target.x = e.offsetX - this._dragStartPos.x;
target.y = e.offsetY - this._dragStartPos.y;
// 分发 'drag' 事件
this._dispatchEvent(target, 'drag', e);
// 拖拽时需要重绘画布
this.render();
} else {
// 正常的 mousemove 命中检测
// ...
}
}
_onMouseUp(e) {
if (this._draggingTarget) {
// 分发 'dragend' 事件
this._dispatchEvent(this._draggingTarget, 'dragend', e);
this._draggingTarget = null; // 停止拖拽
}
// 正常的 'mouseup' 命中检测
const target = this._findHitTarget(e.offsetX, e.offsetY);
if (target) {
this._dispatchEvent(target, 'mouseup', e);
}
// 触发 click 事件的逻辑也可以在这里处理
}
3.3 mouseenter 和 mouseleave 事件
这两个事件比 mousemove 更复杂,因为它们需要状态。你需要跟踪上一帧鼠标悬停在哪个对象上。
JavaScript
kotlin
// 在 Stage 类中添加
_lastHoveredTarget = null;
_onMouseMove(e) {
// ... 拖拽逻辑优先 ...
const currentHoveredTarget = this._findHitTarget(e.offsetX, e.offsetY);
if (this._lastHoveredTarget !== currentHoveredTarget) {
// 鼠标移出了上一个目标
if (this._lastHoveredTarget) {
this._dispatchEvent(this._lastHoveredTarget, 'mouseleave', e);
}
// 鼠标移入了新目标
if (currentHoveredTarget) {
this._dispatchEvent(currentHoveredTarget, 'mouseenter', e);
}
// 更新状态
this._lastHoveredTarget = currentHoveredTarget;
}
// 始终分发 mousemove
if (currentHoveredTarget) {
this._dispatchEvent(currentHoveredTarget, 'mousemove', e);
}
}