如何实现一个Canvas渲染引擎(四):事件系统

友情提示

  1. 请先看这里 👉 链接
  2. 代码的GitHub地址 👉 链接
  3. 阅读本文建议将代码回退到4605d6df402df089e364891176c6c2007d260a38这个commit,以去掉一些与本文功能无关的代码。

1. 前言

终于来到了事件系统,事件系统是canvas渲染引擎中至关重要的一个模块,canvas渲染引擎不能失去事件系统,就像西方不能失去耶路撒冷(狗头)。本篇文章将会基于上一篇文章的碰撞检测来实现事件系统。

DOM提供了非常多的事件供我们监听,如mousedown、mouseup、click等等等等,这使得我们可以给DOM节点绑定非常多的交互事件,比如点击某个按钮时,发起一个网络请求,改变某个元素的颜色等等。

原生的canvas并没有提供事件系统,而事件系统又是一个canvas渲染引擎必不可少的一个模块,它实在是太重要了,想象一下,我们在画布上创建了一堆节点,然后我们点击了画布,这个时候,我们怎么知道点击了哪个节点呢?我们想在点击某个节点后改变它的颜色,那我们该怎么办呢?如果没有事件系统,那么这个渲染引擎肯定会非常难用。

2. 要实现哪些东西

首先需要明确的是:本文要实现的事件系统,将会仿照DOM的事件系统来做,以最大程度地降低理解成本。

2.1 事件监听和移除

DisplayObject这个类代表了节点,我们将在DisplayObject这个类上挂载一个addEventListener函数(和DOM的API命名一样),这个函数的第1个参数是事件的类型,如click,mousemove等,第2个参数是event handler,在事件触发时,会执行对应的event handler,第3个参数是可选的,如果为true说明我们将监听捕获阶段的事件,这个参数默认是false,也就是监听冒泡阶段的事件。

我们还会在DisplayObject这个类上挂载一个removeEventListener函数,用来移除相应的event handler。

2.2 事件传播机制

DOM有着一套事件传播机制,在触发了事件后,首先会进入捕获阶段,事件会从上至下沿着节点树依次在对应的节点上触发,然后到at target阶段,也就是在event target本身执行事件,最后进入冒泡阶段,事件会从下至上沿着节点树依次在对应的节点上触发,至此,这一次事件传播就结束了。

要注意的是,并不是所有事件都会传播的,比如mouseleave、mouseenter就不会传播。

在这个canvas渲染引擎中,我们会实现事件传播机制。

2.3 cursor

CSS中有一个属性,即cursor,这个属性决定了,当我们把鼠标移到这个节点上,鼠标会变成什么样子,其中最常用的就是cursor: pointer;了。

在这个canvas渲染引擎中,我们会实现cursor属性。

3. 事件监听和移除实现

事件监听将依赖eventemitter3这个库,DisplayObject继承自这个类。

3.1 addEventListener

这里主要是利用了eventemitter3这个类的API

typescript 复制代码
public addEventListener<K extends keyof FederatedEventMap>(
  type: K,
  listener: (e: FederatedEventMap[K]) => any,
  options?: boolean | AddEventListenerOptions
) {
  const capture =
    (typeof options === 'boolean' && options) ||
    (typeof options === 'object' && options.capture)

  const realType = capture ? `${type}capture` : type

  if (typeof options === 'object' && options.once) {
    this.once(realType, listener)
  } else {
    this.on(realType, listener)
  }
}

3.2 removeEventListener

同样,主要是使用eventemitter3的API

typescript 复制代码
public removeEventListener<K extends keyof FederatedEventMap>(
  type: K,
  listener: (e: FederatedEventMap[K]) => any,
  capture?: boolean
) {
  const realType = capture ? `${type}capture` : type
  this.off(realType, listener)
}

4. 各个事件的实现

4.1 坐标转换

在canvas元素上触发事件时,我们可以得到一个相对于浏览器视窗的全局坐标,我们要将这个坐标转化成相对于canvas视窗的坐标,以供后续的事件类使用。

在这个渲染引擎内部,我们用FederatedMouseEvent来表示事件类

typescript 复制代码
export class FederatedMouseEvent {
  public isTrusted = true
  public timeStamp = 0
  public type: keyof FederatedEventMap = 'mousemove'
  public button = 0
  public buttons = 0
  public global = new Point()
  public propagationStopped = false
  public eventPhase = EventPhase.NONE
  public target = new Container()
  public currentTarget = new Container()

  public stopPropagation() {
    this.propagationStopped = true
  }
}

这个类上面挂载了一些和DOM的事件类比较类似的属性,其中的global属性就是这个事件相对于canvas视窗的全局坐标。后续所有的event handler(addEventListener的第二个参数)的参数都是FederatedMouseEvent

offsetXoffsetY来将DOM事件的坐标转化成canvas视窗的全局坐标:

typescript 复制代码
private bootstrapEvent = (nativeEvent: PointerEvent) => {
  // ...
  this.rootEvent.global.x = nativeEvent.offsetX
  this.rootEvent.global.y = nativeEvent.offsetY
}

这里并非一定要使用offsetXoffsetY,也可以根据实际情况自行采用其他属性。

4.2 mouseenter、mouseleave、mousemove、mouseout、mouseover

只需要通过监听canvas元素的mousemove事件,我们就可以实现mouseenter、mouseleave、mousemove、mouseout、mouseover这5个事件。

4.2.1 在canvas元素上监听pointermove事件

typescript 复制代码
private addEvents = () => {
  this.canvasEle.addEventListener('pointermove', this.onPointerMove)
}

canvas元素上触发了pointermove事件后,将原生事件转化成这个渲染引擎的内部事件(FederatedMouseEvent),并执行对应的event handler

typescript 复制代码
private onPointerMove = (nativeEvent: PointerEvent) => {
  this.bootstrapEvent(nativeEvent)
  this.eventBoundary.fireEvent(this.rootEvent)
}

执行event handler,首先获取碰撞元素,这里使用的是上一篇文章讲述的碰撞检测代码:

typescript 复制代码
private fireMouseMove = (event: FederatedMouseEvent) => {
  const hitTarget = this.hitTest(event.global)
  // ...
}

获取到了hitTarget后,就要开始处理各个事件了

4.2.2 mouseout

当鼠标移出了元素,则触发mouseout事件,mouseout事件会传播

typescript 复制代码
private onPointerMove = (nativeEvent: PointerEvent) => {
  // ...
  
  // 用this.overTargets来表示每次mousemove事件触发时的事件传播路径,通过对比前后两次的传播路径,就可以得出这一次的mousemove到底触发了哪些事件
  // topTarget是this.overTargets的最后一个元素,也就是最顶层的元素
  const topTarget =
    this.overTargets.length > 0
      ? this.overTargets[this.overTargets.length - 1]
      : null
  
  if (topTarget && topTarget !== hitTarget) {
    event.target = topTarget
    event.type = 'mouseout'
    this.dispatchEvent(event)
  }
  
  // ...
}

4.2.3 mouseleave

当鼠标移出元素以及元素的所有子元素时,触发mouseleave事件,mouseleave事件不传播。

typescript 复制代码
private fireMouseMove = (event: FederatedMouseEvent) => {
  // ...

  const topTarget =
    this.overTargets.length > 0
      ? this.overTargets[this.overTargets.length - 1]
      : null

  if (topTarget && topTarget !== hitTarget) {
    if (!hitTarget || !this.composePath(hitTarget).includes(topTarget)) {
      event.type = 'mouseleave'
      event.eventPhase = EventPhase.AT_TARGET

      if (!hitTarget) {
        for (let i = this.overTargets.length - 1; i >= 0; i--) {
          event.target = this.overTargets[i]
          event.currentTarget = event.target

          // 执行对应的event handler,捕获和冒泡分别执行一次
          event.target.emit(`${event.type}capture`, event)
          event.target.emit(event.type, event)
        }
      } else {
        let tempTarget: Container | null = topTarget
        while (
          tempTarget &&
          !this.composePath(hitTarget).includes(tempTarget)
        ) {
          event.target = tempTarget
          event.currentTarget = event.target

          // 执行对应的event handler,捕获和冒泡分别执行一次
          event.target.emit(`${event.type}capture`, event)
          event.target.emit(event.type, event)

          tempTarget = tempTarget.parent
        }
      }
    }
  }
}

4.2.4 mouseover

当鼠标移入了元素时,触发mouseover事件,mouseover事件会传播

typescript 复制代码
private fireMouseMove = (event: FederatedMouseEvent) => {
  // ...

  if (hitTarget && topTarget !== hitTarget) {
    event.target = hitTarget
    event.type = 'mouseover'
    this.dispatchEvent(event)
  }

  // ...
}

4.2.5 mouseenter

当鼠标移入元素或元素的子元素时,触发mouseenter事件,mouseenter事件不传播

typescript 复制代码
private fireMouseMove = (event: FederatedMouseEvent) => {
  // ...

  const topTarget =
    this.overTargets.length > 0
      ? this.overTargets[this.overTargets.length - 1]
      : null
  
  if (hitTarget && topTarget !== hitTarget) {
    const composedPath = this.composePath(hitTarget)
    event.type = 'mouseenter'
    event.eventPhase = EventPhase.AT_TARGET
    if (!topTarget) {
      for (let i = 0; i < composedPath.length; i++) {
        event.target = composedPath[i]
        event.currentTarget = event.target

        // 执行对应的event handler,捕获和冒泡分别执行一次
        event.target.emit(`${event.type}capture`, event)
        event.target.emit(event.type, event)
      }
    } else {
      // 首先找出分叉点,也就是hitTarget和topTarget的冒泡路径上的共同点
      let forkedPointIdx = composedPath.length - 1
      for (; forkedPointIdx >= 0; forkedPointIdx--) {
        if (
          this.composePath(topTarget).includes(composedPath[forkedPointIdx])
        ) {
          break
        }
      }

      // 按照自顶向下的顺序依次在对应的event target上触发mouseenter事件
      for (let i = forkedPointIdx + 1; i < composedPath.length; i++) {
        event.target = composedPath[i]
        event.currentTarget = event.target

        // 执行对应的event handler,捕获和冒泡分别执行一次
        event.target.emit(`${event.type}capture`, event)
        event.target.emit(event.type, event)
      }
    }
  }
} 

4.2.6 mousemove

鼠标在元素内容区域移动时,触发mousemove事件,mousemove事件会传播

typescript 复制代码
private fireMouseMove = (event: FederatedMouseEvent) => {
  const hitTarget = this.hitTest(event.global)

  // ...

  // 处理mousemove事件
  if (hitTarget) {
    event.target = hitTarget
    event.type = 'mousemove'

    this.dispatchEvent(event)
  }

  // 重新生成this.overTargets,供下一次的mousemove事件使用
  this.overTargets = hitTarget ? this.composePath(hitTarget) : []
}

4.2.7 清空overTargets

在鼠标离开canvas元素时,清空overTargets,否则会影响后续的mousemove事件

typescript 复制代码
private addEvents = () => {
  // ...
  this.canvasEle.addEventListener('pointerleave', this.onPointerLeave)
}

private onPointerLeave = () => {
  this.eventBoundary.overTargets = []
}

4.3 mousedown、mouseup、click

通过监听mousedownmouseup事件,来实现上面这3个事件

4.3.1 mousedown

在canvas元素上监听pointerdown事件

typescript 复制代码
private addEvents = () => {
  // ...
  this.canvasEle.addEventListener('pointerdown', this.onPointerDown)
}

处理mousedown事件

typescript 复制代码
private fireMouseDown = (event: FederatedMouseEvent) => {
  const hitTarget = this.hitTest(event.global)
  if (!hitTarget) {
    return
  }
  event.target = hitTarget
  this.dispatchEvent(event)

  // 记录mousedown时的传播路径
  this.pressTargetsMap[event.button] = this.composePath(hitTarget)
}

在这个函数的末尾,记录下了mousedown时的hitTarget的传播路径,这是为了供后面的click事件判断使用的,click事件基于mousedownmouseup事件。

4.3.2 mouseup

在canvas元素上监听pointerup事件

typescript 复制代码
private addEvents = () => {
  // ...
  this.canvasEle.addEventListener('pointerup', this.onPointerup)
}

处理mouseup事件

typescript 复制代码
private fireMouseUp = (event: FederatedMouseEvent) => {
  const hitTarget = this.hitTest(event.global)
  if (!hitTarget) {
    return
  }
  event.target = hitTarget
  this.dispatchEvent(event)
  // ...
}

4.3.3 click

在处理mouseup后,就要处理click事件了,鼠标按下去后再松开,就相当于一次click事件,这里要注意的是,就算鼠标按下去时所碰撞到的元素和鼠标松开时碰撞到的元素不是同一个元素,也会触发click事件,MDN对与click事件的解释是这样的:

所以,对于click事件的处理的核心,就是找到mousedownmouseup时的两个碰撞元素的公共祖先元素。代码如下:

typescript 复制代码
private fireMouseUp = (event: FederatedMouseEvent) => {
  // ...

  const pressTarget = propagationPath[propagationPath.length - 1]

  // 处理click事件
  let clickTarget = pressTarget
  const composedPath = this.composePath(hitTarget)

  // 找出最近公共祖先
  while (clickTarget) {
    if (!composedPath.includes(clickTarget)) {
      // @ts-ignore
      clickTarget = clickTarget.parent
    } else {
      break
    }
  }

  event.type = 'click'
  event.target = clickTarget
  this.dispatchEvent(event)

  delete this.pressTargetsMap[event.button]
}

在处理完click事件后,需要清空mousedown事件时缓存下来的事件传播路径。

4.4 小结

到这里,这个渲染引擎已经实现了9个基础事件,分别是:mouseentermouseleavemousemovemouseoutmouseovermousedownmouseupclick。DOM中支持的事件远远不止9个,如果大家有兴趣,可以基于本文的代码实现更多事件。

5. 鼠标指针样式

就像CSS的cursor: pointer;一样,我们希望在这个渲染引擎里也实现类似的效果

可以在mousemove事件触发后,来指定鼠标指针样式:

typescript 复制代码
private fireMouseMove = (event: FederatedMouseEvent) => {
  const hitTarget = this.hitTest(event.global)
  
  // ...

  if (hitTarget) {
    this.cursor = hitTarget.cursor
  } else {
    this.cursor = 'auto'
  }
}
typescript 复制代码
private onPointerMove = (nativeEvent: PointerEvent) => {
  // ...
  this.setCursor()
}
private setCursor = () => {
  this.canvasEle.style.cursor = this.eventBoundary.cursor
}

6. hitArea

现在的碰撞检测是根据图形的边界来决定碰撞区域的,比如,一个圆的碰撞区域就是这个圆内的区域,但是有的时候我们想自定义碰撞区域,比如我们想让一个圆的碰撞区域变成一个矩形或者其他多边形,这个时候就引出了一个新的属性:hitArea

在DisplayObject上添加一个新的属性:hitArea

typescript 复制代码
public hitArea: Shape | null = null

在进行碰撞检测时,优先检测hitArea

typescript 复制代码
// Container.ts
public containsPoint(p: Point) {
  if (!this.hitArea) {
    return false
  }

  return this.hitArea.contains(p)
}
typescript 复制代码
// Graphics.ts
public containsPoint(p: Point): boolean {
  // 如果设置了hitArea则只判断hitArea
  if (this.hitArea) {
    return this.hitArea.contains(p)
  }

  return this._geometry.containsPoint(p)
}

7. 实现一个拖拽多边形的案例

目前已经实现了9个基础事件,利用这9个基础事件,我们就可以实现很多功能了,在这个章节,我们将利用这几个基础事件实现一个拖拽多边形的案例。

创建app,并生成一个多边形,放到stage上:

typescript 复制代码
const view = document.getElementById('canvas') as HTMLCanvasElement
const app = new Application({
  rendererType: RendererType.Canvas,
  view,
  backgroundColor: '#aaaaaa'
  // backgroundAlpha: 0.1
})
appRef.current = app
const g = new Graphics()
  .beginFill('gold')
  .moveTo(200, 200)
  .lineTo(400, 100)
  .lineTo(600, 200)
  .lineTo(700, 100)
  .lineTo(600, 500)
g.cursor = 'pointer'
g.scale.set(0.3, 0.8)
g.position.set(100, 50)
app.stage.addChild(g)

监听mousedown事件,在mousedown时记录初始位置:

typescript 复制代码
g.addEventListener('mousedown', (e) => {
  dragging = true
  mouseDownPoint = e.global.clone()
  startPoint = new Point(g.x, g.y)
})

监听mousemove事件,在mousemove时改变多边形的位置

typescript 复制代码
app.stage.addEventListener('mousemove', (e) => {
  if (!dragging) {
    return
  }

  const newP = e.global.clone()
  const diffX = newP.x - mouseDownPoint.x
  const diffY = newP.y - mouseDownPoint.y
  g.position.set(startPoint.x + diffX, startPoint.y + diffY)
})

监听mouseup事件,在mouseup时将dragging设置为false

typescript 复制代码
app.stage.addEventListener('mouseup', (e) => {
  dragging = false
})

效果:

codesandbox地址

8. 结语

本文基于上一篇文章讲述的碰撞检测,完成了事件系统,让这个渲染引擎的功能变得更加丰富实用了一些。这些事件都是参照DOM的事件系统做的,DOM的事件有时不一定能满足用户的需求,比如click事件并不是我们想象的那种快速的mousedown然后mouseup才会触发,而是无论mousedownmouseup间隔了多久都会触发,大家可以根据自己的业务诉求来定制一些自定义事件,但是这些自定义事件也是绕不开DOM原生的事件的,因为我们需要监听canvas元素上的事件以将其转化成这个渲染引擎内部的事件。

谢谢观看🙏,如果觉得本文还不错,就点个赞吧👍。

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