更好维护的发布订阅模式的应用

更好维护的发布订阅模式的应用

发布订阅模式非常灵活,但随在项目中使用的越来越多,也会越来越因为难以维护而屡遭诟病。

本文基于 TypeScript 的静态能力,解决传统 JavaScript 发布订阅模式在应用中的一些痛点,以求提升开发人员的编码体验。

痛点场景

先来看看传统实现的发布订阅模式:

// event.js
class Event {
    on(eventName, callback){}
    /** 只触发一次 */
    once(eventName, callback){}
    emit(eventName, ...args){}
    off(eventName, callback){}
    /** 删除该event下的所有事件 */
    offAll(eventName){}
}

export default new Event

通过 on/once订阅事件,用emit发布事件,用off/ofAll销毁订阅。

业务代码中会这么使用:

import event from './event'

event.on('test1', (arg1, arg2) => {
    // code
})
event.on('test1', (arg1, arg2) => {
    // code
})

event.emit('test1', '1')
event.emit('test1', '1', '2')

前文有提到,这种使用方式有很多令人诟病的地方。

1.事件名称未知

随着订阅的事件多起来,并且散落在各个文件当中,开发者难以得知有什么事件可用。

虽然现在有一种方案,用一个文件来管理所有的 eventName,

export const TEST_EVENT1 = 'TEST_EVENT1'
export const TEST_EVENT2 = 'TEST_EVENT2'

但非常不优雅,每次要用事件的时候要另外 import 进来。并且不好直观的得知当前 eventName 会在什么场景发布。

2.事件发布者不知道怎么传递参数

对于事件发布者来说,因为订阅函数通常是散落在各个文件当中,也没有统一的规范约束使用一致的函数签名。因此不知道自己传递的参数是否符合预期,稳妥的方式是扫一遍所有的订阅函数,再做定夺。

// xxx/xxx/index.js
event.on('test1', (arg1) => {})
// xxx/index2.js
event.on('test1', (arg1, arg2) => {})
// ./index.js
event.emit('test1', '?', '?') // 这里怎么传递比较好?

3.维护压力大

这是基于上面1、2的问题之下,引起的第三个问题。

正因为没有统一的规范,导致如果要维护/修复某一个事件,需要把所有的事件看一遍,还不一定能找到方法。

在一些更加复杂的代码中,订阅事件的函数签名更加混乱,甚至不知道这个系统怎么运转起来的,更是没人敢动。

基于 TS 的发布订阅系统

通过分析,上面痛点里最大的问题就是因为"发布"和"订阅"之间缺少强关联,任期发展,导致相互耦合严重。

设计思路

所以优化的第一个思路就是建立强关联的逻辑。

由于订阅发布模式的实现,导致在 JS 下很难下手。这里就请出非常强大的 TS 系统,它能通过自定义的类型编程,像胶水一样把代码束缚起来。

优化的第二个思路,是将无意义的 string 类型的 eventName,改成函数式这种更为灵活的方式。

string 类型能操作的空间有限,结合 TS 之后也不能很好的使用 TSDoc 和泛型等工具。因此要将原有的 API 改造成更为合适的新 API。

效果预览

先放出最终实现的一个效果。

假设类型定义如下:

const eventFunction = {
    /** 测试事件1 */
    test1: (a: string, b?: number) => {},
    /** 测试事件2 */
    test2: (c: string) => {}
}

最终效果如下:

在前文当中,在对象 eventFunction 中定义了 test1这个函数。在这里是把它之前定义的函数类型挪了过来,约束订阅方法里的 callback 类型。可以留意到对于test1这个事件的描述"测试事件1"也能显示出来。

发布者也是类似的,上文 eventFunction.test2的函数签名为 (c: string) => {}。这里可以看到,如果emit里面传递了一个 boolean 类型,会提示错误,说明拥有了强类型的关系。

意义

回过头来看当初的三个痛点。

  1. 所有的事件名称可查可管理。借助 TS 能够自动显示所有可用的事件,又由于可以显示预设的 TSDoc,在使用过程中不需要全局去看怎么使用。
  2. 由于在 eventFunction 内约束了对应事件的函数类型,因此可以用在后面的发布订阅当中,使得约束它们的类型成为可能。
  3. 由于有了强关联,借助 TS Error,可以轻松实现任意事件的维护和重构。

实现

接下来聊一聊如何实现。

交互设计

首要的思路是把它做成非侵入式的 eventHelper,并且不对当前的业务有影响。

接入方式设计如下:

import event from './event'
import { eventHelper } from './eventHelper'

const eventTrain = eventHelper({
  	// proxy event
    on: (eventName, callback) => event.on(eventName, callback),
    once: (eventName, callback) => event.once(eventName, callback),
    emit: (eventName, ...args) => event.emit(eventName, ...args),
    off: (eventName, callback) => event.off(eventName, callback),
    offAll: (eventName) => event.offAll(eventName),
}, {
  	// 函数式类型声明 eventName
    keyFunction: {
        /** 测试事件1 */
        test1: (a: string, b?: number) => {},
        /** 测试事件2 */
        test2: (c: string) => {}
    },
  	// 传统方式声明 eventName
    keyValue: {
        TEST_EVENT1: 'TEST_EVENT1',
        TEST_EVENT2: 'TEST_EVENT2',
    }
})

通过 eventHelper 的第一个参数,对传统的 event 做一个代理,包一层马甲。

在第二个参数里,

提供了keyFunction这个属性,也就是函数类型来预定义和管理所有 eventName

提供了 keyValue 这个属性,也就是兼容传统方式,来辅助管理,缺少函数签名,但是能够管理所有事件名称。

通过 keyValue 预定义的 eventName,里面的参数类型都是 any。

核心类型编程

想要实现上面的类型效果,需要使用 TS 对类型编码。

核心实现如下:

/** 任意函数 */
type IAnyFunction<T = any> = (...args: any) => T
/** 转换器 */
type EventNameTransFunction<
    EventFunctionTypes extends Record<string, IAnyFunction> = Record<string, IAnyFunction>,
    EventKeyTypes extends Record<string, string> = Record<string, string>,
> = {
    [T in keyof (EventKeyTypes&EventFunctionTypes)]: (EventKeyTypes&EventFunctionTypes)[T] extends IAnyFunction ?
        (EventKeyTypes&EventFunctionTypes)[T] :
        IAnyFunction<void>
}

简单的解释一下。EventNameTransFunction这个类型接收两个泛型参数,

第一个泛型参数是EventFunctionTypes,设置为 string: Function 的形状,对应的就是这种:

// EventFunctionTypes extends Record<string, IAnyFunction> = Record<string, IAnyFunction>
{
  	/** 测试事件1 */
  	test1: (a: string, b?: number) => {},
    /** 测试事件2 */
    test2: (c: string) => {}
}

第二个泛型参数是 EventKeyTypes,设置为 string: string 的形状,对应的就是这种:

// EventKeyTypes extends Record<string, string> = Record<string, string>
{
    TEST_EVENT1: 'TEST_EVENT1',
    TEST_EVENT2: 'TEST_EVENT2'
}

在后面将它们两个联合起来EventKeyTypes&EventFunctionTypes,一起处理。通过判断是否为函数,来分别设置不同的类型:

(EventKeyTypes&EventFunctionTypes)[T] extends IAnyFunction ?
	(EventKeyTypes&EventFunctionTypes)[T] : // 这里是直接使用定义的类型
	IAnyFunction<void>	// 这里是使用通用函数

测试一下效果:

const keyFunction = {
    /** 测试事件1 */
    test1: (a: string, b?: number) => {},
    /** 测试事件2 */
    test2: (c: string) => {}
}
const keyValue = {
    TEST_EVENT1: 'TEST_EVENT1',
    TEST_EVENT2: 'TEST_EVENT2',
}
type test = EventNameTransFunction<typeof keyFunction, typeof keyValue>

和预期一样。

event 代理实现

因为要对 event 实例做非侵入式实现,因此是用代理的方式实现,核心函数是 Object.defineProperties

// 这里只给出 emit 相关的实现
// 把函数形式的event key取出来,生成 { key: key }
const keyFnName = {}
if (keyFunction) {
  Object.keys(keyFunction).forEach((key) => {
    keyFnName[key] = key;
  });
}
const eventKeys = {
  ...keyValue,
  ...keyFnName,
};

const emitPropertyDescriptorMap = {};
Object.keys(eventKeys).forEach((key) => {
  const realKey = eventKeys[key]; // realKey 就是对应 event.emit 里的第一个 string 类型参数
  // emit
  emitPropertyDescriptorMap[realKey] = {
    get: () => (...args) => { // proxy
      if (event.emit) {
        return event.emit(realKey, ...args); // 这里就是传统的 event
      }
    }
  };
});

const emit = Object.defineProperties({}, emitPropertyDescriptorMap);

return {
  emit
}

剩余实现

解决了两个核心难点,剩下的就是仁者见仁智者见智了,设计自己想要的功能。

我这里提供一个自己用于生产环境的完整实现,放在了 github 上,点击这里查看。也可以下载下来到本地,实际体验一下,不需要任何依赖,但请使用 VSCode。

结语

欢迎大家发表自己的见解和看法。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值