更好维护的发布订阅模式应用
发布订阅模式非常灵活,但随在项目中使用的越来越多,也会越来越因为难以维护而屡遭诟病。
本文基于 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)
。说明了这种订阅发布模式的使用方式拥有了强类型的联系。
意义
回过头来看当初的三个痛点。
- 所有的事件名称可查可管理。借助 TS 能够自动显示所有可用的事件,又由于可以显示预设的 TSDoc,在使用过程中不需要全局去查找怎么使用。
- 由于在
eventFunction
内约束了事件的函数类型,因此可以限制对应事件的"发布参数类型"和"订阅函数类型"。这里通过eventFunction
将所有的订阅发布强关联了到了一起。 - 由于有了强关联,借助 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) 使用。
结语
欢迎大家发表自己的见解和看法,一起交流、学习和成长。