面试热门考题:观察者/发布订阅模式

154 阅读6分钟

情景导入:什么是观察者/发布订阅模式?

想象你正在开发一个电商网站,页面上显示了多个商品的库存数量。当用户购买商品时,库存数量需要立即更新显示。你可以通过简单的刷新页面来解决这个问题,但显然这不是一个用户友好的方案。我们希望能够在用户购买商品时,实时更新页面上的库存信息。

假如没有设计模式

对于这个案例,一种直接的解决方法是每个商品对象都拥有一个方法,用于更新库存,并且在库存变化时直接更新页面:

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);

概念透析:两种模式的区别

观察者模式是一种行为设计模式,允许一个对象(称为“观察者”)监听另一个对象(称为“目标”)的状态变化。当目标对象的状态发生变化时,它会通知所有已注册的观察者。这个过程通常是同步进行的,观察者直接收到通知并处理变化。

发布订阅模式虽然与观察者模式相似,但它引入了一个中介者角色,称为事件总线消息队列。在这种模式下,发布者和订阅者之间不存在直接联系。发布者只需要将消息发布到事件总线,订阅者通过事件总线接收消息。这种模式更松耦合,也更适合大型系统中的模块化设计。

image.png

  • 观察者模式:观察者直接订阅目标的状态变化。
  • 发布订阅模式:通过事件总线,发布者和订阅者进行解耦,彼此无需知道对方的存在。

实战演练:手写发布订阅模式

总述:实现过程的重点

在接下来的实现过程中,我们将逐步构建一个 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
    }
}