meta2d.js:由乐吾乐科技自主研发的一款 2D 可视化引擎。使用该组件,可以定制开发自己的在线画图编辑器软件,应用于物联网组态、办公、设计等场景(平时画图觉着墨刀、ppt、xmind 等不好用的可以考虑自己基于此组件再造个轮子😆)。
事件,是 meta2d.js 提供的一项很实用的功能。通过对图元事件的管理,我们可以设定非常灵活的业务逻辑。在实际的客户业务中,会存在对某些事件在执行前,进行前置条件的判定。根据前置条件是否满足,来决定是否执行图元的事件。这样的需求场景,类似于 WEB 应用中的请求拦截器。meta2d.js 目前的事件处理机制,虽然支持自定义事件,却不能支持这样的场景。这里将介绍一种事件拦截机制的实现。
要想实现自定义的事件拦截,必须先搞清楚 meta2d.js 的消息通知和事件机制。
一、meta2d.js 的消息通知机制
在了解 meta2d 事件机制之前,必须先明白 meta2d.js 的消息通知机制。meta2d 通过实例的 on 和 off 方法,来订阅和取消订阅通知消息。
rust
// fn 事件处理函数
const fn = (event, data) => {};
// 监听一个名为 event 的事件,一旦收到该事件的消息通知,就调用 fn 函数
meta2d.on('event', fn);
// 监听全部消息
meta2d.on('*', fn);
// 取消监听
meta2d.off('event', fn);
meta2d.off('*', fn);
其底层依赖于 Mitt 组件 实现。通过注册事件名和监听函数来实现注册机制。Mitt 组件要点如下:
1、Mitt 在应用程序中,实例化一个总线对象,事件采用发布和订阅模式。所有事件都发布到这个总线对象上。
2、在 Mitt 中,事件处理程序是按照订阅的顺序执行的,并且在执行一个事件处理程序时,无法直接阻止其他事件的触发。 Mitt 并没有提供内置的机制来阻止其他事件的触发。
3、在 Mitt 中,如果使用相同的事件名称多次注册事件处理程序,后续的注册将不会覆盖先前的注册。
从 Mitt 组件的要点,结合上面代码来说明 meta2d 消息通知的逻辑就是:
1、meta2d 的实例,向 Mitt 事件总线订阅了一个名为 event 的事件,一旦收到该消息,将调用 fn 方法。
2、当某个图元组件触发名为 event 的事件时,会向 Mitt 事件总线发布一个名为 event 的消息通知。
3、Mitt 事件总线将消息发送给 event 的订阅者 - meta2d 的实例。
二、meta2d.js 的事件机制
1、事件的注册
每一个 pen 对象,都有一个类型为对象数组的 events 属性。注册事件,本质上就是往 events 里写数据。
rust
const pen = {
events: [
{
name: "click", // 事件名,也可以理解为事件发生时,发送到消息总线的消息名
action: EventAction.Link, // 执行动作
where: { type:'comparison', key: "text", comparison: "==", value: "矩形" }, // 触发条件
value: "2d.le5le.com",
},
],
};
2、事件的处理
我们结合 meta2d.js 的源码,来理解图元事件的处理流程。
类 Meta2d 的构造函数代码如下:
ini
export class Meta2d {
store: Meta2dStore;
canvas: Canvas;
websocket: WebSocket;
mqttClient: MqttClient;
websockets: WebSocket[];
mqttClients: MqttClient[];
socketFn: (
e: string,
// topic: string,
context?: {
meta2d?: Meta2d;
type?: string;
topic?: string;
url?: string;
}
) => boolean;
events: Record<number, (pen: Pen, e: Event) => void> = {};
map: ViewMap;
mapTimer: any;
constructor(parent: string | HTMLElement, opts: Options = {}) {
this.store = useStore(s8());
this.setOptions(opts);
this.setDatabyOptions(opts);
this.init(parent);
this.register(commonPens());
this.registerCanvasDraw({ cube });
this.registerAnchors(commonAnchors());
globalThis.meta2d = this;
this.initEventFns();
this.store.emitter.on('*', this.onEvent);
}
......
}
这说明,在实例化 meta2d 时,注册了所有事件。只要监听到事件,就调用 this.onEvent 函数。再来看看这个 onEvent 函数的部分内容,以 click 事件为例:
typescript
private onEvent = (eventName: string, e: any) => {
switch (eventName) {
......
case 'click':
e.pen && e.pen.onClick && e.pen.onClick(e.pen, this.canvas.mousePos);
this.store.data.locked && e.pen && this.doEvent(e.pen, eventName);
break;
......
}
};
说明,在收到 click 事件通知时,会调用 对象的 onClick 方法,以及 执行一个 this.doEvent 函数。doEvent 部分代码如下:
ini
private doEvent = (pen: Pen, eventName: EventName) => {
if (!pen) {
return;
}
pen.events?.forEach((event) => {
if (event.actions && event.actions.length) {
event.actions.forEach((action) => {
if (this.events[action.action] && event.name === eventName) {
this.events[action.action](pen, action);
}
});
} else {
if (this.events[event.action] && event.name === eventName) {
let can = !event.where?.type;
if (event.where) {
......
}
can && this.events[event.action](pen, event);
}
}
});
......
// 事件冒泡,子执行完,父执行
this.doEvent(this.store.pens[pen.parentId], eventName);
};
上面代码的核心逻辑,就是循环执行 pen 对象中事件数组中的每个对象的 action 处理:
this.events[event.action](pen, event);
meta2d 内置了一些 action 的处理,在其构造函数中:this.initEventFns (); 代码定义了内置的 action 的具体实现。
了解了消息通知和事件处理机制,我们总结一下事件处理的流程:
1)、进入图纸时,构造一个 meta2d 对象的实例。该实例注册了所有事件的通知。
2)、当图元触发事件时(如 单击事件 click),调用实例的 onEvent 方法,传入了消息名称: click 和 业务数据
3)、调用 doEvent 方法,循环图元的每一个事件,并根据传入的事件名称,来定位和执行事件中定义的 action 实现。
三、事件拦截的实现思路
meta2d 的事件处理本质上是执行一个 pen 对象的所有事件的 action,实现拦截实际上就是拦截这些 action 的执行。但是由于 mitt 的事件机制是一旦有事件,就会执行。因此我们在拦截时,必须禁止其所有的 aciton 执行。那么如何做到这一点?一个实现思路是:执行拦截的事件处理时,先删除其他的事件。在执行完拦截事件后,再恢复其他的事件,并执行。
核心逻辑和代码如下:
1、注册消息监听,这里以单机事件 click 为例:
csharp
meta2dInstance.on('click', customEventListener);
2、定义自定义事件监听程序
typescript
async function customEventListener(data: any) {
// 点击图纸时,也会触发click事件,此时pen属性为空
if (data.pen && meta2dInstance.data().locked > LockState.None) {
let pen = data.pen as Pen;
// 获取图元事件列表
let events = pen.events;
// 检查是否包含目标事件的处理行为
let index = indexOfPasswordVerifyAction(events);
if (index != -1) {
// 缓存事件列表
cachedEvents = JSON.parse(JSON.stringify(events));
// 删除拦截事件,以避免再次触发相同的事件时,造成死循环。将剩余事件赋值给临时变量 otherClientEvents
events.splice(index, 1);
let otherClientEvents = events
// 清空图元的事件列表,以避免执行注册的各个事件。
pen.events = [];
......
try {
// 拦截业务方法,在30秒内没反馈结果,则认为拦截失败。
let nv = await Promise.race([verify(), new Promise((resolve) => {
// 在内部设定一个定时器,以防止长期不操作,或者操作超时的情况下,流程可以正常执行下去。
setTimeout(() => {
// 如果超时,直接返回
resolve(VerifyStatus.NO);
}, VERIFY_TIMEOUT);
})]) as number;
// 拦截通过
if (nv === VerifyStatus.YES) {
// 将除拦截事件的其他事件,赋值给当前图元,并主动触发的事件
pen.events = otherClientEvents;
// 此处以click为例
meta2dInstance.value.emit('click', data);
}
} catch (error) {
console.log('Error:', error);
}
// 无论如何,当前流程完成后,都需要重置状态数据,以保证下次点击可以再次执行当前逻辑。
reset();
}
}
}
indexOfPasswordVerifyAction 函数,逻辑是根据拦截事件的名称,查找在 pen 图元的事件列表中,是否存在这个事件,而且事件的 action 是否就是你需要执行的名称,存在返回该事件在数组中的索引。
reset 代码:
ini
function reset() {
.....
if (cachedEvents.length > 0 && meta2dInstance.value && meta2dInstance.value.store.active[0]) {
meta2dInstance.value.store.active[0].events = cachedEvents;
}
}
核心逻辑就是将缓存的原事件列表,重新赋值给当前激活的 pen 对象。
至此,就实现了自定义的事件拦截。