友情提示
- 请先看这里👉前言
- 相关代码👉地址,本文相关的代码在
events-system分支上,请切到这个分支查看代码 - 本系列文章提到的fabric均为v7版本,pixijs均为v8版本
- 本系列文章对应的内容已经发布到了npm,通过
npm i pixijs-fabric来安装,这个包的使用方式和fabric一样
1. 前言
事件系统是一个渲染引擎最重要的功能之一,如果没有事件系统,这个渲染引擎将不可用。
上一章里,我们已经实现了静态画布,用来绘制需要展示的元素,接下来我们将给这些元素绑定上事件,让这些元素可交互。
2. fabric的事件系统
2.1 实现方式
和pixi一样,fabric也是基于射线法来完成碰撞检测的,如果不知道什么是射线法,可以看这篇文章:如何实现一个Canvas渲染引擎(三):碰撞检测(射线法和像素标记法)的2.2节。
fabric的findTarget函数被用来检测碰撞对象,它会递归遍历对象树,寻找可能的碰撞对象,最后用射线法来判断是否与对象产生了碰撞,射线法的核心函数为:isPointInPolygon函数。
2.2 特点
pixi的事件系统,完全参照了DOM的实现,有冒泡阶段、捕获阶段,区分了mouseenter、mouseover事件等,而fabric并没有完全参照DOM来实现事件系统,它有着自己的一套逻辑。还有一个特点就是,fabric在进行碰撞检测的时候,不仅会判断是否命中了对象的碰撞区域,还会判断鼠标命中的点的像素是否是透明像素,如果是透明像素,则判断为未命中。
2.3 如何替代?
pixijs提供的事件系统已经非常完备了,包含了:视窗坐标到canvas坐标的转换、抵消分辨率的影响、射线法进行碰撞检测等等,所以,我们可以直接用pixijs的事件系统来实现fabric的事件系统。
3. 准备工作
3.1 pixi对象和fabric对象双向绑定
因为我们要用pixi的事件系统来实现fabric的事件系统,所以,我们取到的event.target是pixi的对象,但是我们在做的事情是:实现fabric,我们最终还是要取到fabric的对象,所以,我们可以让这两者进行一个双向绑定,以便在不同的对象之间切换。
ts
declare module 'pixi.js' {
interface Container {
fabricContent: FabricObject;
}
}
class InteractiveFabricObject extends InnerObject{
// ...
public pixiContent = new Container({ children: [new Graphics()] });
// ...
constructor(options?: Props) {
// ...
this.pixiContent.fabricContent = this;
}
}
3.2 对象的碰撞区域(hitArea)
既然要触发事件,那么就要告诉计算机,命中了对象的哪个区域,才算命中了这个对象,这个区域就是对象的碰撞区域(hitArea)。
fabric的hitArea,就是以一个宽为this.width,高为this.height的矩形,这个矩形的中心点,在fabric对象的(0,0)点。
知道了hitArea的位置以及大小后,我们可以用pixi的hitArea属性来替换fabric的碰撞区域:
ts
export class ObjectGeometry {
// ...
protected hitArea = new Rectangle();
// ...
public setCoords(): void {
// ...
this.hitArea.set(
-this.width / 2,
-this.height / 2,
this.width,
this.height,
);
this.pixiContent.hitArea = this.hitArea;
}
}
在对象的setCoords函数里,计算碰撞区域。
4. 各个事件的具体实现
4.1 MouseDown
在pixi的stage上监听mousedown事件:
ts
const stage = this.pixiApp.stage;
stage.on('pointerdown', this._onMouseDown)
触发mousedown之后,需要记录一些状态,供后续的其他事件处理,主要是click事件和drag事件。这里的处理是,记录元素的初始状态,以及触发mosuedown时的鼠标位置,代码:
ts
private handlePotentialClick(e: FederatedPointerEvent) {
const fabricTarget = e.target.fabricContent;
if (!fabricTarget) return;
this.clickInfo.mouseDownTime = performance.now();
this.clickInfo.mouseDownTarget = fabricTarget;
this.clickInfo.mouseDownGlobalX = e.globalX;
this.clickInfo.mouseDownGlobalY = e.globalY;
}
private handleDragStart(e: FederatedPointerEvent) {
const fabricTarget = e.target?.fabricContent;
if (!fabricTarget) return;
this._dragSource = fabricTarget;
this.hasFiredDragStart = false;
this.dragInitInfo.targetX = this._dragSource.left;
this.dragInitInfo.targetY = this._dragSource.top;
const matrix =
this._dragSource.parent?.pixiContent.worldTransform ||
this.root.worldTransform;
matrix.applyInverse(e.global, tempPoint);
this.dragInitInfo.mouseX = tempPoint.x;
this.dragInitInfo.mouseY = tempPoint.y;
}
private _onMouseDown = (e: FederatedPointerEvent) => {
this.mouseDown = true;
this.handlePotentialClick(e);
this.handleDragStart(e);
// ...
}
之后,需要emit对应的事件(mousedown:before和mousedown),事件名称与fabric保持一致:
ts
private _onMouseDown = (e: FederatedPointerEvent) => {
// ...
this._handleEvent(e, 'down:before');
// ...
if (button) {
((this.fireMiddleClick && button === 1) ||
(this.fireRightClick && button === 2)) &&
this._handleEvent(e, 'down', {
alreadySelected,
});
return;
}
// ...
}
4.2 MouseMove
由于需要处理把元素拖拽到canvas之外的场景,所以不能直接监听mousemove了,而是监听globalmousemove
在stage上监听globalmousemove事件:
ts
const stage = this.pixiApp.stage;
stage.on('globalpointermove', this._onMouseMove);
触发mousemove的时候,emit对应的事件:mousemove:before、mousemove:
ts
__onMouseMove(e: FederatedPointerEvent) {
//...
this._handleEvent(e, 'move:before');
//...
this._handleEvent(e, 'move');
//...
}
判断命中的对象绑定的cursor,如cursor:pointer:
ts
__onMouseMove(e: FederatedPointerEvent) {
//...
const fabricTarget = e.target?.fabricContent;
this._setCursorFromEvent(e, fabricTarget);
//...
}
如果两次mousemove触发的对象不一样,则分别触发mouseover和mouseout事件:
ts
__onMouseMove(e: FederatedPointerEvent) {
//...
const fabricTarget = e.target?.fabricContent;
this._fireOverOutEvents(e, fabricTarget);
//...
}
4.3 MouseUp
这个事件的处理比较关键,在这里我们会处理click、dbclick、tripleclick
fabric.js虽然提供了双击、三击事件,但是并没有提供单击事件,在这里,作者打算给这个fabric加上一个click事件,弥补这个空缺。
在stage上监听mouseup事件:
ts
stage.on('pointerup', this._onMouseUp);
首先,按照fabric的逻辑,依然是emit mouseup:before事件和mouseup事件:
ts
private _onMouseUp = (e: FederatedPointerEvent) => {
// ...
this._handleEvent(e, 'up:before');
// ...
this._handleEvent(e, 'up');
// ...
}
然后,处理click事件,在这个fabric里(pixijs-fabric),click事件的判定将会更加严格,首先,mousedown和mouseup必须在同一个元素上触发;其次,mousedown和mouseup的鼠标距离,不能超过6个像素,以及,时间间隔不能超过150ms,才算一次click,处理click事件的逻辑如下:
ts
private handleClick(e: FederatedPointerEvent) {
const fabricTarget = e.target?.fabricContent;
if (!fabricTarget) return;
const clickInfo = this.clickInfo;
clickInfo.mouseUpTime = performance.now();
const diffX = e.globalX - clickInfo.mouseDownGlobalX;
const diffY = e.globalY - clickInfo.mouseDownGlobalY;
const diff = Math.sqrt(diffX * diffX + diffY * diffY);
if (
clickInfo.mouseUpTime - clickInfo.mouseDownTime < 150 &&
fabricTarget === clickInfo.mouseDownTarget &&
diff < 6
) {
this._handleEvent(e, 'click');
// 处理双击
this.handleDbClick(e);
clickInfo.clickTime = performance.now();
clickInfo.clickTarget = fabricTarget;
clickInfo.clickGlobalX = e.globalX;
clickInfo.clickGlobalY = e.globalY;
}
}
private _onMouseUp = (e: FederatedPointerEvent) => {
// ...
this.handleClick(e);
// ...
}
处理完了click事件之后,接着就要处理dbclick事件了,和click事件一样,dbclick事件的判定也会更加严格:
ts
private handleDbClick(e: FederatedPointerEvent) {
const fabricTarget = e.target.fabricContent;
const clickInfo = this.clickInfo;
const { clickTime, clickTarget, clickGlobalX, clickGlobalY } = clickInfo;
const diffX = e.globalX - clickGlobalX;
const diffY = e.globalY - clickGlobalY;
const diff = Math.sqrt(diffX * diffX + diffY * diffY);
const now = performance.now();
if (now - clickTime < 250 && fabricTarget === clickTarget && diff < 6) {
if (now - clickInfo.dbClickTime >= 300) {
this._handleEvent(e, 'dblclick');
}
this.handleTripleClick(e);
clickInfo.dbClickTarget = fabricTarget;
clickInfo.dbClickGlobalX = e.globalX;
clickInfo.dbClickGlobalY = e.globalY;
clickInfo.dbClickTime = now;
}
}
处理完了dbclick,接下来处理tripleclick事件:
ts
private handleTripleClick(e: FederatedPointerEvent) {
const fabricTarget = e.target.fabricContent;
const clickInfo = this.clickInfo;
const { dbClickGlobalX, dbClickGlobalY, dbClickTarget, dbClickTime } =
clickInfo;
const diffX = e.globalX - dbClickGlobalX;
const diffY = e.globalY - dbClickGlobalY;
const diff = Math.sqrt(diffX * diffX + diffY * diffY);
const now = performance.now();
if (now - dbClickTime < 250 && fabricTarget === dbClickTarget && diff < 6) {
if (now - clickInfo.triClickTime >= 300) {
this._handleEvent(e, 'tripleclick');
}
clickInfo.triClickTime = now;
}
}
这里有个点需要注意,dbclick和tripleclick是需要防抖的,不能让它们无限次数地连续触发。
4.4 MouseOut
在stage上监听mouseout事件:
ts
stage.on('pointerout', this._onMouseOut);
触发mouseout后,emit对应的事件(mouseout):
ts
private _onMouseOut = (e: FederatedPointerEvent) => {
// ...
const target = this._hoveredTarget;
// ...
this.fire('mouse:out', { ...shared, target });
target && target.fire('mouseout', { ...shared, target });
}
4.5 Wheel
在stage上监听wheel事件,然后把对应的参数信息包装成fabric事件对象的形式,就OK了:
ts
private bindEvents(){
// ...
stage.on('wheel', this._onMouseWheel);
}
private _onMouseWheel = (e: FederatedWheelEvent) => {
const fabricTarget = e.target?.fabricContent;
const viewportPoint = new Point(e.globalX, e.globalY);
const scenePoint = this.root.worldTransform.applyInverse(
viewportPoint,
new Point(),
);
const options: CanvasEvents[`mouse:wheel`] = {
e: e.nativeEvent as TPointerEvent,
target: fabricTarget,
subTargets: [],
scenePoint,
viewportPoint,
transform: this._currentTransform,
} as CanvasEvents[`mouse:wheel`];
this.fire(`mouse:wheel`, options);
let t = fabricTarget;
while (t) {
t.fire(`mousewheel`, options);
t = t.parent!;
}
};
4.6 dragstart
fabric提供了一系列drag相关的事件,用来将外部元素拖动到画布区域,与画布产生联动,但是在这里,作者并不想按照fabric的drag逻辑来做,而是把drag做的更简单实用一些。这个版本的dragstart将不再是用来和画布外的元素联动了,而是专注于处理画布内的联动。
dragstart新定义:当鼠标在元素上触发mousedown事件,并开始拖动(触发mousemove)时,就会触发dragstart事件。
在mousedown的时候,记录一些被拖拽元素的基本信息:
ts
// ...
private dragInitInfo = {
targetX: 0,
targetY: 0,
mouseX: 0,
mouseY: 0,
};
// ...
private _onMouseDown = (e: FederatedPointerEvent) => {
// ...
this.handleDragStart(e);
// ...
}
private handleDragStart(e: FederatedPointerEvent) {
const fabricTarget = e.target?.fabricContent;
if (!fabricTarget) return;
this._dragSource = fabricTarget;
this.hasFiredDragStart = false;
this.dragInitInfo.targetX = this._dragSource.left;
this.dragInitInfo.targetY = this._dragSource.top;
const matrix =
this._dragSource.parent?.pixiContent.worldTransform ||
this.root.worldTransform;
matrix.applyInverse(e.global, tempPoint);
this.dragInitInfo.mouseX = tempPoint.x;
this.dragInitInfo.mouseY = tempPoint.y;
}
// ...
在mousemove的时候,判断当前是否有drag对象,如果有,就emit dragstart事件,并且,还要修改drag对象的位置信息,达到拖拽的效果:
ts
private handleDragMove(e: FederatedPointerEvent) {
if (!this._dragSource) return;
// fire drag start
if (!this.hasFiredDragStart) {
const viewportPoint = new Point(e.globalX, e.globalY);
const scenePoint = this.root.worldTransform.applyInverse(
viewportPoint,
new Point(),
);
const options: CanvasEvents['dragstart'] = {
e: e.nativeEvent as TPointerEvent,
target: this._dragSource,
subTargets: [],
scenePoint,
viewportPoint,
transform: this._currentTransform,
};
this.fire('dragstart', options);
this._dragSource.fire('dragstart', options);
this.hasFiredDragStart = true;
}
const matrix =
this._dragSource.parent?.pixiContent.worldTransform ||
this.root.worldTransform;
matrix.applyInverse(e.global, tempPoint);
const diffX = tempPoint.x - this.dragInitInfo.mouseX;
const diffY = tempPoint.y - this.dragInitInfo.mouseY;
this._dragSource.set({
left: this.dragInitInfo.targetX + diffX,
top: this.dragInitInfo.targetY + diffY,
});
this.requestRenderAll();
}
4.7 dragend
和dragstart一样,这个事件将不会参照fabric的逻辑来做,而是会做成专注于画布内元素的交互的形式。
鼠标抬起后,如果判断有拖拽对象,则触发dragend事件,并且,在emit对应的事件后,需要删除drag对象,避免状态污染:
ts
private handleDragEnd = (e: FederatedPointerEvent) => {
if (!this._dragSource) return;
if (this.hasFiredDragStart) {
const viewportPoint = new Point(e.globalX, e.globalY);
const scenePoint = this.root.worldTransform.applyInverse(
viewportPoint,
new Point(),
);
const options: CanvasEvents['dragend'] = {
e: e.nativeEvent as TPointerEvent,
target: this._dragSource,
subTargets: [],
scenePoint,
viewportPoint,
transform: this._currentTransform,
};
this.fire('dragend', options);
this._dragSource.fire('dragend', options);
}
delete this._dragSource;
};
5. 其他事件
由于时间原因,这里并没有实现fabric的所有事件,而是只实现了常用的那些事件,还有诸如contextmenu、dragover等等的事件没有实现,如果大家有兴趣,可以自己去实现一下。
6. 测试一下
测试方式:沿用上一章里的fabric官方demo和性能测试demo,来看一下加入了事件系统后的鼠标hover效果,以及拖拽元素的效果。
6.1 fabric官方demo
测试代码地址👉codesandbox地址
为了方便大家查看,我在canvas元素上绑定了一系列的事件,让用户能够缩放、平移画布;我还在底部加入了一个radio,让用户可以在pixi、fabric两者之间切换,以对比效果。
pixijs-fabric效果:

fabric效果:

可以看到,两者的绘制效果是一样的。
6.2 性能测试
在画布上绘制15000个矩形,对比fabric的性能和pixijs-fabric的性能,这个测试主要是强调pixijs强大的渲染性能。
测试代码:
ts
const canvas = new Canvas(undefined, {
width: 1200,
height: 700,
backgroundColor: '#85C8F2',
});
enableCanvasZoom(canvas);
const wrapper = document.getElementById('wrapper');
wrapper?.appendChild(canvas.lowerCanvasEl);
for (let i = 0; i < 15000; i++) {
const rect = new Rect({
left: Math.random() * 1200,
top: Math.random() * 700,
width: 30 + Math.random() * 20,
height: 15 + Math.random() * 20,
fill: `rgb(${Math.floor(Math.random() * 256)}, ${Math.floor(
Math.random() * 256,
)}, ${Math.floor(Math.random() * 256)})`,
});
canvas.add(rect);
}
和上面一样,我在canvas元素上绑定了一系列的事件,让用户能够缩放、平移画布;并且在底部加入了一个radio,让用户可以在pixi、fabric两者之间切换,以对比效果。
测试代码地址👉codesandbox地址
通过chrome的提供的性能分析工具来对比两者的渲染性能。
pixijs-fabric效果:

可以看到,加入了事件系统后,画布性能有所下降,每个task来到了22ms左右,FPS来到了40多帧。
fabric效果:

fabric和之前一样,每个依然保持在600ms,FPS不到2,处于ppt的状态。
6.3 拖拽元素测试
拖拽元素是fabric的可交互画布(Canvas类)自带的功能,并不需要任何配置就可以开启,可以通过上面两节里的任意一个测试demo来体验拖拽元素的效果: