大家好,欢迎来到今天的“JavaScript内核与高级编程”讲座!今天我们要聊的是一个听起来很高大上,但其实用起来很接地气的模式:Pub/Sub
(发布/订阅)模式。
咱们先来热热身,想想平时生活中订阅报纸、杂志的场景。你(订阅者)告诉报社(发布者):“我要订阅你的报纸”,然后报社每天就把报纸送到你家。这就是一个典型的发布/订阅模式。
在JavaScript的世界里,Pub/Sub
模式也是类似的。它允许不同的模块或组件之间进行松耦合的通信,也就是说,发布者不需要知道订阅者的存在,订阅者也不需要知道发布者是谁。它们通过一个中间人(通常被称为消息代理或事件总线)来进行通信。
一、为什么要用Pub/Sub?
你可能会问,我们已经有了事件监听器,为什么还需要Pub/Sub
呢?好问题!我们来对比一下:
特性 | 事件监听器 (Event Listeners) | Pub/Sub |
---|---|---|
耦合性 | 紧耦合 | 松耦合 |
直接性 | 直接调用 | 通过消息代理间接调用 |
适用场景 | 同一个对象内的事件处理 | 跨模块、组件的通信 |
灵活性 | 较低 | 较高 |
可维护性 | 较低 | 较高 |
- 紧耦合 vs. 松耦合: 事件监听器通常是针对特定对象的,发布者和订阅者之间存在直接的依赖关系。而
Pub/Sub
模式中,发布者和订阅者之间没有直接的依赖关系,它们只与消息代理打交道。 - 直接调用 vs. 间接调用: 事件监听器是直接调用回调函数,而
Pub/Sub
模式是通过消息代理来触发回调函数。 - 适用场景: 事件监听器适合处理同一个对象内的事件,比如按钮点击事件。
Pub/Sub
模式更适合跨模块、组件的通信,比如一个模块更新了数据,需要通知其他模块进行更新。
总而言之,Pub/Sub
模式可以提高代码的灵活性、可维护性和可扩展性。
二、Pub/Sub模式的实现
接下来,我们来手撸一个简单的Pub/Sub
实现。这个实现的核心就是一个对象,我们称之为EventEmitter
。这个对象负责管理所有的订阅者和发布消息。
class EventEmitter {
constructor() {
this.events = {}; // 存储事件和对应的回调函数
}
// 订阅事件
subscribe(eventName, callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
return { // 返回一个取消订阅的函数
unsubscribe: () => {
this.events[eventName] = this.events[eventName].filter(cb => cb !== callback);
}
};
}
// 发布事件
publish(eventName, data) {
if (this.events[eventName]) {
this.events[eventName].forEach(callback => {
callback(data);
});
}
}
}
// 使用示例
const emitter = new EventEmitter();
// 订阅 'userLoggedIn' 事件
const subscription = emitter.subscribe('userLoggedIn', (userData) => {
console.log('User logged in:', userData);
});
// 发布 'userLoggedIn' 事件
emitter.publish('userLoggedIn', { username: 'JohnDoe', id: 123 });
// 取消订阅
subscription.unsubscribe();
// 再次发布 'userLoggedIn' 事件,这次不会有任何输出
emitter.publish('userLoggedIn', { username: 'JaneDoe', id: 456 });
代码解读:
-
constructor()
: 初始化一个空对象this.events
,用于存储事件和对应的回调函数。this.events
的结构大概是这样的:{ "eventName1": [callback1, callback2, ...], "eventName2": [callback3, callback4, ...] }
-
subscribe(eventName, callback)
: 订阅事件。- 如果
this.events
中没有eventName
对应的数组,就创建一个空数组。 - 将
callback
添加到eventName
对应的数组中。 - 返回一个对象,该对象包含一个
unsubscribe
函数,用于取消订阅。
- 如果
-
publish(eventName, data)
: 发布事件。- 如果
this.events
中存在eventName
对应的数组,就遍历该数组,并依次调用数组中的回调函数,并将data
作为参数传递给回调函数。
- 如果
使用示例:
- 我们创建了一个
EventEmitter
实例emitter
。 - 我们使用
emitter.subscribe()
订阅了'userLoggedIn'
事件,并传入了一个回调函数。 - 我们使用
emitter.publish()
发布了'userLoggedIn'
事件,并传入了一个包含用户信息的数据对象。 - 回调函数会被执行,并在控制台输出用户信息。
- 我们使用
subscription.unsubscribe()
取消了订阅。 - 再次发布
'userLoggedIn'
事件,这次不会有任何输出,因为我们已经取消了订阅。
三、Pub/Sub模式的进阶用法
上面的例子只是一个最简单的Pub/Sub
实现。在实际开发中,我们可能需要更高级的功能,比如:
- 命名空间: 将事件划分为不同的命名空间,方便管理和组织。
- 优先级: 为不同的订阅者设置优先级,确保重要的订阅者能够优先接收到消息。
- 异步处理: 使用
Promise
或async/await
处理异步回调函数。 - 错误处理: 捕获和处理回调函数中的错误。
- 持久化: 将消息持久化存储,防止消息丢失。
下面我们来简单实现一个带命名空间的Pub/Sub
。
class EventEmitterWithNamespace {
constructor() {
this.events = {};
}
// 订阅事件,支持命名空间
subscribe(eventName, callback, namespace = 'default') {
const namespacedEventName = `${namespace}:${eventName}`;
if (!this.events[namespacedEventName]) {
this.events[namespacedEventName] = [];
}
this.events[namespacedEventName].push(callback);
return {
unsubscribe: () => {
this.events[namespacedEventName] = this.events[namespacedEventName].filter(cb => cb !== callback);
}
};
}
// 发布事件,支持命名空间
publish(eventName, data, namespace = 'default') {
const namespacedEventName = `${namespace}:${eventName}`;
if (this.events[namespacedEventName]) {
this.events[namespacedEventName].forEach(callback => {
callback(data);
});
}
}
}
// 使用示例
const emitterWithNamespace = new EventEmitterWithNamespace();
// 订阅 'dataUpdated' 事件,命名空间为 'moduleA'
const subscriptionA = emitterWithNamespace.subscribe('dataUpdated', (data) => {
console.log('Module A received data:', data);
}, 'moduleA');
// 订阅 'dataUpdated' 事件,命名空间为 'moduleB'
const subscriptionB = emitterWithNamespace.subscribe('dataUpdated', (data) => {
console.log('Module B received data:', data);
}, 'moduleB');
// 发布 'dataUpdated' 事件,命名空间为 'moduleA'
emitterWithNamespace.publish('dataUpdated', { message: 'Hello from Module A' }, 'moduleA');
// 发布 'dataUpdated' 事件,命名空间为 'moduleB'
emitterWithNamespace.publish('dataUpdated', { message: 'Hello from Module B' }, 'moduleB');
// 发布 'dataUpdated' 事件,默认命名空间
emitterWithNamespace.publish('dataUpdated', { message: 'Hello from Default' }); // 不会触发任何回调
代码解读:
-
subscribe(eventName, callback, namespace = 'default')
: 订阅事件,增加了namespace
参数,默认为'default'
。- 将
eventName
和namespace
拼接成一个带有命名空间的事件名namespacedEventName
,例如'moduleA:dataUpdated'
。 - 后续的逻辑与之前的
subscribe()
函数类似,只是操作的是带有命名空间的事件名。
- 将
-
publish(eventName, data, namespace = 'default')
: 发布事件,增加了namespace
参数,默认为'default'
。- 将
eventName
和namespace
拼接成一个带有命名空间的事件名namespacedEventName
。 - 后续的逻辑与之前的
publish()
函数类似,只是操作的是带有命名空间的事件名。
- 将
使用示例:
- 我们创建了一个
EventEmitterWithNamespace
实例emitterWithNamespace
。 - 我们使用
emitterWithNamespace.subscribe()
订阅了'dataUpdated'
事件,分别指定了命名空间'moduleA'
和'moduleB'
。 - 我们使用
emitterWithNamespace.publish()
发布了'dataUpdated'
事件,分别指定了命名空间'moduleA'
和'moduleB'
。 - 只有对应命名空间的回调函数会被执行。
- 我们尝试发布一个默认命名空间的
'dataUpdated'
事件,但由于没有订阅者订阅默认命名空间的'dataUpdated'
事件,因此不会触发任何回调。
四、Pub/Sub模式的应用场景
Pub/Sub
模式在实际开发中有很多应用场景,比如:
- UI组件通信: 当一个UI组件的状态发生变化时,通知其他UI组件进行更新。例如,一个购物车组件更新了商品数量,需要通知订单总价组件进行更新。
- 模块解耦: 将不同的模块解耦,使它们可以独立开发和测试。例如,一个数据模块负责从服务器获取数据,然后通过
Pub/Sub
模式通知其他模块进行渲染。 - 事件驱动架构: 构建事件驱动的应用程序,使应用程序可以对事件做出响应。例如,一个用户注册事件发生后,可以触发一系列的后续操作,比如发送欢迎邮件、创建用户资料等等。
- 消息队列: 消息队列本质上也是一种
Pub/Sub
模式,它可以用于异步处理任务、削峰填谷等等。
五、Pub/Sub模式的优缺点
任何设计模式都有其优缺点,Pub/Sub
模式也不例外。
优点:
- 松耦合: 发布者和订阅者之间没有直接的依赖关系,可以独立开发和测试。
- 灵活性: 可以动态地添加和删除订阅者,而不需要修改发布者的代码。
- 可扩展性: 可以轻松地扩展应用程序的功能,只需要添加新的订阅者即可。
缺点:
- 难以调试: 由于发布者和订阅者之间是间接通信,因此调试起来比较困难。
- 消息丢失: 如果没有持久化机制,可能会出现消息丢失的情况。
- 性能问题: 如果订阅者过多,可能会影响应用程序的性能。
六、第三方Pub/Sub库
除了自己手撸Pub/Sub
实现之外,我们还可以使用一些第三方的Pub/Sub
库,比如:
- postal.js: 一个轻量级的
Pub/Sub
库,提供了丰富的功能,比如命名空间、优先级、消息拦截等等。 - EventEmitter3: 一个高性能的
EventEmitter
实现,可以作为Pub/Sub
的基础。 - RxJS: 一个强大的响应式编程库,也提供了
Pub/Sub
的功能。
选择哪个库取决于你的具体需求和项目规模。
七、总结
今天我们学习了Pub/Sub
模式的基本概念、实现方法、应用场景和优缺点。Pub/Sub
模式是一种非常有用的设计模式,可以帮助我们构建松耦合、灵活和可扩展的应用程序。希望今天的讲座能对你有所帮助!
下次再见!