likes
comments
collection
share

[TypeScript]实践--手动封装一个事件监听器(基于对发布订阅模式的理解)

作者站长头像
站长
· 阅读数 15

应用背景

在前端开发的过程中,我们可能需要实现对一些异步过程的监听,订阅其发布的一些事件消息从而加以处理。比如: DOM 事件、网络请求等,我们不知道它在何时完成触发。因此需要为其创建一个事件调度中心,并且在事件调度中心中管理所有的订阅者,每当其事件触发时,发布者将通过事件调度中心发布给其管理的每一位订阅者。其实, DOM 事件的管理模式就是一种发布订阅模式,通过 addEventListener / removeEventListener 来实现对事件订阅函数的管理,每当触发事件时,即会向每一个订阅函数发布事件消息。

实现思路

本文将利用 TypeScript 实现一个类似 DOM 事件监听器的构造类,包含 on / off 方法来实现对事件订阅(监听)函数的增加和移除,因此需要一个 evtHooks 队列数组来存储订阅函数,同时因为可能存在多种事件类型,故采用一个对象的方式存储,键名表示事件名,键值则是该事件所存储的事件订阅函数队列 evtHooks 。实现对订阅函数的管理工作之后,需要指定一个发布函数 emit 来将事件发布给所有的订阅函数。而这个发布函数 emit 将在事件触发的过程中执行。具体实现代码如下:

/**
 * @title IEventListener
 * @author wzdong
 * @description 事件监听器, 用于需要监听自定义事件的类继承或挂载其成员方法 on\off\emit , 或需要监听自定义事件的对象挂载 on\off\emit 方法
 */

/**
 * @eg
 * class ChildClass extends IEventListener<EvtName, Evt> {
 *     constructor() {
 *         super(evtNameList?: EvtName[])
 *     }
 * }
 */
export default class IEventListener<EvtName extends string, Evt = any> {
    private _evtNameList?: EvtName[];
    private _evtHooksRecord: Record<EvtName, Array<(evt: Evt) => void>> = {} as any;

    constructor(evtNameList?: EvtName[]) {
        this._evtNameList = evtNameList;
    }
    private _findEvtHooks(
        evtName: keyof typeof this._evtHooksRecord
    ): Record<EvtName, ((evt: Evt) => void)[]>[EvtName] | never {
        if (this._evtNameList && !this._evtNameList.includes(evtName))
            throw new Error(`There is no event called ${evtName}!`);
        this._evtHooksRecord[evtName] = this._evtHooksRecord[evtName] ?? [];
        return this._evtHooksRecord[evtName];
    }
    emit(evtName: keyof typeof this._evtHooksRecord, evt: Evt, self?: object) {
        const evtHooks = this._findEvtHooks(evtName);
        evtHooks.forEach((cbk) => cbk.bind(self)(evt));
    }
    // 添加事件,类似于HTMLElement.addEventListener
    on(evtName: keyof typeof this._evtHooksRecord, listener: (evt: Evt) => void) {
        const evtHooks = this._findEvtHooks(evtName);
        evtHooks.push(listener);
    }
    // 注销事件,类似于HTMLElement.removeEventListener
    off(evtName: keyof typeof this._evtHooksRecord, listener: (evt: Evt) => void) {
        const evtHooks = this._findEvtHooks(evtName);
        evtHooks.splice(evtHooks.indexOf(listener), 1);
    }
}

以上实现的事件监听器构造类,一个自定义的类如果希望实现对其自身的一些异步事件的监听,可通过class ChildClass extends IEventListener<EvtName, Evt> {}的方式来继承其 emit、on、off 方法。但是这种方法似乎不太好看,它们逻辑上应该不算是继承关系。因此以下对IEventListener类再做了一次函数封装:

/**
 * @eg1
 * class someClass {
 *     constructor() {
 *         useEventListener.bind(this)<EvtName, Evt>(evtNameList?: EvtName[])
 *     }
 * }
 * @eg2
 * const someObj = {}
 * useEventListener.bind(someObj)<EvtName, Evt>(evtNameList?: EvtName[])
 */
export function useEventListener<EvtName extends string, Evt = any>(
    this: any,
    evtNameList?: EvtName[]
) {
    const evtListener = new IEventListener<EvtName, Evt>(evtNameList);
    this.on = evtListener.on.bind(evtListener);
    this.off = evtListener.off.bind(evtListener);
    this.emit = (evtName: EvtName, evt: Evt) => evtListener.emit.bind(evtListener)(evtName, evt, this);
}

useEventListener函数实现的工作:通过IEventListener类创建一个实例并将该实例上的 on、off、emit 方法挂载到调用该函数的对象上。

使用场景

如果你做过一些 canvas 动画开发,可能就常常要用到 window.requestAnimationFrame 函数来做动画实时渲染,其伪代码实现如下:

animate() {
    const animation = (time: number) => {
        // 这里是执行你的一些动画函数
        requestAnimationFrame(animation);
    };
    requestAnimationFrame(animation);
}

当然你可以将 canvas 里面每一部分内容的动画执行全部都写在animation函数里,但是当需要执行动画的内容越来越多的时候,animation函数里面就会显得过于臃肿。因此,这里提到的事件监听器就很好的派上用场了,你可以在animation函数执行的过程中发布一个事件消息, canvas 里的每一部分需要执行动画的内容都可以来订阅此次事件,订阅到该事件消息后将执行自己这部分的动画。 因此,你可以借助上面的useEventListener函数把你的 canvas 动画渲染器封装成一个类,像下面这样:

class MyRenderer {
    on!: (evtName: EvtName, cbk: (evt: Evt) => void) => void;
    off!: (evtName: EvtName, cbk: (evt: Evt) => void) => void;
    emit!: (evtName: EvtName, evt: Evt) => void;
    constructor() {
        // 对 MyRenderer 类绑定 on / off / emit 成员方法。
        useEventListener.bind(this)<EvtName, Evt>(['onAnimation'])
    }
    animate() {
        const animation = (time: number) => {
            // 这里发布`onAnimation`事件消息
            this.emit?.('onAnimation', time);
            requestAnimationFrame(animation);
        };
        requestAnimationFrame(animation);
    }
}
const myRenderer = new MyRenderer()
// 开始渲染动画
myRenderer.animate()

之后,你可以在其它地方专注的写执行动画的内容。比如,此时你需要添加一个birdFlying的动画。

const birdFlying = (time: number) => {
    console.log('鸟儿飞啊飞', time);
};
myRenderer.on!('onAnimation', birdFlying);
myRenderer.animate();
setTimeout(() => {
    myRenderer.off!('onAnimation', birdFlying);
}, 1000);

后面你可能又写了一个robotDancing的动画,同样的方式可以将这个动画也添加进去。

const robotDancing = (time: number) => {
  console.log('机器人跳着舞', time);
};
myRenderer.on!('onAnimation', robotDancing);
myRenderer.animate();

后面你创建了更多的动画只需要按照这样的方式来进行添加进去即可。通过这种方式,你可以将每个部分的动画内容单独管理,而不用全部都写在aniamtion函数中。每个部分动画只需要关注自己的核心功能,很好的实现了关注点分离。 以上只是列举动画渲染这一个场景下利用事件监听器的妙用,事件监听器不仅仅局限于这一场景,可以用它来处理很多异步场景,如请求、IndexedDB数据库连接等等。


以上是本人关于事件监听器的一点理解,希望对你有帮助。如有理解错误之处,还望大神指出!