前端面试热门:观察者/发布订阅模式

2,266 阅读6分钟

最近也是在面试,手写发布订阅这个问题也是出现较频繁,今天也就来简单聊一聊!

假设我们有一个在线购物平台,该平台需要实时更新库存和通知管理员库存的变化情况。此外,还需要通知用户关于商品的状态变化,比如商品是否缺货或补货等。

像关于这类问题需要实时监测数据并更新的情况,我们应该使用到观察者模式或者发布订阅模式。现在我们就根据这个案例来区分一下这两种模式又有何不同

观察者模式

定义对象之间的一种一对多的依赖关系,当对象的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。

在这种模式下,管理员和用户 (观察者) 需要订阅库存,当库存的数据变化了,库存就会通知所有的观察者更新数据。

class Inventory {  
    constructor() {  
        // 初始化观察者列表,用于存储所有注册的观察者  
        this.observers = [];  
        // 初始化产品库存量  
        this.productStock = 0;  
    }  
  
    // 注册观察者到库存系统  
    registerObserver(observer) {  
        this.observers.push(observer); // 将新的观察者添加到观察者列表中  
    }  
  
    // 从库存系统中移除观察者  
    removeObserver(observer) {  
        const index = this.observers.indexOf(observer); // 查找观察者在列表中的索引  
        if (index >= 0) {  
            this.observers.splice(index, 1); // 如果找到,则从列表中移除该观察者  
        }  
    }  
  
    // 通知所有注册的观察者库存已经更新  
    notifyObservers() {  
        this.observers.forEach(observer => observer.update(this.productStock)); // 遍历观察者列表,并调用每个观察者的update方法,传递当前库存量  
    }  
  
    // 设置产品库存量,并通知所有观察者  
    setProductStock(stock) {  
        this.productStock = stock; // 更新库存量  
        this.notifyObservers(); // 通知所有观察者库存已更新  
    }  
}  
  
// 定义观察者基类,用于接收库存更新的通知  
class Observer {  
    // 当库存更新时,这个方法会被调用  
    update(stock) {  
        console.log(`Inventory updated to: ${stock}`); // 打印库存更新的信息  
    }  
}  
  
// 创建库存系统的实例  
const inventory = new Inventory();  
  
// 创建管理员和用户观察者的实例  
const adminObserver = new Observer(); // 管理员观察者,用于接收库存更新的通知  
const userObserver = new Observer();  // 用户观察者,同样用于接收库存更新的通知  
  
// 将管理员和用户观察者注册到库存系统中  
inventory.registerObserver(adminObserver);  
inventory.registerObserver(userObserver);  
  
// 更新库存并触发通知  
inventory.setProductStock(10); // 将库存设置为 10,并通知所有观察者  
inventory.setProductStock(5);  // 将库存减少到 5,并再次通知所有观察者

在这个模式中,库存系统作为被观察者,会维护一个观察者列表,这些观察者可能是管理员通知系统、用户通知系统等。当库存发生变化时,库存系统会主动遍历这个列表,并通知每一个观察者库存的最新状态。这种模式的好处在于它提供了一种直接的、同步的更新机制,但也可能导致系统间的耦合度较高,因为观察者需要明确知道被观察者的存在并注册自己。

发布订阅模式

一种消息范式,涉及消息的发送者(称为发布者)和接收者(称为订阅者)。在这种模式中,发布者和订阅者不直接相互了解,而是通过一个称为"事件通道"或"消息代理"的中间人来管理消息的分发。与观察者模式最直接的区别就是发布订阅有一个中间媒介。

相比之下,发布订阅模式通过引入一个中介(如事件总线或消息队列)来解耦发布者和订阅者。库存系统作为发布者,不需要关心哪些订阅者(如管理员通知系统、用户通知系统等)会接收库存变化的信息。它只需要将库存变化的信息发布到中介上,而订阅者则会在中介上订阅自己感兴趣的事件。当库存变化事件发生时,中介会负责将事件推送给所有订阅了该事件的订阅者。这种模式的好处在于它提供了更高的灵活性和可扩展性,因为新的订阅者可以轻松加入系统,而不需要修改发布者。同时,它也支持异步通信,使得系统更加高效。

class EventBus {  
    constructor() {  
        this.subscribers = {}; // 存储订阅者(回调函数)的对象,键为事件名称,值为回调函数数组  
    }  
  
    // 订阅一个事件,当该事件被发布时,会调用提供的回调函数  
    subscribe(event, callback) {  
        if (!this.subscribers[event]) {  
            this.subscribers[event] = []; // 如果该事件还没有订阅者,则初始化一个空数组  
        }  
        this.subscribers[event].push(callback); // 将回调函数添加到该事件的订阅者列表中  
    }  
  
    // 发布一个事件,并传递数据给所有订阅了该事件的回调函数  
    publish(event, data) {  
        const callbacks = this.subscribers[event]; // 获取该事件的所有订阅者  
        if (callbacks) {  
            callbacks.forEach(callback => callback(data)); // 遍历订阅者列表,并调用每个回调函数,传递发布的数据  
        }  
    }  
}  
  
// 天气数据类,负责存储天气数据并通过事件总线发布数据变化  
class WeatherData {  
    constructor(eventBus) {  
        this.eventBus = eventBus; // 注入事件总线实例  
        this.temperature = 0; // 初始化温度  
        this.humidity = 0; // 初始化湿度  
        this.windSpeed = 0; // 初始化风速  
    }  
  
    // 设置新的天气数据,并通过事件总线发布'measurementsChanged'事件  
    setMeasurements(temperature, humidity, windSpeed) {  
        this.temperature = temperature; // 更新温度  
        this.humidity = humidity; // 更新湿度  
        this.windSpeed = windSpeed; // 更新风速  
        this.eventBus.publish('measurementsChanged', { temperature, humidity, windSpeed }); // 发布数据变化事件  
    }  
}  
  
// 显示元素的基类,定义了一个空的display方法,供子类实现  
class DisplayElement {  
    display() {} // 一个空方法,用于在子类中实现具体的显示逻辑  
}  
  
// 温度显示类,订阅了'measurementsChanged'事件,用于更新和显示温度  
class TemperatureDisplay extends DisplayElement {  
    constructor(eventBus) {  
        super();  
        this.eventBus = eventBus; // 注入事件总线实例  
        // 使用bind确保回调函数中的this指向当前实例  
        this.eventBus.subscribe('measurementsChanged', this.update.bind(this));   
    }  
  
    // 当接收到'measurementsChanged'事件时,更新并显示温度  
    update(data) {  
        console.log(`Temperature: ${data.temperature}°C`); // 显示温度  
        this.display(); // 调用基类的display方法(虽然这里它什么都不做)  
    }  
}  
  
// 湿度显示类和风速显示类与温度显示类类似,但分别显示湿度和风速  
// ...(HumidityDisplay 和 WindSpeedDisplay 类的代码与 TemperatureDisplay 类似,只是更新和显示的信息不同)  
  
// 创建事件总线实例  
const eventBus = new EventBus();  
  
// 创建天气数据源实例,并注入事件总线  
const weatherData = new WeatherData(eventBus);  
  
// 创建各种显示设备实例,并注入事件总线以订阅天气数据变化  
const temperatureDisplay = new TemperatureDisplay(eventBus);  
const humidityDisplay = new HumidityDisplay(eventBus);  
const windSpeedDisplay = new WindSpeedDisplay(eventBus);  
  
// 更新天气数据,并通过事件总线通知所有订阅者  
weatherData.setMeasurements(25, 60, 5); // 温度 25°C, 湿度 60%, 风速 5 m/s

关于使用在vue中使用EventBus通信我也有篇文章Vue组件通信(二)

相比

  • 观察者模式:

    • 主题直接与观察者交互。
    • 更适合于简单的依赖关系,特别是当观察者数量较少时。
    • 观察者可以直接访问主题的状态。
  • 发布/订阅者模式:

    • 事件总线作为中间人管理发布者和订阅者。
    • 更适合于复杂的通信场景,特别是当需要多个订阅者时。
    • 订阅者和发布者之间没有直接的依赖关系,更加解耦。

手写发布订阅

我们大概把步骤分为五步

步骤 1: 初始化事件存储

  • 创建一个对象来存储事件名及其对应的回调函数列表。
constructor() {
    this.cache = {}; // 初始化一个空对象
}

步骤 2: 订阅事件

  • 定义一个方法来添加事件监听器。
  • 检查事件名是否存在,如果不存在则创建一个新的数组来存储回调函数。
  • 将回调函数添加到事件名对应的数组中。
/**
     * 订阅事件
     * @param {string} name - 事件名称
     * @param {Function} fn - 回调函数
     */
    on(name, fn) {
        if (this.cache[name]) {
            this.cache[name].push(fn);
        } else {
            this.cache[name] = [fn];
        }
    }

步骤 3: 触发事件

  • 定义一个方法来触发事件。
  • 检查事件名是否存在,如果存在则获取对应的回调函数数组。
  • 遍历数组并调用每一个回调函数。
  • 如果设置了 once 参数,则在触发后清除该事件的所有监听器。
/**
     * 触发事件
     * @param {string} name - 事件名称
     * @param {boolean} [once=false] - 是否只触发一次
     * @param {...any} args - 传给回调函数的参数
     */
    emit(name, once = false, ...args) {
        if (this.cache[name]) {
            // 复制当前事件的回调列表,以防止执行过程中被修改
            let tasks = this.cache[name].slice();
            for (let fn of tasks) {
                fn(...args);
            }
            if (once) {
                // 如果设置了 once 参数,则删除该事件的所有监听器
                delete this.cache[name];
            }
        }
    }

步骤 4: 取消订阅

  • 定义一个方法来移除事件监听器。
  • 检查事件名是否存在,如果存在则获取对应的回调函数数组。
  • 使用 findIndex 方法查找要移除的回调函数的位置。
  • 如果找到了回调函数,则使用 splice 方法将其从数组中移除。
/**
     * 取消订阅事件
     * @param {string} name - 事件名称
     * @param {Function} fn - 要移除的回调函数
     */
    off(name, fn) {
        let tasks = this.cache[name];
        if (tasks) {
            // 查找回调函数的位置
            const index = tasks.findIndex(f => f === fn || f.callback === fn);
            if (index >= 0) {
                // 移除找到的回调函数
                tasks.splice(index, 1);
            }
        }
    }

步骤 5: 只触发一次事件

  • 定义一个方法来创建一个只触发一次的事件监听器。
  • 创建一个新的包装函数,该函数会在执行后自动取消订阅。
  • 使用 on 方法订阅事件,传递包装后的函数作为回调。
/**
     * 只触发一次事件
     * @param {string} name - 事件名称
     * @param {Function} fn - 回调函数
     */
    once(name, fn) {
        // 创建一个新的回调函数,该函数会在执行后自动取消订阅
        const wrappedFn = (...args) => {
            this.off(name, wrappedFn);
            fn(...args);
        };

        this.on(name, wrappedFn);
    }

完整代码如下:

class EventEmitter {
    constructor() {
        this.cache = {};
    }

    /**
     * 订阅事件
     * @param {string} name - 事件名称
     * @param {Function} fn - 回调函数
     */
    on(name, fn) {
        if (this.cache[name]) {
            this.cache[name].push(fn);
        } else {
            this.cache[name] = [fn];
        }
    }

    /**
     * 触发事件
     * @param {string} name - 事件名称
     * @param {boolean} [once=false] - 是否只触发一次
     * @param {...any} args - 传给回调函数的参数
     */
    emit(name, once = false, ...args) {
        if (this.cache[name]) {
            // 复制当前事件的回调列表,以防止执行过程中被修改
            let tasks = this.cache[name].slice();
            for (let fn of tasks) {
                fn(...args);
            }
            if (once) {
                // 如果设置了 once 参数,则删除该事件的所有监听器
                delete this.cache[name];
            }
        }
    }

    /**
     * 取消订阅事件
     * @param {string} name - 事件名称
     * @param {Function} fn - 要移除的回调函数
     */
    off(name, fn) {
        let tasks = this.cache[name];
        if (tasks) {
            // 查找回调函数的位置
            const index = tasks.findIndex(f => f === fn || f.callback === fn);
            if (index >= 0) {
                // 移除找到的回调函数
                tasks.splice(index, 1);
            }
        }
    }

    /**
     * 只触发一次事件
     * @param {string} name - 事件名称
     * @param {Function} fn - 回调函数
     */
    once(name, fn) {
        // 创建一个新的回调函数,该函数会在执行后自动取消订阅
        const wrappedFn = (...args) => {
            this.off(name, wrappedFn);
            fn(...args);
        };

        this.on(name, wrappedFn);
    }
}