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

本文介绍了一种利用TypeScript的静态类型特性改进JavaScript传统发布订阅模式的方法,解决了事件名称未知、参数传递不确定等问题,提高了代码的可维护性和开发效率。

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

本文基于 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 eventon: (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),
}, {// 函数式类型声明 eventNamekeyFunction: {/** 测试事件1 */test1: (a: string, b?: number) => {},/** 测试事件2 */test2: (c: string) => {}},// 传统方式声明 eventNamekeyValue: {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 类型参数// emitemitPropertyDescriptorMap[realKey] = {get: () => (...args) => { // proxyif (event.emit) {return event.emit(realKey, ...args); // 这里就是传统的 event}}};
});

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

return {emit
} 

最后

为大家准备了一个前端资料包。包含54本,2.57G的前端相关电子书,《前端面试宝典(附答案和解析)》,难点、重点知识视频教程(全套)。



有需要的小伙伴,可以点击下方卡片领取,无偿分享

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值