更好维护的发布订阅模式的应用
发布订阅模式非常灵活,但随在项目中使用的越来越多,也会越来越因为难以维护而屡遭诟病。
本文基于 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 类型,会提示错误,说明拥有了强类型的关系。
意义
回过头来看当初的三个痛点。
- 所有的事件名称可查可管理。借助 TS 能够自动显示所有可用的事件,又由于可以显示预设的 TSDoc,在使用过程中不需要全局去看怎么使用。
- 由于在
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 做一个代理,包一层马甲。
在第二个参数里,
提供了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。
结语
欢迎大家发表自己的见解和看法。