前端设计模式:观察者、发布订阅
观察者模式和发布订阅模式的核心区别只有一句话:观察者模式里「被观察者」直接持有观察者列表并逐个调用,两者互相知道对方的存在;发布订阅模式在中间加了一个事件中心(Event Bus),发布者只认事件名、订阅者也只认事件名,双方完全不知道对方是谁。前者是强耦合的一对多,后者是解耦后的多对多。
观察者模式和发布订阅模式有什么区别?
结论:观察者模式只有两个角色(目标 Subject、观察者 Observer),目标直接调用 observer.update();发布订阅有三个角色(发布者、事件中心、订阅者),中间多了一层事件中心,且通信靠「事件名」这个字符串路由。
具体差异体现在三处:
第一,依赖方向。观察者模式中 Subject 需要知道 Observer 的接口(必须有 update 方法),所以耦合更紧、性能更高(少一次 Map 查找 + 字符串匹配)。发布订阅中 Publisher 和 Subscriber 互不引用,只引用 EventBus,所以同一个事件可以有任意多个发布者。
第二,通信粒度。观察者模式通常是「数据变了通知所有观察者」,是一对多的广播;发布订阅按事件名分频道,天然支持多对多,'user:login' 和 'cart:add' 互不干扰。
第三,典型场景。DOM 的 addEventListener、Redux 的 store.subscribe、Vue 2 的 Dep/Watcher 属于观察者模式;Node 的 EventEmitter、Vue 2 的 $emit/$on、社区常用的 mitt 属于发布订阅模式。
观察者模式怎么写?
先给完整实现,核心是 Subject 用 Set 存观察者、notify 时遍历调用,并返回一个取消订阅的函数避免内存泄漏。
class Subject {
constructor() { this.observers = new Set(); }
subscribe(observer) {
this.observers.add(observer);
return () => this.observers.delete(observer); // 返回取消订阅函数
}
notify(payload) {
this.observers.forEach(observer => {
try { observer.update(payload); }
catch (e) { console.error('[Subject] observer error:', e); }
});
}
}
class Observer {
constructor(name) { this.name = name; }
update(payload) { console.log(this.name, '收到', payload); }
}
const subject = new Subject();
const offA = subject.subscribe(new Observer('A'));
subject.notify({ msg: 'hello' }); // A 收到 { msg: 'hello' }
offA(); // 单条取消订阅
三个必须注意的点:用 Set 而不是数组,可以自动去重同一个观察者;notify 里一定要 try/catch 包住每次调用,否则第一个观察者抛错,后面所有观察者都收不到通知;一定要提供取消订阅的入口,否则 SPA 里路由切换几十次就会累积几十个失效的观察者。
发布订阅模式怎么写?
结论:一个可用的 EventBus 必须实现 on、once、off、emit 四个方法,其中 emit 遍历前要复制一份监听器集合,防止监听器在回调里调用 off 导致遍历被破坏。
class EventBus {
constructor() { this.events = new Map(); }
on(type, fn) {
if (!this.events.has(type)) this.events.set(type, new Set());
this.events.get(type).add(fn);
return () => this.off(type, fn);
}
once(type, fn) {
const wrap = (...args) => { this.off(type, wrap); fn(...args); };
return this.on(type, wrap);
}
off(type, fn) {
const set = this.events.get(type);
if (!set) return;
fn ? set.delete(fn) : set.clear();
if (set.size === 0) this.events.delete(type); // 及时释放,避免 Map 无限增长
}
emit(type, ...args) {
const set = this.events.get(type);
if (!set) return;
[...set].forEach(fn => {
try { fn(...args); }
catch (e) { console.error(`[EventBus] ${type}:`, e); }
});
}
}
[...set] 这一步不能省:监听器回调里常见的写法是「先 off 再干活」,如果直接 forEach 原集合,正在被遍历的 Set 被修改会丢事件。另外 off(type) 不传 fn 时执行 set.clear(),配合 if (set.size === 0) this.events.delete(type) 可以彻底清掉这个频道,否则 Map 会随着事件名增多一直涨。
前端框架里到底用的是哪一种?
结论:Vue 2 的响应式是观察者模式,Redux 的 store.subscribe 是观察者模式,Node 的 EventEmitter 和 mitt 是发布订阅模式,不要混着说。
Vue 2 中每个响应式属性对应一个 Dep 实例,Dep 内部维护 subs 数组存放 Watcher,数据变化时执行 dep.notify() 直接遍历调用 watcher.update(),Dep 和 Watcher 互相持有引用,是标准观察者。Vue 3 换成 effect 做依赖收集,本质仍是目标对象直接持有依赖集合。
Redux 的 store.subscribe(listener) 把 listener 存进数组,dispatch 之后逐个调用,同样是观察者。Node 的 EventEmitter 用事件名做字符串路由,还有 newListener、removeListener 这些元事件,是标准的发布订阅。需要注意 Node 默认 maxListeners 是 10,某个事件超过 10 个监听器会打印 MaxListenersExceededWarning,可以用 emitter.setMaxListeners(n) 调整。
还有一个版本坑:Vue 3 移除了 $on、$off、$once,只保留 $emit,如果项目从 Vue 2 迁移到 Vue 3,原来基于 $on 的事件总线必须换成 mitt 之类的第三方库。
用这两种模式最容易踩的坑是什么?
结论:最大的坑是内存泄漏和错误吞没,其次是误以为回调是异步的。
内存泄漏:在 mounted/useEffect 里注册,就必须在 unmounted/清理函数里注销。注册时返回的取消函数是最好用的写法,on 和 subscribe 都返回它,直接 const off = bus.on(...),清理时 off() 即可,不用记函数引用。
错误吞没:两个模式默认都是同步调用,emit 里第一个监听器抛异常会中断后面的所有监听器,所以每个回调都要 try/catch 隔离。如果业务需要异步,用 queueMicrotask(() => fn(...args)) 塞进微任务队列,或者用 setTimeout,但要注意异步后错误就无法被 try/catch 捕获了。
执行顺序:ES6 的 Set 和 Node 的 EventEmitter 都按注册顺序同步调用,谁先 on 谁先执行,依赖顺序做初始化逻辑非常脆弱,跨模块的初始化应该显式写在启动流程里。
选型上,一句话收尾:模块之间能直接拿到对象、只是数据变化要通知,用观察者模式;模块之间不该互相知道、需要跨层级跨页面通信,用发布订阅。前端项目里绝大多数「事件总线」的需求,用上面那个 30 行的 EventBus 就够了,不必引入额外依赖。
原文链接:https://www.gj0.com/thread-689.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。