情景导入:什么是观察者/发布订阅模式?
想象你正在开发一个电商网站,页面上显示了多个商品的库存数量。当用户购买商品时,库存数量需要立即更新显示。你可以通过简单的刷新页面来解决这个问题,但显然这不是一个用户友好的方案。我们希望能够在用户购买商品时,实时更新页面上的库存信息。
假如没有设计模式
对于这个案例,一种直接的解决方法是每个商品对象都拥有一个方法,用于更新库存,并且在库存变化时直接更新页面:
class Product {
constructor(name, stock) {
this.name = name; // 商品名称
this.stock = stock; // 商品库存
}
updateStock(newStock) { // 更新库存
this.stock = newStock;
console.log(`${this.name} has ${this.stock} items left.`);
// 直接更新页面显示
document.getElementById(this.name).innerText = `Stock: ${this.stock}`;
}
}
const productA = new Product('ProductA', 10);
productA.updateStock(8);
在这里,我们手动调用
updateStock
方法更新库存并刷新页面。这种方式简单,也能实现基本功能。但如果你有多个地方需要监听这个库存变化呢?你需要修改
updateStock
方法,使得在更多地方显示;这种修改一处影响另一处的做法,会严重影响我们项目的稳定性和可维护性。
观察者模式
通过观察者模式,我们可以让商品对象通知所有依赖它的观察者(比如购物车、库存管理页面等),这样每次库存更新时,所有相关组件都会自动收到通知并更新。
class Product {
constructor(name, stock) {
this.name = name;
this.stock = stock;
this.observers = []; // 存储所有观察者
}
addObserver(observer) {
this.observers.push(observer); // 添加观察者
}
notifyObservers() {
this.observers.forEach(observer => observer.update(this)); // 通知所有观察者
}
updateStock(newStock) {
this.stock = newStock;
this.notifyObservers(); // 库存变化时通知观察者
}
}
class Cart {
update(product) {
console.log(`Cart updated: ${product.name} now has ${product.stock} items left.`);
// 这里你可以更新购物车中的显示
}
}
const productA = new Product('ProductA', 10);
const cart = new Cart();
productA.addObserver(cart); // 购物车成为观察者
productA.updateStock(8);
在这个例子中,当
productA
的库存变化时,Cart
对象(观察者)会自动接收到通知并更新。这样,代码更加灵活和可维护。但是,这个模式还有优化的空间。我们在
Product
类 中定义观察者也可能会导致代码耦合变高。我们可以考虑在它们之间加一个 平台,处理二者之间的关系。就像 掘金 维护着 创作者 和 读者 之间的关系。
发布订阅模式
发布订阅模式新建了一个 事件总线(Event Bus) 的概念,事件总线维护着 发布者 、 订阅者 、 事件 三种组件以及它们之间的关系。
class EventBus {
constructor() {
this.events = {};
}
subscribe(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
}
publish(event, data) {
if (this.events[event]) {
this.events[event].forEach(callback => callback(data));
}
}
}
const eventBus = new EventBus();
class Product {
constructor(name, stock) {
this.name = name;
this.stock = stock;
}
updateStock(newStock) {
this.stock = newStock;
eventBus.publish('stockUpdated', this); // 发布库存更新事件
}
}
eventBus.subscribe('stockUpdated', (product) => {
console.log(`Cart updated: ${product.name} now has ${product.stock} items left.`);
// 更新购物车显示
});
eventBus.subscribe('stockUpdated', (product) => {
console.log(`Inventory system updated: ${product.name} now has ${product.stock} items left.`);
// 更新库存管理系统显示
});
const productA = new Product('ProductA', 10);
productA.updateStock(8);
概念透析:两种模式的区别
观察者模式是一种行为设计模式,允许一个对象(称为“观察者”)监听另一个对象(称为“目标”)的状态变化。当目标对象的状态发生变化时,它会通知所有已注册的观察者。这个过程通常是同步进行的,观察者直接收到通知并处理变化。
发布订阅模式虽然与观察者模式相似,但它引入了一个中介者角色,称为事件总线或消息队列。在这种模式下,发布者和订阅者之间不存在直接联系。发布者只需要将消息发布到事件总线,订阅者通过事件总线接收消息。这种模式更松耦合,也更适合大型系统中的模块化设计。
- 观察者模式:观察者直接订阅目标的状态变化。
- 发布订阅模式:通过事件总线,发布者和订阅者进行解耦,彼此无需知道对方的存在。
实战演练:手写发布订阅模式
总述:实现过程的重点
在接下来的实现过程中,我们将逐步构建一个 EventEmitter
类,并通过五个核心方法来完成发布订阅功能的实现:
-
on(eventName, fn)
- 作用:为指定的事件绑定一个回调函数。当该事件被触发时,所有绑定的回调函数都会依次执行。
- 目的:实现事件的订阅机制,使得模块可以监听特定事件的发生,并在事件触发时做出响应。
-
emit(eventName, ...args)
- 作用:触发指定的事件,并执行所有绑定在该事件上的回调函数。可以传递任意数量的参数给回调函数。
- 目的:发布事件,并通知所有已订阅的模块,使它们根据传递的参数执行相应的逻辑。
-
off(eventName, fn)
- 作用:取消指定事件的订阅。可以选择取消某个特定回调函数的订阅,也可以移除整个事件,防止后续触发。
- 目的:管理事件的订阅状态,提供对事件的精细化控制,避免不必要的回调函数执行,减少系统资源消耗。
-
once(eventName, fn)
- 作用:为指定的事件绑定一个一次性回调函数。该回调函数在事件触发时执行一次,并立即取消自身的订阅。
- 目的:提供对特殊情况的支持,当某些事件只需处理一次时,通过
once
方法避免手动取消订阅的麻烦。
-
constructor()
- 作用:初始化事件存储对象
this.events
,用于保存所有事件名称及其对应的回调函数数组。 - 目的:为发布订阅模式的实现提供基础设施,使得
EventEmitter
类能够管理和存储事件及其回调函数。
- 作用:初始化事件存储对象
步骤一:创建 EventEmitter
类和事件存储
首先,我们需要一个地方来存储所有的事件及其对应的回调函数。这是 EventEmitter
类的核心。我们使用一个对象来存储事件,事件名作为键,回调函数数组作为值。
class EventEmitter {
constructor() {
this.events = {}; // 初始化事件存储对象
}
}
解释:
this.events
是一个对象,用于存储所有的事件。每个事件名称都是一个键,对应的值是一个数组,数组中存放的是订阅了该事件的回调函数。
步骤二:实现 on
方法进行事件订阅
接下来,我们实现 on
方法,允许用户订阅特定事件。这个方法会接收事件名称和回调函数作为参数,并将回调函数存储在事件列表中。
on(eventName, fn) {
if (!this.events[eventName]) {
this.events[eventName] = []; // 如果事件还未注册,则创建一个新的数组
}
this.events[eventName].push(fn); // 将回调函数推入数组
return this; // 返回 this 以支持链式调用
}
解释:
this.events[eventName] = []
:如果事件名对应的数组不存在,则创建一个新的空数组。this.events[eventName].push(fn)
:将回调函数fn
添加到对应事件名的数组中。- 返回
this
使得方法支持链式调用,如emitter.on('event1', handler1).on('event2', handler2)
。
步骤三:实现 emit
方法触发事件
当事件发生时,我们需要触发所有订阅了该事件的回调函数。emit
方法会负责这一功能。
emit(eventName, ...args) {
if (!this.events[eventName]) return this; // 如果事件不存在,则直接返回
this.events[eventName].forEach(fn => {
fn.apply(this, args); // 逐个执行回调函数,并传入参数
});
return this; // 返回 this 以支持链式调用
}
解释:
fn.apply(this, args)
:使用apply
方法调用每个回调函数,并将emit
方法传递的参数传递给回调函数。...args
:这是 JavaScript 的扩展运算符,可以将任意数量的参数传递给回调函数。
步骤四:实现 off
方法取消事件订阅
有时我们需要取消某个事件的订阅,off
方法可以实现这个功能。它允许我们移除某个事件的特定回调函数或移除整个事件。
off(eventName, fn) {
if (!this.events[eventName]) return this; // 如果事件不存在,则直接返回
if (typeof fn === 'function') {
// 只移除特定的回调函数
this.events[eventName] = this.events[eventName].filter(f => f !== fn);
return this;
}
// 如果没有传入回调函数,移除整个事件
this.events[eventName] = null;
return this;
}
解释:
this.events[eventName].filter(f => f !== fn)
:使用filter
方法移除数组中与fn
不相等的函数,从而只保留未被移除的回调函数。this.events[eventName] = null
:如果没有传递fn
,则删除整个事件。
步骤五:实现 once
方法一次性订阅
有时我们只想让某个事件的回调函数执行一次,执行后立即取消订阅。once
方法实现了这一功能。
once(eventName, fn) {
const func = (...args) => {
this.off(eventName, func); // 首先取消订阅
fn.apply(this, args); // 然后执行回调函数
};
this.on(eventName, func); // 将 `func` 作为回调函数进行订阅
return this;
}
解释:
this.off(eventName, func)
:在回调函数执行后,立即取消订阅,确保回调函数只执行一次。this.on(eventName, func)
:将一个包装后的函数func
订阅到事件中,这个函数会在第一次触发时取消自身的订阅。
最终代码
class EventEmitter {
constructor() {
this.events = {}
}
on(eventName, fn) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(fn)
return this
}
once(eventName, fn) {
const func = (...args) => {
this.off(eventName, func)
fn.apply(this, args)
}
this.on(eventName, func)
return this
}
emit(eventName, ...args) {
if (!this.events[eventName]) return this
this.events[eventName].forEach(fn => {
fn.apply(this, args)
});
return this
}
off(eventName, fn) {
if (!this.events[eventName]) return this
if (typeof fn === 'function') {
this.events[eventName] = this.events[eventName].filter((f) => f !== fn)
return this
}
this.events[eventName] = null
return this
}
}