发布订阅模式渐进式学习指南
写给前端开发者的一篇入门笔记:先把发布订阅模式的核心想清楚,再看它在项目里为什么这么常见,以及什么时候该用、什么时候别硬用。
目录
- 什么是发布订阅模式?
- 它到底解决了什么问题?
- 先从一个最小例子看懂它
- 发布者、订阅者、中间层分别是什么
- 它和观察者模式到底有什么区别
- 前端里常见的使用场景
- 手写一个简单的事件中心
- 再往前走一步:支持取消订阅和只订阅一次
- 发布订阅模式的优点和问题
- 几个很容易踩的坑
- 如果放到面试里,怎么讲比较自然
- 练习题
- 最后做个收束
一、什么是发布订阅模式?
发布订阅模式(Publish--Subscribe Pattern)本质上是在处理一件事:
一个地方发生了变化,另外一些地方想知道这件事,但它们彼此最好不要直接绑死。
它通常有三个角色:
- 发布者(publisher):负责发消息
- 订阅者(subscriber):负责接收消息并处理
- 事件中心 / 消息中心(broker / event bus):负责转发消息
和"你直接调用我、我直接依赖你"不一样,发布订阅模式会在中间加一层。发布者不需要知道是谁在听,订阅者也不需要知道消息到底是谁发出来的,大家都只和中间层打交道。
如果用一句更白一点的话说:
发布者负责喊一嗓子,谁想听就去订阅,真正传话的是中间那个消息中心。
二、它到底解决了什么问题?
前端项目里经常会出现一种需求:某个状态变化后,不止一个地方要做反应。
比如:
- 用户登录成功后,导航栏要更新头像
- 购物车变化后,角标数字要刷新
- 切换主题后,多个组件都要同步更新样式
- 某个模块请求成功后,别的模块要刷新数据
如果不用发布订阅,最直接的写法通常是"谁变化了就手动通知谁"。这样短期看起来省事,长期通常会越写越紧。
举个很简单的例子:
js
function login() {
updateNavbar()
updateUserPanel()
updateCartInfo()
updateMessageCenter()
}
这段代码最大的问题不是不能跑,而是耦合太死:
- 登录逻辑必须知道后面所有要更新的模块
- 新增一个联动模块时,登录函数还得继续改
- 模块之间的边界会越来越模糊
而发布订阅的思路是:登录模块只负责发出"登录成功"这个消息,至于谁需要响应,各自去订阅。
这样一来,登录模块就不需要认识所有业务模块了。
三、先从一个最小例子看懂它
先看一个最小版本。
js
const eventBus = {
events: {},
on(eventName, handler) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(handler)
},
emit(eventName, data) {
const handlers = this.events[eventName] || []
handlers.forEach((handler) => handler(data))
},
}
// 订阅
eventBus.on('login', (userInfo) => {
console.log('更新导航栏:', userInfo.name)
})
eventBus.on('login', (userInfo) => {
console.log('更新个人中心:', userInfo.name)
})
// 发布
eventBus.emit('login', { name: 'Alice' })
输出结果会是:
js
更新导航栏: Alice
更新个人中心: Alice
这段代码里最关键的是两件事:
- 订阅者通过
on()把处理函数注册到事件中心 - 发布者通过
emit()把消息发出去
发布消息的人根本不关心有多少订阅者,也不关心订阅者分别是谁;它只负责把消息扔到事件中心。
这就是发布订阅模式最核心的味道。
四、发布者、订阅者、中间层分别是什么
很多人第一次学这个模式,会觉得"我大概懂了,但又说不清谁是谁"。可以把它拆开看。
1. 发布者
发布者负责发消息。它不处理消息流向,也不关心谁会收到。
比如:
- 登录模块发布
loginSuccess - 购物车模块发布
cartUpdated - 主题切换模块发布
themeChanged
2. 订阅者
订阅者负责监听某类消息,并在消息到来时执行自己的逻辑。
比如:
- 导航栏订阅
loginSuccess - 购物车角标订阅
cartUpdated - 页面组件订阅
themeChanged
3. 中间层
中间层是发布订阅模式里最关键的一层。它负责:
- 保存事件和处理函数的映射关系
- 在消息发布时找到对应的订阅者
- 调用这些订阅者的处理函数
这层在前端里常见的名字有:
eventBuseventEmittermessageCenterpubsub
没有这层,也就谈不上典型的发布订阅模式。
五、它和观察者模式到底有什么区别
这是最容易被混淆的一点。
很多文章会把观察者模式和发布订阅模式混着讲,实际开发里问题不一定大,但如果你要真正讲清楚,最好还是分开。
一个直观结论
观察者模式更像对象之间直接建立联系;发布订阅模式中间多了一层事件中心。
观察者模式更像什么
观察者模式里,目标对象通常直接维护观察者列表。
也就是说:
- 被观察者知道有哪些观察者
- 观察者也通常和被观察者存在直接关系
发布订阅模式更像什么
发布订阅模式里,发布者和订阅者通常不直接认识。
它们之间隔着事件中心:
- 发布者把消息发给事件中心
- 订阅者在事件中心登记自己
- 双方通过事件名建立联系,而不是直接引用彼此
可以这么记
| 模式 | 关系特点 |
|---|---|
| 观察者模式 | 目标对象和观察者直接关联 |
| 发布订阅模式 | 发布者和订阅者通过中间层解耦 |
为什么前端里更常提发布订阅
因为前端工程里很多模块本来就不希望彼此直接认识。事件总线、消息中心、组件通信这些场景,更自然地会落到发布订阅模式上。
六、前端里常见的使用场景
1. 跨模块通信
比如一个页面里:
- 搜索模块发出"筛选条件变了"
- 列表模块收到后刷新列表
- 统计模块收到后更新结果数量
如果这些模块互相直接调用,很快就会绕成一团。通过事件中心转一下,结构会干净很多。
2. 组件之间的轻量通信
在一些简单项目里,组件层级不方便直接传值,又不值得上完整状态管理时,会有人用事件总线做临时通信。
不过这类用法要克制,后面会说为什么。
3. 全局通知系统
例如:
- 用户登录成功
- 权限变化
- 主题切换
- 网络状态变化
这些都很适合抽象成事件,再由多个模块各自订阅处理。
4. 自定义事件系统
很多库、很多插件系统,本质上都会提供一套 on / emit / off 接口。说白了,就是把发布订阅模式包了一层更友好的 API。
5. Node.js 里的事件机制
如果你接触过 Node.js 的 EventEmitter,那其实已经碰过发布订阅模式了。很多前端同学是先会用了,后来才知道这背后对应的是这个模式。
七、手写一个简单的事件中心
先写一个最常用版本,支持两个核心能力:
on:订阅事件emit:发布事件
js
class EventBus {
constructor() {
this.events = {}
}
on(eventName, handler) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(handler)
}
emit(eventName, ...args) {
const handlers = this.events[eventName] || []
handlers.forEach((handler) => handler(...args))
}
}
const bus = new EventBus()
bus.on('themeChanged', (theme) => {
console.log('导航栏收到主题:', theme)
})
bus.on('themeChanged', (theme) => {
console.log('内容区收到主题:', theme)
})
bus.emit('themeChanged', 'dark')
输出:
js
导航栏收到主题: dark
内容区收到主题: dark
这个版本已经足够把模式讲清楚了。
它的本质就是维护一个结构:
js
{
themeChanged: [fn1, fn2],
loginSuccess: [fn3, fn4],
}
事件名是 key,处理函数列表是 value。发布时按事件名去找,再把对应的函数挨个执行。
八、再往前走一步:支持取消订阅和只订阅一次
真正放到项目里,只能订阅和发布还不太够,通常还要支持:
- 取消订阅:组件销毁时把监听移除
- 只订阅一次:某些事件只想响应一次
可以把事件中心补完整一点:
js
class EventBus {
constructor() {
this.events = {}
}
on(eventName, handler) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(handler)
}
off(eventName, handler) {
const handlers = this.events[eventName]
if (!handlers) return
this.events[eventName] = handlers.filter((item) => item !== handler)
}
once(eventName, handler) {
const wrapper = (...args) => {
handler(...args)
this.off(eventName, wrapper)
}
this.on(eventName, wrapper)
}
emit(eventName, ...args) {
const handlers = this.events[eventName] || []
handlers.forEach((handler) => handler(...args))
}
}
使用方式:
js
const bus = new EventBus()
function handleLogin(userInfo) {
console.log('登录成功:', userInfo.name)
}
bus.on('loginSuccess', handleLogin)
bus.emit('loginSuccess', { name: 'Alice' })
bus.off('loginSuccess', handleLogin)
bus.emit('loginSuccess', { name: 'Bob' })
bus.once('init', () => {
console.log('只执行一次')
})
bus.emit('init')
bus.emit('init')
这里的 off() 非常重要。很多项目里事件总线最后失控,不是因为模式本身有问题,而是因为订阅之后没人记得清理。
九、发布订阅模式的优点和问题
它的好处
1. 解耦明显 发布者和订阅者不需要直接依赖对方,代码边界会更清楚一些。
2. 扩展更方便 新增一个订阅者,通常不用改发布逻辑;只要去订阅对应事件就行。
3. 很适合一对多通知 一个事件发生后,多个模块同时响应,这正是它擅长的场景。
4. 更容易做成统一机制 很多事件通信、插件系统、消息系统都可以基于这套思路统一封装。
它的问题
1. 事件流向可能越来越隐蔽 表面看模块解耦了,实际排查问题时,你可能得一路追"这个事件到底是谁发的、谁收了、谁又继续发了别的事件"。
2. 事件名容易失控 项目大了以后,如果事件命名没有规范,update、refresh、change 这类名字会满天飞,看到就头大。
3. 容易造成内存泄漏 只订阅不取消,组件销毁后处理函数还挂着,问题就来了。
4. 用多了会让架构变得过于"玄学" 到处都是 emit() 和 on(),短期爽,长期很难追踪,最后代码读起来像在听隔壁房间传话。
十、几个很容易踩的坑
1. 把所有通信都塞给事件总线
事件总线很方便,但方便的东西最容易被滥用。不是所有模块通信都适合发布订阅。
比如父子组件之间本来就该用 props / emit,你硬上全局事件中心,通常是在把简单问题复杂化。
2. 事件名起得太随意
比如下面这些名字就很危险:
updatechangesuccess
问题不是它们不能用,而是语义太模糊。项目稍微一大,你就会忘掉这到底是"谁 update 了谁"。
相对更好一点的是带业务语义的命名:
user:login-successcart:item-addedtheme:changed
3. 订阅之后不清理
这是最经典的问题之一。尤其在组件反复挂载/卸载的场景下,如果没有在合适时机 off(),监听函数会越积越多。
结果就是:
- 同一个事件触发多次回调
- 页面越来越卡
- 排查起来还挺烦
4. 用事件传太多隐式状态
如果一个事件的 payload 越来越大,甚至靠"约定俗成"来传一堆字段,那这套通信方式很快就会变脆。
事件适合传"发生了什么"和"必要数据",不适合变成一个无边界的数据中转站。
5. 本来该用状态管理,却硬靠事件串起来
如果你的项目已经有明显的共享状态中心需求,比如:
- 多页面共享复杂状态
- 多模块需要统一读写同一份数据
- 需要可追踪的数据流
那很多时候状态管理方案会比满天飞的事件更合适。
十一、如果放到面试里,怎么讲比较自然
如果面试官问到发布订阅模式,没必要上来就背定义,先把核心和区别讲清楚就够了。
可以概括成下面这段意思:
发布订阅模式是一种通过中间层做消息通信的设计模式。发布者只负责发布消息,订阅者只负责订阅和处理消息,双方不直接依赖,而是通过事件中心建立联系。它很适合一对多通知和跨模块解耦,前端里常见于事件总线、消息系统、插件机制、自定义事件系统。和观察者模式相比,它最大的特点是中间多了一层 broker,所以发布者和订阅者耦合更低。
如果被追问"它的缺点是什么",可以补一句:
最大的问题不是不能用,而是容易被滥用。事件太多之后,数据流会变得不透明,排查问题成本会上升;另外如果只订阅不取消,也容易引发内存泄漏。
如果被追问"前端里有哪些例子",可以举:
- 事件总线
- Node.js 的
EventEmitter - 自定义消息中心
- 插件系统中的钩子通知
这样基本就够用了。
十二、练习题
练习 1:手写一个最小事件中心
要求:
- 支持
on - 支持
emit
写完之后,试着让两个模块同时订阅同一个事件。
练习 2:给事件中心加上 off
要求:
- 能取消指定事件的指定处理函数
- 取消之后再次发布,不应该再触发该函数
练习 3:给事件中心加上 once
要求:
- 某个订阅函数只触发一次
- 第二次发布同一事件时不再执行
练习 4:判断下面哪些场景适合发布订阅模式
- 父组件给子组件传一个标题
- 登录成功后多个模块同步更新
- 插件系统通知所有插件某个生命周期开始
- 一个图表组件内部更新自己的局部状态
可以先自己判断,再参考下面这个方向:
| 场景 | 是否适合 | 原因 |
|---|---|---|
| 父组件给子组件传标题 | 不太适合 | 直接 props 更自然 |
| 登录成功后多个模块同步更新 | 适合 | 一对多通知、解耦明显 |
| 插件系统通知生命周期开始 | 适合 | 很典型的事件通知场景 |
| 图表组件内部更新局部状态 | 不太适合 | 组件自身逻辑不需要绕事件中心 |
十三、最后做个收束
如果只记一句话,那就是:
发布订阅模式的重点,不是"发消息"这件事本身,而是通过中间层把模块之间的直接依赖拆开。
它特别适合这种场景:
- 一个地方变化了
- 多个地方要响应
- 但这些地方彼此不想直接绑在一起
不过它也不是通信万金油。简单场景下,直接调用、组件通信、状态管理往往更直白;只有当你确实需要"一对多通知 + 解耦"时,发布订阅模式才最顺手。
理解到这里,再去看前端项目里的事件总线、自定义事件、插件钩子、消息系统,就会发现它们背后的思路其实很统一。