友情提示
- 请先看这里 👉 链接
- 代码的GitHub地址 👉 链接
- 阅读本文建议将代码回退到
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
类
用offsetX
和offsetY
来将DOM事件的坐标转化成canvas视窗的全局坐标:
typescript
private bootstrapEvent = (nativeEvent: PointerEvent) => {
// ...
this.rootEvent.global.x = nativeEvent.offsetX
this.rootEvent.global.y = nativeEvent.offsetY
}
这里并非一定要使用offsetX
、offsetY
,也可以根据实际情况自行采用其他属性。
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
通过监听
mousedown
和mouseup
事件,来实现上面这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
事件基于mousedown
和mouseup
事件。
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
事件的处理的核心,就是找到mousedown
和mouseup
时的两个碰撞元素的公共祖先元素。代码如下:
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个基础事件,分别是:mouseenter
、mouseleave
、mousemove
、mouseout
、mouseover
、mousedown
、mouseup
、click
。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
})
效果:
8. 结语
本文基于上一篇文章讲述的碰撞检测
,完成了事件系统
,让这个渲染引擎的功能变得更加丰富实用了一些。这些事件都是参照DOM的事件系统做的,DOM的事件有时不一定能满足用户的需求,比如click
事件并不是我们想象的那种快速的mousedown
然后mouseup
才会触发,而是无论mousedown
和mouseup
间隔了多久都会触发,大家可以根据自己的业务诉求来定制一些自定义事件,但是这些自定义事件也是绕不开DOM原生的事件的,因为我们需要监听canvas元素上的事件以将其转化成这个渲染引擎内部的事件。
谢谢观看🙏,如果觉得本文还不错,就点个赞吧👍。