美团一面:你了解发布-订阅模式吗?「每天搞透一道JS手写题💪Day8」
笔者在前些天的美团一面中被问到 vuex
的实现原理,被追问到发布订阅模式,并提及到 vue
的响应式原理就是数据劫持结合发布订阅模式实现的,今天就来强化一遍这个知识点。
什么是发布订阅模式
在软件架构中,发布/订阅(Publish–subscribe pattern)是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者)。而是将发布的消息分为不同的类别,无需了解哪些订阅者(如果有的话)可能存在。同样的,订阅者可以表达对一个或多个类别的兴趣,只接收感兴趣的消息,无需了解哪些发布者(如果有的话)存在。
与观察者模式的区别
- 实现方式:在观察者模式中,观察者(Observer)通常会直接订阅(Subscribe)主题(Subject)的更新,而主题则会在状态改变时直接调用观察者的方法。而在发布订阅模式中,发布者(Publisher)和订阅者(Subscriber)通常不会直接交互,而是通过一个调度中心(Message broker 或 Event bus)来进行通信。
- 耦合性:观察者模式中的观察者和主题之间的耦合性相对较高,因为观察者需要直接订阅主题。而在发布订阅模式中,由于引入了调度中心,发布者和订阅者之间的耦合性较低。
- 使用场景:观察者模式通常用于处理较为简单的一对多依赖关系,例如GUI中的事件处理等。而发布订阅模式则更适合处理复杂的异步处理和跨系统通信等场景,例如消息队列、事件驱动架构等。
JS实现发布订阅模式
接下来我们结合它的功能,一步一步来手撸一个发布订阅模式:
🐾第一步: 实现发布订阅模式的第一步是创建一个可以存储事件及其对应回调函数的容器。在JavaScript中,我们可以使用一个对象来作为这个容器。每个事件都是对象的一个属性,其值是一个数组,用来存储所有订阅了该事件的回调函数。
class PubSub {
constructor() {
this.events = {};
}
}
🐾第二步:
第二步是添加一个名为subscribe
的方法,该方法允许监听器订阅特定的事件。这个方法需要两个参数:一个是事件名,另一个是当事件被触发时应该调用的回调函数。
class PubSub {
constructor() {
this.events = {};
}
subscribe(event, callback) {
// 检查`events`对象是否已经有了对应的事件属性,如没有则新建该时间属性并赋值为一个空数组
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
}
🐾第三步:
第三步是实现取消订阅的功能:添加一个名为unsubscribe
的方法来实现这个功能。这个方法需要两个参数:一个是事件名,另一个是要取消订阅的回调函数。
class PubSub {
constructor() {
this.events = {};
}
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
unsubscribe(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
}
🐾第四步:
第四步是实现事件发布的功能,我们可以添加一个名为publish
的方法来实现这个功能。这个方法需要两个参数:一个是事件名,另一个是当事件被触发时应该传递给回调函数的数据。
class PubSub {
constructor() {
this.events = {};
}
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
unsubscribe(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
publish(event, data) {
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
}
🐾使用示例: 至此我们已经完成了一个基本的发布订阅模式,下面展示一下它的具体使用:
// 创建一个新的PubSub实例
const pubsub = new PubSub();
// 定义两个回调函数
function callback1(data) {
console.log('这里是第一个回调: ' + data);
}
function callback2(data) {
console.log('这里是第二个回调: ' + data);
}
// 订阅一个事件
pubsub.subscribe('myEvent', callback1);
pubsub.subscribe('myEvent', callback2);
// 输出两个回调函数的 console
pubsub.publish('myEvent', 'Hello, world!');
// 取消订阅 callback1
pubsub.unsubscribe('myEvent', callback1);
// callback1 的订阅被取消了,仅打印 callback2 的 console
pubsub.publish('myEvent', 'Hello, world!');
错误处理与功能优化
上面的简单实现已经足够我们理解发布订阅模式,并在面试中输出相关的知识了,但在学习中我们可以做的更加完美一点。
首先是类型判断与错误处理,我们应当检查参数的类型:
class PubSub {
constructor() {
this.events = {};
}
subscribe(event, callback) {
if (typeof event !== 'string') {
throw new Error('Event name must be a string');
}
if (typeof callback !== 'function') {
throw new Error('Callback must be a function');
}
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
unsubscribe(event, callback) {
if (typeof event !== 'string') {
throw new Error('Event name must be a string');
}
if (typeof callback !== 'function') {
throw new Error('Callback must be a function');
}
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
publish(event, data) {
if (typeof event !== 'string') {
throw new Error('Event name must be a string');
}
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
}
}
在取消订阅和发布方法中传入未定义的事件,笔者这里直接return了,实际开发中可以根据需要打印或者throw一个错误方便开发者定位问题。
其次,我们可以做一些功能上的优化让这个 eventBus 更加好用:
- 开发中我们会遇到一些一次性事件,不会被触发第二次了,我们可以加一个参数来省去手动清除事件的负担:
publish(event, data, once=false) {
if (typeof event !== 'string') {
throw new Error('Event name must be a string');
}
if (!this.events[event]) return;
this.events[event].forEach(callback => callback(data));
if(once) delete this.events[event];
}
- 在学习相关知识的时候笔者发现有些文章中提及到:如果一个回调函数在被调用时订阅了相同的事件,可能会导致无限循环。这是因为
publish
方法会立即调用所有的回调函数,而这些回调函数可能会改变监听器列表。 上面的代码并没有考虑这个问题,但笔者测试后发现并不会发生无限循环的情况,这是什么原因呢?问题出在for
和forEach
中,forEach
方法在开始循环时就已经确定了循环的次数,所以,即使在回调函数中添加或删除了元素,也不会影响forEach
的循环次数;而for
循环会实时检查数组的长度,故而会出现上述的情况。
转载自:https://juejin.cn/post/7283682738498306085