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

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

本文基于 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 的发布订阅系统

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

优化思路

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

由于传统订阅发布模式的实现,导致在 JavaScript 下很难下手建立强关联。这里请出 JavaScript 的知名外挂 —— TypeScript,我们能通过它强大的类型编程,像胶水一样把 JavaScript 代码束缚起来。

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

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

效果预览

先放出最终实现的效果。

假设类型定义如下:

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

最终效果如下:

在前文当中,在对象 eventFunction 中定义了test1这个函数。eventTrain把它定义的类型挪了过来,挂载到了自己的.on.test1之上,从而约束订阅方法里的 callback 类型。

更为有意思的是,通过这种使用方式,test1这个事件的 doc 描述:"测试事件1",也能显示出来。

发布者也是类似的,上文 eventFunction.test2的函数签名为 (c: string) => {}

额外需要注意的是,如果emit里面传递了一个错误类型,比如 boolean,会有 ts error。比如第一行的eventTrain.emit.test2(true)。说明了这种订阅发布模式的使用方式拥有了强类型的联系。

意义

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

  1. 所有的事件名称可查可管理。借助 TS 能够自动显示所有可用的事件,又由于可以显示预设的 TSDoc,在使用过程中不需要全局去查找怎么使用。
  2. 由于在 eventFunction 内约束了事件的函数类型,因此可以限制对应事件的"发布参数类型"和"订阅函数类型"。这里通过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 做一个代理,包一层马甲。

eventHelper 的第二个参数里:

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

提供了 keyValue 这个属性,也就是兼容传统的 string 方式,来辅助管理。这种方式定义的事件,在使用过程中缺少函数签名(都是 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 上,点击这里查看。也可以下载下来到本地,实际体验一下,不需要任何依赖,但请配合能解析 TS 的 IDE(比如 VSCode) 使用。

结语

欢迎大家发表自己的见解和看法,一起交流、学习和成长。

作者:Y君

%s 个评论

要回复文章请先登录注册