如何设计一个 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);
  }
}
相关推荐
Baklib梅梅3 小时前
无头内容管理系统:打造灵活高效的多渠道内容架构
前端·ruby on rails·前端框架·ruby
over6974 小时前
浏览器里的AI魔法:用JavaScript玩转自然语言处理
前端·javascript
渣渣盟4 小时前
探索Word2Vec:从文本向量化到中文语料处理
前端·javascript·python·文本向量化
Pu_Nine_94 小时前
Vue 3 + TypeScript 项目性能优化全链路实战:从 2.1MB 到 130KB 的蜕变
前端·vue.js·性能优化·typescript·1024程序员节
云枫晖5 小时前
Webpack系列-Loader
前端·webpack
aggression5 小时前
代码敲击乐:让你了解前端的动静结合和移动端的适配性
前端
yinuo5 小时前
深入理解与实战 Git Submodule
前端
骑自行车的码农5 小时前
React 事件收集函数
前端·react.js
一个处女座的程序猿O(∩_∩)O5 小时前
Vue CLI 插件开发完全指南:从原理到实战
前端·javascript·vue.js