前言
发布-订阅模式(Pub-Sub),又叫观察者模式(Observer Pattern),是前端开发中非常经典且实用的设计模式之一。它能帮助我们实现模块之间的松耦合通信,提升代码的可维护性和扩展性。
今天,我们就来深入浅出地聊聊这个模式,让你快速掌握它的原理与实战用法!🚀
🧠 什么是发布-订阅模式?
想象一下你订阅了一个微信公众号 📱:
- 你(订阅者)关注了这个公众号(主题/事件)。
- 当公众号有新文章发布时(发布事件),所有订阅它的用户都会收到推送通知。
- 你不需要主动去查有没有更新,而是“被动接收”通知。
这就是发布-订阅模式的核心思想:
- 发布者(Publisher):负责发出消息(事件)。
- 订阅者(Subscriber):提前注册自己感兴趣的事件,等待通知。
- 事件中心(Event Bus / 中介者):管理所有事件的订阅和发布,是两者之间的桥梁。
✅ 为什么使用发布-订阅模式?
- 解耦组件:A组件不需要知道B组件的存在,只需通过事件通信。
- 提高灵活性:可以动态添加或移除监听者。
- 适用于跨层级通信:比如父子组件、兄弟组件甚至全局状态通知。
- 便于测试与维护:逻辑分离,职责清晰。
💻 手写一个简单的发布-订阅中心
下面我们用 JavaScript 实现一个轻量级的 EventEmitter 类,支持:
on:订阅事件emit:发布事件off:取消订阅once:只监听一次
// 📦 事件中心类
class EventEmitter {
constructor() {
// 存储所有事件及其回调函数
// 结构:{ eventName: [callback1, callback2] }
this.events = {};
}
/**
* 📥 订阅事件
* @param {string} eventName - 事件名称
* @param {function} callback - 回调函数
*/
on(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
console.log(`✅ 已订阅事件: "${eventName}"`);
}
/**
* 🔔 发布事件(触发所有回调)
* @param {string} eventName - 事件名称
* @param {...any} args - 传递给回调函数的参数
*/
emit(eventName, ...args) {
const callbacks = this.events[eventName];
if (callbacks && callbacks.length > 0) {
console.log(`🔔 正在发布事件: "${eventName}",共 ${callbacks.length} 个监听者`);
callbacks.forEach(callback => {
try {
callback(...args);
} catch (error) {
console.error(`❌ 回调执行出错:`, error);
}
});
} else {
console.warn(`⚠️ 没有监听者订阅事件: "${eventName}"`);
}
}
/**
* 🚫 取消订阅事件
* @param {string} eventName - 事件名称
* @param {function} callback - 要移除的回调函数(可选:不传则移除所有)
*/
off(eventName, callback) {
const callbacks = this.events[eventName];
if (!callbacks) return;
if (!callback) {
// 如果没传具体回调,清空该事件所有监听
delete this.events[eventName];
console.log(`🗑️ 已清除所有 "${eventName}" 的订阅`);
} else {
// 否则只移除指定回调
this.events[eventName] = callbacks.filter(cb => cb !== callback);
console.log(`➖ 已移除 "${eventName}" 的一个订阅`);
}
}
/**
* 🔔 只监听一次事件
* @param {string} eventName - 事件名称
* @param {function} callback - 回调函数
*/
once(eventName, callback) {
// 包装一个只执行一次的函数
const onceWrapper = (...args) => {
callback(...args); // 执行原回调
this.off(eventName, onceWrapper); // 执行后自动取消订阅
};
this.on(eventName, onceWrapper);
console.log(`🎯 已设置一次性订阅: "${eventName}"`);
}
}
🧪 实战演示:模拟用户登录通知系统
我们来模拟一个简单的“用户登录”场景:
- 用户登录成功后,发布
user:login事件。 - 多个模块(如:更新头像、加载用户数据、发送欢迎消息)订阅该事件并做出响应。
// 创建一个全局事件中心实例 🌐
const eventBus = new EventEmitter();
// 👤 模块1:更新用户头像
eventBus.on('user:login', (userInfo) => {
console.log(`🖼️ 更新头像: ${userInfo.avatar}`);
});
// 📊 模块2:加载用户数据面板
eventBus.on('user:login', (userInfo) => {
console.log(`📊 加载用户数据: ID=${userInfo.id}, 名称=${userInfo.name}`);
});
// 💬 模块3:发送欢迎消息(只欢迎一次)
eventBus.once('user:login', (userInfo) => {
console.log(`🎉 欢迎你,${userInfo.name}!这是你的首次登录问候~`);
});
// 🎁 模块4:加载个性化推荐
const loadRecommendations = (userInfo) => {
console.log(`🎁 推荐内容已加载,基于用户兴趣: ${userInfo.interests.join(', ')}`);
};
eventBus.on('user:login', loadRecommendations);
// 🚪 模拟用户登录
console.log("\n🚀 模拟用户登录...");
eventBus.emit('user:login', {
id: 1001,
name: 'Alice',
avatar: 'https://example.com/avatar.png',
interests: ['编程', '设计', '旅行']
});
// 再次登录(验证 once 是否只执行一次)
console.log("\n🔁 再次登录...");
eventBus.emit('user:login', {
id: 1001,
name: 'Alice',
avatar: 'https://example.com/avatar.png',
interests: ['编程', '设计', '旅行']
});
// ❌ 移除某个订阅
console.log("\n🚫 移除推荐模块的监听...");
eventBus.off('user:login', loadRecommendations);
// 再次登录,看是否还触发推荐
console.log("\n🔄 再次登录(移除后)...");
eventBus.emit('user:login', {
id: 1001,
name: 'Alice',
avatar: 'https://example.com/avatar.png'
});
🔍 代码解析:用户登录通知系统的运作流程
我们通过一个完整的“用户登录”案例,来深入剖析 发布-订阅模式 在实际开发中的应用逻辑。下面逐段解析代码执行过程和设计思想。
🌐 1. 创建全局事件中心(Event Bus)
const eventBus = new EventEmitter();
✅ 解析:
eventBus 是一个全局的“事件调度中心”,所有模块都通过它来订阅或发布消息。
它就像一个“广播站”,谁想听消息就来注册,谁有消息就来这里喊一声。
💡 好处:各个模块之间互不依赖,完全解耦。比如“更新头像”模块不需要知道“欢迎消息”是否存在。
📥 2. 多个模块订阅 user:login 事件
👤 模块1:更新头像
eventBus.on('user:login', (userInfo) => {
console.log(`🖼️ 更新头像: ${userInfo.avatar}`);
});
📊 模块2:加载用户数据
eventBus.on('user:login', (userInfo) => {
console.log(`📊 加载用户数据: ID=${userInfo.id}, 名称=${userInfo.name}`);
});
💬 模块3:发送欢迎消息(仅一次)
eventBus.once('user:login', (userInfo) => {
console.log(`🎉 欢迎你,${userInfo.name}!这是你的首次登录问候~`);
});
🎁 模块4:加载推荐内容
const loadRecommendations = (userInfo) => {
console.log(`🎁 推荐内容已加载,基于用户兴趣: ${userInfo.interests.join(', ')}`);
};
eventBus.on('user:login', loadRecommendations);
✅ 解析:
- 所有模块都对
user:login这个事件感兴趣。 - 使用
on()表示“持续监听”,只要事件触发就执行。 - 使用
once()表示“只响应一次”,适合做初始化提示、埋点上报等场景。 - 第4个模块将函数单独定义再传入,便于后续通过
off()精准移除。
🧠 关键点:这些模块可以分布在项目的不同文件中,甚至由不同团队维护,只要它们都连接到同一个
eventBus,就能实现通信。
🔔 3. 模拟用户登录 —— 发布事件
console.log("\n🚀 模拟用户登录...");
eventBus.emit('user:login', {
id: 1001,
name: 'Alice',
avatar: 'https://example.com/avatar.png',
interests: ['编程', '设计', '旅行']
});
✅ 解析:
- 当用户成功登录后,系统调用
emit('user:login', userInfo)发布事件。 emit方法会查找所有订阅了user:login的回调函数,并依次执行。- 传递的
userInfo对象作为参数传给每个监听者,实现数据共享。
🔧 内部机制回顾:
this.events['user:login'].forEach(callback => callback(userInfo));
这就是“广播”的本质:遍历数组,逐一通知。
🔁 4. 再次登录 —— 验证 once 的行为
console.log("\n🔁 再次登录...");
eventBus.emit('user:login', { /* 同上 */ });
✅ 解析:
- 此时
once绑定的欢迎消息不会再执行。 - 因为在
onceWrapper执行完后,已自动调用off()将自己从事件列表中移除。 - 其他
on订阅的模块仍然正常响应。
✅ 这体现了
once的“一次性”特性,非常适合用于首次加载提示、新手引导等场景。
🚫 5. 移除订阅 —— 动态控制监听器
eventBus.off('user:login', loadRecommendations);
✅ 解析:
- 我们显式地移除了“加载推荐”这个监听函数。
- 关键在于:必须传入同一个函数引用才能正确匹配并删除。
- 如果是匿名函数(如
(userInfo) => { ... }),则无法单独移除,只能清空整个事件。
⚠️ 重要提醒:在 Vue/React 组件中,如果在
mounted或useEffect中订阅事件,一定要在beforeDestroy或返回的清理函数中off,否则可能导致内存泄漏或重复触发!
🔄 6. 最终登录测试 —— 验证移除效果
eventBus.emit('user:login', { /* ... */ });
✅ 解析:
- 此时事件中心只有 3 个监听者(欢迎消息已因
once失效,推荐模块已被手动移除)。 - 输出中不再出现
🎁 推荐内容...,说明off成功生效。
📌 一句话总结:
发布-订阅模式就像“微信群发”,有人发消息(发布),群里所有人(订阅者)都能收到,而发消息的人不需要知道群里都有谁。
掌握它,你的前端架构会更加灵活、可维护、可扩展!🚀
✅ 输出结果:
✅ 已订阅事件: "user:login"
✅ 已订阅事件: "user:login"
🎯 已设置一次性订阅: "user:login"
✅ 已订阅事件: "user:login"
🚀 模拟用户登录...
🔔 正在发布事件: "user:login",共 4 个监听者
🖼️ 更新头像: https://example.com/avatar.png
📊 加载用户数据: ID=1001, 名称=Alice
🎉 欢迎你,Alice!这是你的首次登录问候~
🎁 推荐内容已加载,基于用户兴趣: 编程, 设计, 旅行
🔁 再次登录...
🔔 正在发布事件: "user:login",共 4 个监听者
🖼️ 更新头像: https://example.com/avatar.png
📊 加载用户数据: ID=1001, 名称=Alice
🎁 推荐内容已加载,基于用户兴趣: 编程, 设计, 旅行
🚫 移除推荐模块的监听...
➖ 已移除 "user:login" 的一个订阅
🔄 再次登录(移除后)...
🔔 正在发布事件: "user:login",共 3 个监听者
🖼️ 更新头像: https://example.com/avatar.png
📊 加载用户数据: ID=1001, 名称=Alice
🔍 注意:
once的欢迎消息只出现了一次,off后推荐模块不再触发。
🌟 实际应用场景
| 场景 | 说明 |
|---|---|
| 🔔 组件通信 | Vue/React 中的 EventBus 或自定义事件总线 |
| 🔄 状态管理 | Redux、Vuex 内部也用到了类似机制 |
| 📡 WebSocket 消息处理 | 收到消息后通知多个模块 |
| ⏱️ 定时任务通知 | 定时器触发后通知监听者 |
| 🧩 插件系统 | 插件监听核心系统的事件进行扩展 |
⚠️ 使用注意事项
- 避免内存泄漏:记得在组件销毁时
off取消订阅,尤其是 DOM 相关的。 - 事件命名规范:建议使用命名空间,如
user:login、order:created,避免冲突。 - 不要滥用:过度使用会导致事件流难以追踪,调试困难。建议配合状态管理工具使用。
- 错误处理:每个回调最好加
try-catch,防止一个错误影响其他监听者。
🧰 扩展:支持命名空间和通配符(进阶)
你可以进一步扩展 EventEmitter,支持:
user.*:通配符匹配所有用户相关事件priority:优先级控制async/await:异步事件处理
这些在大型项目中非常有用,比如使用
wildcard-eventemitter或mitt等库。
🏁 总结
发布-订阅模式是 JavaScript 中实现松耦合通信的利器 🔧:
- ✅ 解耦模块,提升可维护性
- ✅ 易于扩展和测试
- ✅ 适合跨层级通信
通过手写一个 EventEmitter,我们不仅理解了其内部机制,还能在实际项目中灵活运用。
💡 记住:好的架构不是一开始就设计出来的,而是在不断重构中演进的。掌握发布-订阅模式,是迈向高级前端的重要一步!
📚 推荐阅读
- 📖 《JavaScript设计模式》—— Addy Osmani
- 📦 npm 库:mitt(超轻量事件总线)
- 📘 MDN 文档:CustomEvent
💬 思考题:
如果多个订阅者依赖同一个事件,但执行顺序很重要,你怎么保证顺序?欢迎留言讨论!👇
解答:
为了保证多个订阅者在处理同一个事件时能够按照特定顺序执行,可以通过给每个订阅的回调函数分配一个优先级来实现。以下是简化的实现思路:
实现步骤
-
修改
EventEmitter类:为每个事件的监听器添加一个优先级属性,默认情况下可以设置为中等优先级(例如 0)。当添加监听器时,允许指定优先级。 -
调整监听器存储结构:将监听器按照优先级排序存储,确保触发事件时按正确的顺序执行。
-
触发事件时排序执行:当发布事件时,根据监听器的优先级对它们进行排序,然后依次调用。
示例代码
这里给出一个简化版的实现,展示了如何基于优先级来管理监听器的执行顺序:
class EventEmitter {
constructor() {
this.events = {};
}
on(eventName, callback, priority = 0) { // 添加优先级参数,默认为0
if (!this.events[eventName]) {
this.events[eventName] = [];
}
// 插入时根据优先级插入到正确位置
const newListener = {callback, priority};
const added = false;
for (let i = 0; i < this.events[eventName].length; i++) {
if (this.events[eventName][i].priority > priority) {
this.events[eventName].splice(i, 0, newListener);
added = true;
break;
}
}
if (!added) {
this.events[eventName].push(newListener);
}
}
emit(eventName, ...args) {
if (this.events[eventName]) {
this.events[eventName].forEach(listener => listener.callback(...args));
}
}
off(eventName, callback) {
if (this.events[eventName]) {
this.events[eventName] = this.events[eventName].filter(l => l.callback !== callback);
}
}
}
// 使用例子
const eventBus = new EventEmitter();
eventBus.on('user:login', userInfo => console.log(`低优先级任务: ${userInfo.name}`), -1);
eventBus.on('user:login', userInfo => console.log(`高优先级任务: ${userInfo.name}`), 1);
eventBus.emit('user:login', {name: 'Alice'});
关键点
- 在注册监听器时通过
priority参数指定其执行优先级。 - 监听器列表根据优先级有序插入,这样在事件触发时就能保证按照预定顺序执行。
- 默认情况下,所有监听器具有相同的优先级(如上面的例子中的
0),因此除非特别指定了优先级,否则它们会按照注册的顺序执行。
这种方法简单直接,适用于需要控制监听器执行顺序的场景。
📌 点赞 + 收藏 = 学会一半!
Happy Coding!💻✨