前言
发布订阅通过事件通道将发布者与订阅者解耦,使系统可在不修改双方代码的前提下横向扩展。本文基于精简 EventEmitter 实现,剖析订阅登记、消息派发及取消订阅的完整数据流,并结合实际场景讨论其松耦合收益与调试、可靠性、资源占用等潜在成本,为架构选型提供参考。
1️⃣ 发布订阅模式的定义
发布订阅模式 是一种设计模式,它定义了一种一对多 的关系,让多个订阅者同时监听某一个发布者,当发布者状态发生改变时,会自动通知所有订阅者。它让发布者 和订阅者 通过事件通道(Event Channel) 通信,两者完全解耦。
- 一对多:一个发布者可以同时通知任意数量的订阅者;
- 松耦合:发布者不知道谁在订阅,订阅者也不知道消息是谁发的;
- 事件驱动 :系统以事件 为核心,按 事件名(eventName) 进行匹配;
- 异步友好:天然支持同步或异步派发,常用于消息队列、微前端、跨组件通信等场景。
2️⃣ 代码实现
js
class EventEmitter {
// 创建一个纯净的哈希表------事件列表(用于存储事件和回调函数)
// 事件名作为key,回调函数数组作为value
constructor() {
this.eventList = {
// 'hasHouse': [publish, process],
// 'hasCar': [done]
}
}
// 订阅
on(eventName, callback) {
// ①判断事件是否存在,不存在则创建
if(!this.eventList[eventName]) {
this.eventList[eventName] = []
}
// ②将回调函数添加到事件列表中
this.eventList[eventName].push(callback)
}
// 取消订阅
off(eventName, callback){
// ①判断事件是否存在,存在则删除对应的回调函数
if(this.eventList[eventName]) {
// ②filter()方法过滤出不等于回调函数的回调函数
this.eventList[eventName] = this.eventList[eventName].filter((item) => {
return item !== callback
})
}
}
// 发布
emit(eventName) {
// ①判断事件是否存在,存在则调用对应事件的回调函数
if(this.eventList[eventName]) {
// ②slice()方法复制事件列表,避免在回调函数中直接操作事件列表
const handlers = this.eventList[eventName].slice()
// ③遍历事件列表,调用每个回调函数
handlers.forEach((item) => {
item()
})
}
}
// 一次性订阅
once(eventName, callback) {
// ①创建一个包装函数,在调用回调函数后,手动删除包装函数,使包装函数失效
const wrapper = () => {
callback()
this.off(eventName, wrapper)
}
// ②将包装函数添加到事件列表中
this.on(eventName, wrapper)
}
}
function publish() {console.log('发布事件');}
function process() {console.log('处理事件');}
function done() {console.log('事件处理完成');}
let _event = new EventEmitter() // 事件发射器
_event.on('hasHouse', publish) // 订阅
_event.on('hasHouse', process) // 订阅
_event.off('hasHouse', process) // 取消订阅
_event.once('hasHouse', process) // 一次性订阅
_event.on('hasCar', done) // 订阅
_event.emit('hasHouse') // 发布 => publish,process函数的调用
_event.emit('hasCar') // 发布 => done函数的调用

🧠 记忆口诀
订阅是「存号码」,发布是「拨群号」,取消订阅是「删号码」。
事件通道就是「通讯录」,永远不知道谁打电话,只负责按号码群发。
动作 | 口诀 | 关键代码 |
---|---|---|
订阅 | 存号码 | events[type].push(fn) |
取消 | 删号码 | events[type]=events[type].filter(...) |
发布 | 群发消息 | events[type].forEach(fn=>fn()) |
3️⃣ 优劣速查表
✅ 五大核心优势
优势 | 一句话释义 | 真实场景举例 |
---|---|---|
松耦合 | 发布者和订阅者互不知晓、可独立迭代 | 前端 A/B 组件通过事件总线通信,A 重构时无需改动 B |
高伸缩性 | 事件通道可横向扩展 | Redis Cluster 支撑上万订阅者实时消息 |
灵活多对多 | 同一事件可被 N 个订阅者、N 个发布者共享 | 微服务日志中心:多个服务同时向一个 Topic 写日志,多个监控实例同时消费 |
异步非阻塞 | 发布后立即返回,订阅者在事件循环中并行处理 | Node.js EventEmitter 提升 I/O 吞吐量 |
系统简洁 | 统一消息格式即可,无需为每个订阅者定制接口 | Kafka 只需定义 Avro Schema,上下游按 Schema 读写 |
❌ 四大典型劣势
劣势 | 风险表现 | 实际痛点案例 |
---|---|---|
调试困难 | 事件链路不透明,断点难以跟踪 | 前端全局 Bus 滥用 → 数据流"幽灵更新" |
消息可靠性 | 无持久化时,Broker 宕机即丢消息 | Redis Pub/Sub 默认不持久化,重启后离线期间消息全部丢失 |
顺序与重复 | 并发订阅者可能乱序或重复消费 | 多个实例并行消费同一分区,需额外幂等设计 |
资源消耗 | 订阅数量爆炸 → 内存/CPU 飙升 | 每订阅一个主题即创建一个队列,未清理会造成内存泄漏 |