1.前言
观察者模式与订阅发布模式只是 JavaScript 的两种设计模式。那在我们日常开发中什么场景下发现我需要用设计模式呢?我看到一个非常形象的比喻,不要为了用设计模式而用,而是发现了病症(代码问题),我需要用药方(设计模式)来解决了。那当我发现一个对象需要通知多个其他的对象,但是又不想将他们紧紧地绑定在一起的时候,就可以考虑使用观察者或者订阅发布模式。
2.是什么
2.1 观察者模式
观察者模式是定义了一种直接依赖通信的设计模式,包含目标者(Subject)和观察者们(Observers)。当目标者发生变化时,对它有依赖关系的观察者们会收到消息并自动更新。
2.2 订阅发布模式
订阅发布模式是定义了一种间接通信的设计模式,包含订阅者(Subscriber)、中间层(事件总线)(Event Bus)和发布者(Publisher)。订阅者在中间层订阅事件,发布者发布事件到中间层,中间层负责接收发布者的事件,根据事件的类型,将其过滤分发给对此类事件感兴趣的订阅者。
3.使用场景
3.1 观察者模式
- 通信双方已知并固定,关系紧密,且只有一个逻辑单元
- 逻辑上属于同一个模块或者系统
- 通知是同步、立即更新的
3.2 发布订阅模式
- 通信双方不固定,关系灵活,逻辑复杂
- 通信需要跨模块/组件/系统的边界
4.优缺点
4.1 观察者模式
-
优点:
✅ 极其高效,同步立即执行
✅ 强一致性,目标者改变,能确定所有的观察者都会更新状态
✅ 简洁可读性高,没有中间层,逻辑简单 -
缺点:
❌ 耦合较高,目标者需要清楚地知道所有的观察者,观察者也需要知道目标者的存在并注册,这种双向知晓构成了耦合
❌ 不异于扩展,如果需要增加新的观察者,需要在更改目标者的代码来支持。
❌ 同步执行可能会有阻塞问题,如果其中一个观察者的更新比较耗时,那么会堵塞其他观察者的状态更新
4.2 发布订阅模式
-
优点:
✅ 极致地解耦,订阅者和发布者无需知道对方的存在
✅ 极强的扩展性和灵活性,随时为某个事件添加订阅者,添加新的订阅者无需更改发布者的代码,符合开放-封闭原则
✅ 支持异步,中间层容易对事件进行异步处理,避免因耗时的订阅者堵塞整个系统 -
缺点:
❌ 复杂度高,调试难度增加,流程变得复杂
❌ 依赖中间层,引入单点故障:一旦中间层发生故障,则整个系统瘫痪
❌ 提高性能成本,相较直接的方法,对事件进行封装、传递、解析等有一定的性能开销
5.代码实现
5.1 观察者模式代码实现
class Subject {
constructor() {
this.state = null
this.observers = []
}
// 添加观察者
add(observer) {
this.observers.push(observer)
}
// 移除观察者
remove(removeObserver) {
this.observers = this.observers.filter(item => removeObserver !== item)
}
// 通知观察者
notify() {
this.observers.forEach(item => {
item.update(this.state)
})
}
// 更新状态并通知观察者
setState(state) {
this.state = state
this.notify()
}
}
class Observer {
constructor(name) {
this.name = name
}
update(...args) {
console.log(`${this.name}观察者状态更新成: ${args}`)
}
}
5.2 发布订阅模式实现
//事件总线
class EventBus {
constructor() {
this.events = {}
}
// 添加事件
_add(eventName,callback,isOnce) {
if(!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push({callback,isOnce})
}
// 事件注册
on (eventName,callback) {
this._add(eventName,callback,false)
}
// 一次性事件监听
once (eventName,callback) {
this._add(eventName,callback,true)
}
// 事件触发
emit(eventName,...data) {
if(!this.events[eventName]) return
const eventList = this.events[eventName]
// 创建副本,防止在删除元素过程中产生异常
const listeners = [...eventList]
listeners.forEach(({callback,isOnce},index) => {
callback(...data)
if(isOnce){
this.off(eventName,callback)
}
})
}
// 事件移除
off(eventName,callback) {
const eventList = this.events[eventName]
if(!eventList) return
this.events[eventName] = eventList.filter((item,index)=> item.callback!==callback)
}
}
module.exports = EventBus
6.测试验证
单元测试,使用了Jest框架,下面是测试的几个测试用例,完全通过
6.1 观察者模式测试验证
const sub = new Subject()
const obsA = new Observer("观察者A")
const obsB = new Observer("观察者B")
const obsC = new Observer("观察者C")
// 注册
sub.add(obsA)
sub.add(obsB)
sub.add(obsC)
// 移除 B
sub.remove(obsB)
// 更新状态
sub.setState("我饿了")
// 观察者A观察者状态更新成: 我饿了
// 观察者C观察者状态更新成: 我饿了
6.2 发布订阅模式测试验证
const EventBus = require('../xxxx');
describe('EventBus 发布订阅模式测试', () => {
let eventBus;
// 每个测试前创建新的EventBus实例,避免测试间相互影响
beforeEach(() => {
eventBus = new EventBus();
});
test('应该能订阅并发布事件', () => {
const callback = jest.fn();
// 订阅事件
eventBus.on('test-event', callback);
// 发布事件
eventBus.emit('test-event', 'hello', 'world');
// 验证回调被调用且参数正确
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('hello', 'world');
});
test('一次性订阅应该只触发一次', () => {
const onceCallback = jest.fn();
// 一次性订阅
eventBus.once('once-event', onceCallback);
// 第一次发布
eventBus.emit('once-event', 'first');
// 第二次发布
eventBus.emit('once-event', 'second');
// 验证只被调用一次,且收到第一次的参数
expect(onceCallback).toHaveBeenCalledTimes(1);
expect(onceCallback).toHaveBeenCalledWith('first');
});
test('多次一次性订阅同一个事件,应该每个都触发一次', () => {
const onceCallback1 = jest.fn();
const onceCallback2 = jest.fn();
const onceCallback3 = jest.fn();
// 一次性订阅
eventBus.once('once-event', onceCallback1);
eventBus.once('once-event', onceCallback2);
eventBus.once('once-event', onceCallback3);
// 第一次发布
eventBus.emit('once-event', 'first');
// 第二次发布
eventBus.emit('once-event', 'second');
// 验证只被调用一次,且收到第一次的参数
expect(onceCallback1).toHaveBeenCalledTimes(1);
expect(onceCallback2).toHaveBeenCalledTimes(1);
expect(onceCallback3).toHaveBeenCalledTimes(1);
expect(onceCallback1).toHaveBeenCalledWith('first');
expect(onceCallback2).toHaveBeenCalledWith('first');
expect(onceCallback3).toHaveBeenCalledWith('first');
});
test('应该能取消订阅', () => {
const callback = jest.fn();
// 订阅事件
eventBus.on('cancel-event', callback);
// 取消订阅
eventBus.off('cancel-event', callback);
// 发布事件
eventBus.emit('cancel-event', 'should not trigger');
// 验证回调未被调用
expect(callback).not.toHaveBeenCalled();
});
test('发布未被订阅的事件应该无副作用', () => {
// 测试发布未订阅的事件不会报错
expect(() => {
eventBus.emit('unsubscribed-event', 'test');
}).not.toThrow();
});
test('多个订阅者应该都能收到事件', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
eventBus.on('multi-subscribers', callback1);
eventBus.on('multi-subscribers', callback2);
eventBus.emit('multi-subscribers', 'shared data');
expect(callback1).toHaveBeenCalledTimes(1);
expect(callback2).toHaveBeenCalledTimes(1);
expect(callback1).toHaveBeenCalledWith('shared data');
expect(callback2).toHaveBeenCalledWith('shared data');
});
});
7.具体的技术场景
7.1 观察者模式技术场景
Vue 2 的响应式系统
- Subject:一个 Vue 组件的 data 对象(背后的 Dep 类)。
- Observers:依赖于这个数据的计算属性(Computed) 和模板渲染函数(Watcher)。
为什么用它:关系非常直接和固定。一个数据变了,就知道要通知哪些计算属性和渲染函数。这是 Vue 内部机制的核心,需要极高的性能和同步性。
7.2 发布订阅模式技术场景
Vue 的全局事件总线(on)
- 场景:两个毫无关联的 Vue 组件(比如一个在页面头部 Header,一个在侧边栏 Sidebar)需要通信。
- 过程:Header 组件发布一个 'toggle-theme' 事件。Sidebar 组件订阅了 'toggle-theme' 事件。它们不需要互相引用,只通过全局的 $bus 通信。
为什么用它:完全解耦。Header 和 Sidebar 是独立的,不应该有直接引用。
8.总结
没有最好的模式,只有最合适的模式。追求性能和控制力,处理内部状态流,选择观察者模式。追求架构灵活性和可扩展性,需要跨边界通信,选择发布订阅模式。一个大型系统可能会同时包含两种模式,观察者模式处理内部核心的、高效状态的变化,发布订阅模式处理外部的,松散的事件通信。