如何设计一个 Canvas 事件系统?

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 个图形怎么办?
});

要实现这一目标,我们的事件系统必须解决三个核心问题:

  1. 场景管理:如何跟踪画布上所有"对象"(图形)的位置和状态?
  2. 命中检测 :当鼠标在 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( x , y ) (x, y) </math>(x,y) 坐标点发生事件时,如何快速判断哪个图形被"击中"了?
  3. 事件分发:当一个图形被击中时,如何触发它上面绑定的回调函数,并模拟事件冒泡等行为?

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) 方法的实现方式各不相同:

  1. 几何算法(AABB)

    • 矩形(Axis-Aligned Bounding Box, AABB) :最简单的。

      JavaScript

      kotlin 复制代码
      isPointInside(x, y) {
        return x >= this.x && x <= this.x + this.width &&
               y >= this.y && y <= this.y + this.height;
      }
    • 圆形:计算点到圆心的距离。

      JavaScript

      kotlin 复制代码
      isPointInside(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)

  2. Canvas API 路径检测(isPointInPath)

    Canvas 2D 上下文提供了一个强大的方法:isPointInPath(x, y) 和 isPointInStroke(x, y)。

    它允许你"重播"一个图形的绘制路径,然后询问浏览器某个点是否在该路径的内部或描边上。

    JavaScript

    kotlin 复制代码
    isPointInside(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 事件上的命中检测开销巨大。一个常见的优化是:

  1. 先进行快速的**包围盒(Bounding Box)**检测。

  2. 如果包围盒命中,再进行精确的(如 isPointInPath)检测。

  3. 对于非常大的场景,使用**空间索引(如 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 mouseentermouseleave 事件

这两个事件比 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);
  }
}
相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax