设计模式 - 订阅模式 (TS语言版)
在软件开发中经常会遇到的场景:当某个核心事件发生时,需要通知多个相关模块做出响应。比如编辑器的撤销/重做操作,当用户执行"撤销"时,要回滚编辑内容、更新历史记录面板、修改状态提示等。如果直接在事件触发处编码所有响应逻辑,会导致代码耦合度极高,后续新增或删除响应模块时改动成本巨大。而订阅模式正是解决这类"一对多"通信问题的绝佳方案。
一、什么是订阅模式?
订阅模式(也叫观察者模式)是一种行为型设计模式,它定义了对象之间的一对多依赖关系:当一个对象(被称为"发布者"或"主题")的状态发生变化时,所有依赖它的对象(被称为"订阅者"或"观察者")都会自动收到通知并做出响应。
这个模式在生活中也很常见:比如订阅报纸,报社(发布者)一旦有新报纸出版,就会给所有订阅者派送。在代码世界中,订阅模式的核心就是解耦发布者和订阅者,让两者只通过约定的接口通信,而不用直接依赖彼此。
二、订阅模式的核心组件
一个完整的订阅模式通常包含三个核心部分,结合后续代码对应理解:
-
事件类型定义:明确发布者可以发布哪些事件。
-
发布者(Publisher):负责维护订阅者列表,提供订阅和发布事件的接口。
-
订阅者(Subscriber):通过订阅事件注册自己的响应逻辑,当事件被发布时执行回调。
三、TypeScript实战:实现一个编辑器事件管理器
下面结合题目中提供的代码,一步步解析如何用TypeScript实现订阅模式,这个例子是一个编辑器的事件管理器,支持撤销、重做、添加操作、添加模型、删除模型等事件的订阅和发布。
1. 第一步:定义事件类型,约束通信规范
首先需要明确系统中支持哪些事件,这一步用TypeScript的常量和类型推导可以做得非常优雅:
const EVENT_NAME = [
"operation:undo", // 操作:撤销
"operation:redo", // 操作:重做
"operation:add", // 操作:添加
] as const;
type EventName = typeof EVENT_NAME[number];
这里有两个关键知识点:
-
as const:将数组变为"只读元组",TypeScript会记住数组中每个元素的具体字符串值,而不是简单地推断为string[]。 -
typeof EVENT_NAME[number]:通过索引访问类型,自动推导出自定义类型EventName,其取值只能是元组中的几个字符串之一。这就实现了事件类型的强约束,避免订阅或发布不存在的事件。
如果后续需要新增事件,只需在
EVENT_NAME数组中添加,EventName类型会自动更新,无需手动修改类型定义,减少出错概率。
2. 第二步:实现发布者类EventListener
发布者是订阅模式的核心,需要维护"事件-订阅者回调"的映射关系,同时提供订阅(on)和发布(emit)方法。来看代码实现:
class EventListener {
// 维护事件与回调函数集合的映射,键为EventName类型,值为函数集合
private FUNC_MAP: Record<EventName, Set<Function>> = {
"operation:undo": new Set(),
"operation:redo": new Set(),
"operation:add": new Set(),
}
// 订阅方法:为指定事件添加回调函数
on(eventName: EventName, callback: Function) {
this.FUNC_MAP[eventName].add(callback);
}
// 取消订阅方法:移除指定事件的回调函数
off(eventName: EventName, callback: Function) {
this.FUNC_MAP[eventName].delete(callback);
}
// 发布方法:触发指定事件,执行所有订阅的回调函数
emit(eventName: EventName, ...args: any[]) {
this.FUNC_MAP[eventName].forEach((callback) => {
callback(...args);
});
}
}
这里有几个关键设计点需要解析:
(1)用Set存储回调函数的原因
没有用数组而是用Set来存储回调函数,主要是因为Set会自动去重。如果同一个订阅者不小心重复订阅了同一个事件,Set只会保留一个回调函数,避免事件发布时重复执行相同逻辑。
(2)订阅方法on
当订阅者调用on方法时,只需传入事件名称和回调函数,发布者就会将该回调函数添加到对应事件的Set集合中。由于eventName被约束为EventName类型,TypeScript会在编译阶段检查传入的事件是否合法,避免拼写错误。
(3)发布方法emit
当发布者调用emit方法时,会根据事件名称找到对应的回调函数集合,然后遍历集合执行每个回调函数,并将传入的参数(...args)传递给回调函数。这样就实现了"一次发布,多订阅者响应"的核心功能。
(4)取消订阅方法off
为了支持订阅者主动取消订阅,避免内存泄漏,提供了off方法。订阅者只需传入事件名称和要移除的回调函数,发布者就会从对应事件的Set集合中删除该回调,后续该事件发布时,被移除的回调将不再执行。
3. 第三步:创建发布者实例并导出
为了让整个系统共享同一个事件管理器(单例模式思想),创建EventListener的实例并导出,其他模块可以直接导入使用:
const operationListener = new EventListener();
export { operationListener };
四、如何使用这个订阅模式?
上面完成了发布者的实现,接下来看看订阅者如何订阅事件,以及发布者如何发布事件。通过一个编辑器的场景来演示:
1. 订阅者订阅事件
假设有三个模块:编辑面板、历史记录面板、状态提示栏,它们都需要响应"operation:undo"(撤销)事件:
import { operationListener } from './EventListener';
// 1. 编辑面板:撤销时回滚内容
operationListener.on("operation:undo", (content: string) => {
console.log("编辑面板回滚内容:", content);
// 实际逻辑:更新编辑区域内容
});
// 2. 历史记录面板:撤销时更新历史记录
operationListener.on("operation:undo", (content: string) => {
console.log("历史记录面板更新:", content);
// 实际逻辑:高亮上一步操作
});
// 3. 状态提示栏:撤销时显示提示
operationListener.on("operation:undo", (content: string) => {
console.log("状态提示:已撤销至「" + content + "」");
// 实际逻辑:显示临时提示框
});
2. 发布者发布事件
当用户点击"撤销"按钮时,发布者触发"operation:undo"事件,并传递回滚的内容:
import { operationListener } from './EventListener';
// 撤销按钮点击事件处理
const handleUndo = () => {
const rollbackContent = "第2步:添加标题"; // 实际场景中从历史记录获取
// 发布撤销事件,所有订阅者都会收到通知
operationListener.emit("operation:undo", rollbackContent);
}
// 模拟用户点击撤销
handleUndo();
3. 执行结果
当调用handleUndo后,控制台会输出以下内容:
编辑面板回滚内容:第2步:添加标题
历史记录面板更新:第2步:添加标题
状态提示:已撤销至「第2步:添加标题」
可以看到,三个订阅者模块都收到了事件通知并执行了各自的逻辑,而发布者完全不需要知道有哪些订阅者存在,两者实现了彻底解耦。
五、订阅模式的优势与适用场景
1. 核心优势
-
解耦发布者与订阅者:发布者无需知道订阅者的具体实现,只需专注于发布事件;订阅者也无需知道发布者的内部逻辑,只需关注订阅的事件。
-
扩展性极强:新增订阅者时,只需调用
on方法订阅事件;删除订阅者时,调用off方法即可,均无需修改发布者核心代码。 -
灵活的事件通信:支持一对多、多对多的通信,一个事件可以有多个订阅者,一个订阅者也可以订阅多个事件。
2. 适用场景
-
事件驱动的系统:编辑器、IDE、前端页面的交互事件(点击、输入、滚动等)。
-
跨模块通信:当多个模块需要响应同一个核心事件时,如后端服务的日志记录、监控告警等。
-
动态添加/删除响应逻辑:功能需要动态启用或禁用,可通过订阅/取消订阅事件实现。