发布订阅模式:实现机制与工程权衡

312 阅读4分钟

前言

发布订阅通过事件通道将发布者与订阅者解耦,使系统可在不修改双方代码的前提下横向扩展。本文基于精简 EventEmitter 实现,剖析订阅登记、消息派发及取消订阅的完整数据流,并结合实际场景讨论其松耦合收益与调试、可靠性、资源占用等潜在成本,为架构选型提供参考。


1️⃣ 发布订阅模式的定义

发布订阅模式是一种设计模式,它定义了一种一对多的关系,让多个订阅者同时监听某一个发布者,当发布者状态发生改变时,会自动通知所有订阅者。它让发布者订阅者通过事件通道(Event Channel) 通信,两者完全解耦

  • 一对多:一个发布者可以同时通知任意数量的订阅者;
  • 松耦合:发布者不知道谁在订阅,订阅者也不知道消息是谁发的;
  • 事件驱动:系统以事件为核心,按 事件名(eventName) 进行匹配;
  • 异步友好:天然支持同步或异步派发,常用于消息队列、微前端、跨组件通信等场景。

2️⃣ 代码实现

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函数的调用

1753966276769.png

🧠 记忆口诀

订阅是「存号码」,发布是「拨群号」,取消订阅是「删号码」。
事件通道就是「通讯录」,永远不知道谁打电话,只负责按号码群发。

动作口诀关键代码
订阅存号码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 飙升每订阅一个主题即创建一个队列,未清理会造成内存泄漏