发布订阅模式渐进式学习指南

发布订阅模式渐进式学习指南

写给前端开发者的一篇入门笔记:先把发布订阅模式的核心想清楚,再看它在项目里为什么这么常见,以及什么时候该用、什么时候别硬用。


目录

  1. 什么是发布订阅模式?
  2. 它到底解决了什么问题?
  3. 先从一个最小例子看懂它
  4. 发布者、订阅者、中间层分别是什么
  5. 它和观察者模式到底有什么区别
  6. 前端里常见的使用场景
  7. 手写一个简单的事件中心
  8. 再往前走一步:支持取消订阅和只订阅一次
  9. 发布订阅模式的优点和问题
  10. 几个很容易踩的坑
  11. 如果放到面试里,怎么讲比较自然
  12. 练习题
  13. 最后做个收束

一、什么是发布订阅模式?

发布订阅模式(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. 中间层

中间层是发布订阅模式里最关键的一层。它负责:

  • 保存事件和处理函数的映射关系
  • 在消息发布时找到对应的订阅者
  • 调用这些订阅者的处理函数

这层在前端里常见的名字有:

  • eventBus
  • eventEmitter
  • messageCenter
  • pubsub

没有这层,也就谈不上典型的发布订阅模式。


五、它和观察者模式到底有什么区别

这是最容易被混淆的一点。

很多文章会把观察者模式和发布订阅模式混着讲,实际开发里问题不一定大,但如果你要真正讲清楚,最好还是分开。

一个直观结论

观察者模式更像对象之间直接建立联系;发布订阅模式中间多了一层事件中心。

观察者模式更像什么

观察者模式里,目标对象通常直接维护观察者列表。

也就是说:

  • 被观察者知道有哪些观察者
  • 观察者也通常和被观察者存在直接关系

发布订阅模式更像什么

发布订阅模式里,发布者和订阅者通常不直接认识。

它们之间隔着事件中心:

  • 发布者把消息发给事件中心
  • 订阅者在事件中心登记自己
  • 双方通过事件名建立联系,而不是直接引用彼此

可以这么记

模式 关系特点
观察者模式 目标对象和观察者直接关联
发布订阅模式 发布者和订阅者通过中间层解耦

为什么前端里更常提发布订阅

因为前端工程里很多模块本来就不希望彼此直接认识。事件总线、消息中心、组件通信这些场景,更自然地会落到发布订阅模式上。


六、前端里常见的使用场景

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. 事件名容易失控 项目大了以后,如果事件命名没有规范,updaterefreshchange 这类名字会满天飞,看到就头大。

3. 容易造成内存泄漏 只订阅不取消,组件销毁后处理函数还挂着,问题就来了。

4. 用多了会让架构变得过于"玄学" 到处都是 emit()on(),短期爽,长期很难追踪,最后代码读起来像在听隔壁房间传话。


十、几个很容易踩的坑

1. 把所有通信都塞给事件总线

事件总线很方便,但方便的东西最容易被滥用。不是所有模块通信都适合发布订阅。

比如父子组件之间本来就该用 props / emit,你硬上全局事件中心,通常是在把简单问题复杂化。

2. 事件名起得太随意

比如下面这些名字就很危险:

  • update
  • change
  • success

问题不是它们不能用,而是语义太模糊。项目稍微一大,你就会忘掉这到底是"谁 update 了谁"。

相对更好一点的是带业务语义的命名:

  • user:login-success
  • cart:item-added
  • theme:changed

3. 订阅之后不清理

这是最经典的问题之一。尤其在组件反复挂载/卸载的场景下,如果没有在合适时机 off(),监听函数会越积越多。

结果就是:

  • 同一个事件触发多次回调
  • 页面越来越卡
  • 排查起来还挺烦

4. 用事件传太多隐式状态

如果一个事件的 payload 越来越大,甚至靠"约定俗成"来传一堆字段,那这套通信方式很快就会变脆。

事件适合传"发生了什么"和"必要数据",不适合变成一个无边界的数据中转站。

5. 本来该用状态管理,却硬靠事件串起来

如果你的项目已经有明显的共享状态中心需求,比如:

  • 多页面共享复杂状态
  • 多模块需要统一读写同一份数据
  • 需要可追踪的数据流

那很多时候状态管理方案会比满天飞的事件更合适。


十一、如果放到面试里,怎么讲比较自然

如果面试官问到发布订阅模式,没必要上来就背定义,先把核心和区别讲清楚就够了。

可以概括成下面这段意思:

发布订阅模式是一种通过中间层做消息通信的设计模式。发布者只负责发布消息,订阅者只负责订阅和处理消息,双方不直接依赖,而是通过事件中心建立联系。它很适合一对多通知和跨模块解耦,前端里常见于事件总线、消息系统、插件机制、自定义事件系统。和观察者模式相比,它最大的特点是中间多了一层 broker,所以发布者和订阅者耦合更低。

如果被追问"它的缺点是什么",可以补一句:

最大的问题不是不能用,而是容易被滥用。事件太多之后,数据流会变得不透明,排查问题成本会上升;另外如果只订阅不取消,也容易引发内存泄漏。

如果被追问"前端里有哪些例子",可以举:

  • 事件总线
  • Node.js 的 EventEmitter
  • 自定义消息中心
  • 插件系统中的钩子通知

这样基本就够用了。


十二、练习题

练习 1:手写一个最小事件中心

要求:

  • 支持 on
  • 支持 emit

写完之后,试着让两个模块同时订阅同一个事件。

练习 2:给事件中心加上 off

要求:

  • 能取消指定事件的指定处理函数
  • 取消之后再次发布,不应该再触发该函数

练习 3:给事件中心加上 once

要求:

  • 某个订阅函数只触发一次
  • 第二次发布同一事件时不再执行

练习 4:判断下面哪些场景适合发布订阅模式

  • 父组件给子组件传一个标题
  • 登录成功后多个模块同步更新
  • 插件系统通知所有插件某个生命周期开始
  • 一个图表组件内部更新自己的局部状态

可以先自己判断,再参考下面这个方向:

场景 是否适合 原因
父组件给子组件传标题 不太适合 直接 props 更自然
登录成功后多个模块同步更新 适合 一对多通知、解耦明显
插件系统通知生命周期开始 适合 很典型的事件通知场景
图表组件内部更新局部状态 不太适合 组件自身逻辑不需要绕事件中心

十三、最后做个收束

如果只记一句话,那就是:

发布订阅模式的重点,不是"发消息"这件事本身,而是通过中间层把模块之间的直接依赖拆开。

它特别适合这种场景:

  • 一个地方变化了
  • 多个地方要响应
  • 但这些地方彼此不想直接绑在一起

不过它也不是通信万金油。简单场景下,直接调用、组件通信、状态管理往往更直白;只有当你确实需要"一对多通知 + 解耦"时,发布订阅模式才最顺手。

理解到这里,再去看前端项目里的事件总线、自定义事件、插件钩子、消息系统,就会发现它们背后的思路其实很统一。

相关推荐
菠萝地亚狂想曲2 小时前
Zephyr_01, environment
android·java·javascript
蜡台3 小时前
vue params传参刷新网页数据丢失解决方法
前端·javascript·vue.js
时寒的笔记4 小时前
js逆向_webpack讲解加载器&酷某音乐案例
开发语言·javascript·webpack
yusirxiaer4 小时前
为什么 markRaw 能修复 Vue 3 + ECharts 的 resize 报错
javascript·vue.js·echarts
赛博切图仔5 小时前
前端性能内卷终点?Signals 正在重塑我们的开发习惯
前端·javascript·vue.js
Highcharts.js5 小时前
抉择之巅:从2029年回望2026年——企业可视化“战略分水岭”?
前端·javascript·信息可视化·编辑器·echarts·highcharts
米丘6 小时前
Vite 开发服务器启动时,如何将 client 注入 HTML?
javascript·node.js·vite
军军君016 小时前
数字孪生监控大屏实战模板:空气污染监控
前端·javascript·vue.js·typescript·前端框架·echarts·数字孪生
米丘6 小时前
vite 插件 @vitejs/plugin-vue
javascript·node.js·vite