简介
发布-订阅模式是一种消息传递模式,它允许发送者(发布者)发送消息而不关心谁将接收这些消息,同样地,接收者(订阅者)可以监听他们感兴趣的消息,而不需要知道这些消息来自哪里。这种模式在JavaScript中常用于实现事件处理系统,如DOM事件监听或自定义事件系统。
特点
-
解耦:发布者和订阅者之间不直接依赖,它们之间通过事件中心(Event Center)进行通信,实现了解耦。
-
一对多:一个事件可以有多个订阅者,当事件被发布时,所有订阅了该事件的订阅者都会收到通知。
-
异步通信:发布者和订阅者之间的通信是异步的,发布者发布事件时不会等待订阅者的处理结果。
-
扩展性好:新的订阅者可以随时订阅事件,旧的订阅者也可以随时取消订阅,无需修改发布者代码。
流程
-
定义事件总线:首先,需要定义一个事件中心对象,该对象用于管理事件和订阅者。
-
订阅事件:订阅者通过调用事件中心的订阅方法,指定要订阅的事件类型和回调函数。
-
发布事件:发布者通过调用事件中心的发布方法,并传递事件类型和相关数据来触发事件。
-
触发回调:事件中心接收到发布请求后,会遍历该事件类型下的所有订阅者,并依次调用它们的回调函数,传递事件数据。
通俗举例
以下将使用消费者
、邮局
、订阅报纸
和 邮递员
来通俗举例解释发布-订阅模式的关系:
角色对应
-
发布者:报纸出版社(负责发布报纸内容)
-
事件:报纸内容(如新闻、广告等)
-
事件总线:邮局(负责传递报纸)
-
订阅者:消费者(订阅报纸的人)
-
邮递员:邮局的工作人员,负责将报纸从邮局传递到消费者手中
运行流程
1. 订阅(Subscribe)
-
消费者(订阅者)到邮局(事件总线)填写订阅单,指定他们感兴趣的报纸(事件类型)。
-
邮局(事件总线)记录消费者的订阅信息,包括消费者的地址和所订阅的报纸类型。
2. 发布(Publish)
-
报纸出版社(发布者)完成当天的报纸编辑,将报纸内容(事件)发送到邮局(事件总线)。
-
邮局(事件总线)接收到报纸后,会检查哪些消费者订阅了这份报纸。
3. 通知(Notify)
-
邮局(事件总线)将报纸(事件)交给邮递员(传递者)。
-
邮递员根据消费者的订阅信息,将报纸投递到消费者的地址(即通知订阅者)。
关键点
-
松耦合:消费者(订阅者)和报纸出版社(发布者)之间不直接通信,而是通过邮局(事件总线)进行间接通信。
-
事件类型:消费者(订阅者)可以订阅不同类型的报纸(如日报、晚报、财经报等),这对应于不同的事件类型。
-
异步性:消费者(订阅者)可以在报纸发布之前或之后订阅,邮局(事件总线)会负责将后续的报纸投递给已经订阅的消费者。
代码实现
// 创建一个事件中心(模拟邮局)
class EventCenter {
constructor() {
this.subscribers = {}; // 订阅者列表,按事件类型存储
}
// 订阅事件(消费者订阅报纸)
subscribe (eventType, callback) {
if (!this.subscribers[eventType]) {
this.subscribers[eventType] = [];
}
this.subscribers[eventType].push(callback);
}
// 取消订阅事件(消费者取消订阅报纸)
unsubscribe(eventType, callback) {
if (this.subscribers[eventType]) {
const index = this.subscribers[eventType].indexOf(callback);
if (index !== -1) {
this.subscribers[eventType].splice(index, 1);
}
}
}
// 发布事件(报纸出版社发布报纸)
publish (eventType, data) {
// 如果有订阅者订阅了这个事件
if (this.subscribers[eventType]) {
// 遍历所有订阅者,并通知他们(传递报纸)
this.subscribers[eventType].forEach(callback => {
callback(data);
});
}
}
// 清除所有订阅者
clearAllSubscriptions () {
this.subscribers = {};
}
}
// 创建一个事件中心实例
const eventCenter = new EventCenter();
// 消费者(订阅者)
function consumerA (newspaper) {
console.log('消费者A收到了报纸:', newspaper);
}
function consumerB (newspaper) {
console.log('消费者B收到了报纸:', newspaper);
}
// 消费者A订阅日报
eventCenter.subscribe('日报', consumerA);
// 消费者B也订阅日报
eventCenter.subscribe('日报', consumerB);
// 报纸出版社(发布者)发布日报
eventCenter.publish('日报', '今天的头条新闻...');
// 如果消费者C稍后订阅,他将不会收到之前发布的日报
function consumerC (newspaper) {
console.log('消费者C收到了报纸:', newspaper);
}
// 消费者C订阅日报(但日报已经发布过了,所以消费者C不会收到这次的报纸)
eventCenter.subscribe('日报', consumerC);
// 如果报纸出版社再次发布日报,消费者C将会收到
eventCenter.publish('日报', '明天的头条预告...');
// 假设消费者A后来决定不再订阅日报
eventCenter.unsubscribe('日报', consumerA);
// 报纸出版社再次发布日报,消费者A将不会收到,但消费者B和消费者C将会收到
eventCenter.publish('日报', '后天的头条预告...');
// 清除所有订阅者
eventCenter.clearAllSubscriptions();
// 尝试发布事件,但此时没有订阅者
eventCenter.publish('日报', '没有订阅者将收到此消息');
Vue 中的发布-订阅模式
简单地说,发布者-订阅者模式的流程就是:监听器监听数据状态变化, 一旦数据发生变化,则会通知对应的订阅者,让订阅者执行对应的业务逻辑 。
首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发生变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图
- 实现一个监听器 Observer ,用来劫持并监听所有属性,如果属性发生变化,就通知订阅者;
- 实现一个订阅器 Dep,用来收集订阅者,对监听器 Observer 和 订阅者 Watcher 进行统一管理;
- 实现一个订阅者 Watcher,可以收到属性的变化通知并执行相应的方法,从而更新视图;
- 实现一个解析器 Compile,可以解析每个节点的相关指令,对模板数据和订阅器进行初始化。
-
监听器(Observer)
-
当Vue实例创建时,Vue会遍历data对象中的所有属性。
-
使用Object.defineProperty()将这些属性转化为getter/setter,从而能够拦截对这些属性的访问和修改。
-
当数据属性被访问时,getter会被调用,Vue会开始收集依赖,并为每个依赖创建一个订阅者Watcher对象。每个Watcher在实例化时,会将自己添加到它所依赖的属性的依赖列表中(由订阅器Dep管理)
-
-
订阅器(Dep)
-
Dep是Vue内部的一个类,用于管理每个数据属性的依赖列表。
-
当数据属性被访问时,相关的Watcher会被添加到Dep的依赖列表中。
-
当数据属性发生变化时,setter会被调用, Dep会通知所有依赖于该属性的Watcher进行更新。
-
-
订阅者(Watcher)
-
Watcher是Vue中的观察者对象,它负责监听数据属性的变化,并在数据变化时触发相应的更新操作。
-
Watcher通常与组件的渲染过程相关联,当组件需要渲染时,会创建一个Watcher来监听相关的数据属性。
-
当数据属性发生变化时,Dep会通知所有依赖于该属性的Watcher进行更新。每个Watcher接收到通知后,会调用其update方法,它会重新计算虚拟DOM,并与之前的虚拟DOM进行比较(diffing算法),以找出需要更新的实际DOM节点。
-
-
解析器(Compile)
-
解析器的作用是将模板解析成抽象语法树(AST)。
-
AST是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
-
在Vue中,解析器会扫描和解析模板中的每个节点,找到其中的指令(如v-model、v-on等)和表达式,并将它们与对应的数据属性进行绑定。
-
当数据属性发生变化时,由于已经通过getter/setter进行了拦截和依赖收集,所以相关的Watcher会被触发,进而触发组件的重新渲染。
-
注意事项
-
内存管理:确保在不再需要某个订阅者时取消订阅,以避免内存泄漏。
-
事件命名:为事件类型选择具有描述性和唯一性的名称,以避免命名冲突。
-
错误处理:在回调函数和事件中心的方法中添加适当的错误处理逻辑,以确保程序的健壮性。
-
避免无限循环:确保在回调函数中不会触发相同的事件,这可能导致无限循环。
-
性能考虑:当订阅者数量较多或事件发布频率较高时,需要考虑性能优化,如使用WeakMap来存储订阅者等。
-
兼容性:在浏览器中实现时,要注意不同浏览器对事件处理的支持情况,确保代码具有良好的兼容性。