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

前言

发布订阅通过事件通道将发布者与订阅者解耦,使系统可在不修改双方代码的前提下横向扩展。本文基于精简 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 飙升 每订阅一个主题即创建一个队列,未清理会造成内存泄漏
相关推荐
奕辰杰3 小时前
关于npm前端项目编译时栈溢出 Maximum call stack size exceeded的处理方案
前端·npm·node.js
JiaLin_Denny4 小时前
如何在NPM上发布自己的React组件(包)
前端·react.js·npm·npm包·npm发布组件·npm发布包
_Kayo_5 小时前
VUE2 学习笔记14 nextTick、过渡与动画
javascript·笔记·学习
路光.5 小时前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!5 小时前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作6 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹6 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz7 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理
换日线°7 小时前
css 不错的按钮动画
前端·css·微信小程序