手撕发布订阅与观察者模式:从原理到实践
前言
在JavaScript异步编程和组件通信中,发布订阅模式和观察者模式是两种至关重要的设计模式。
它们都能实现对象间的一对多依赖关系,但实现方式截然不同。
本文将通过两道手撕面试题代码,深入剖析这两种模式的核心原理、实现方式,以及它们之间的本质区别。
一、题目 FED19 发布订阅模式
描述
请补全JavaScript代码,完成"EventEmitter"类实现发布订阅模式。 注意:
- 同一名称事件可能有多个不同的执行函数
- 通过"on"函数添加事件
- 通过"emit"函数触发事件
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
</head>
<body>
<script type="text/javascript">
class EventEmitter {
// 补全代码
}
</script>
</body>
</html>
二 、发布订阅模式
发布/订阅模式的核心思想,是实现应用中那些彼此不相干的模块之间的轻松通信。
这种模式在 jQuery 插件生态和各类前端架构设计书籍中常有深入探讨,但需要说明的是,它并非 JavaScript 语言规范的一部分,所以在 MDN 等官方文档中并不会有直接的介绍。
原理
发布-订阅模式定义了一种一对多的依赖关系,当发布者(Publisher)对象的状态发生改变时,所有依赖它的订阅者(Subscriber)对象都会得到通知。它像一个“信息中介”,将消息的发送者和接收者彻底解耦,两者不需要知道对方的存在,只需要知道共同的“频道名称”。
它的工作原理可以拆解为以下几个角色:
- 发布者 (Publisher):负责在特定“频道”上发送消息或事件,不关心谁会接收。
- 订阅者 (Subscriber):负责订阅感兴趣的“频道”,并在频道有消息时执行相应的回调函数。
-
事件调度中心 (Event Bus / PubSub):这是模式的核心,负责维护所有“频道”和订阅者的关系。它提供订阅(
on/subscribe)、发布(emit/publish)、取消订阅(off/unsubscribe)等核心方法。
下面是一个极简的 JavaScript 实现:
// 创建一个事件中心 (Event Bus)
const eventHub = {
// 用于存储事件和对应的回调函数
topics: {},
// 订阅方法
subscribe: function(topic, listener) {
if (!this.topics[topic]) this.topics[topic] = [];
this.topics[topic].push(listener);
// 返回一个可以用于取消订阅的函数
return () => {
const index = this.topics[topic].indexOf(listener);
if (index !== -1) this.topics[topic].splice(index, 1);
};
},
// 发布方法
publish: function(topic, data) {
if (!this.topics[topic]) return;
this.topics[topic].forEach(listener => {
listener(data);
});
}
};
// --- 使用示例 ---
// 模块A:订阅 'user-login' 事件
const unsubscribe = eventHub.subscribe('user-login', (userInfo) => {
console.log(`模块A收到通知,用户 ${userInfo.name} 已登录。`);
});
// 模块B:发布 'user-login' 事件
eventHub.publish('user-login', { name: '张三' });
// 输出: 模块A收到通知,用户 张三 已登录。
// 当不再需要时,可以取消订阅
// unsubscribe();
经典实现
其实我觉得这个思想类似于浏览器的 addEventListener。
浏览器 API 中的 window 对象上的事件机制,是发布-订阅模式的一种经典实现。
DOM 事件系统(包括 window 上的事件)就是浏览器原生实现的、基于发布-订阅模式的事件架构。
DOM 事件系统如何实现发布-订阅
让我们把浏览器的事件模型和标准的发布-订阅模式做个映射:
| 模式角色 | DOM 事件系统中的对应实现 | 说明 |
|---|---|---|
| 事件调度中心 |
window、document、Element 等 DOM 节点 |
每个 DOM 节点都内置了事件管理能力 |
| 订阅 (Subscribe) | addEventListener('eventName', callback) |
订阅特定事件类型 |
| 发布 (Publish) | 用户交互或代码触发:dispatchEvent(event)、点击等 |
触发事件,执行所有订阅的回调 |
| 取消订阅 (Unsubscribe) | removeEventListener('eventName', callback) |
移除事件监听,避免内存泄漏 |
| 事件通道 | 事件类型字符串,如 'click'、'resize'、'message'
|
类似发布-订阅中的"topic" |
window 就是典型的事件总线
// ========== window 作为事件调度中心 ==========
// 1. 订阅 (Subscribe):监听一个自定义事件
window.addEventListener('user-logged-in', (event) => {
console.log(`收到通知,用户 ${event.detail.name} 登录了`);
// 可以触发任何行为
});
// 2. 发布 (Publish):在任意地方触发事件
function login() {
// ... 登录逻辑 ...
const customEvent = new CustomEvent('user-logged-in', {
detail: { id: 1, name: '张三' }
});
window.dispatchEvent(customEvent);
}
// 3. 取消订阅 (Unsubscribe)
const handler = (event) => { console.log('只会执行一次'); };
window.addEventListener('once-event', handler);
// 不再需要时移除
window.removeEventListener('once-event', handler);
理解 window 事件是发布-订阅模式,对掌握浏览器 API 和设计模式有双重价值:
-
解释了很多原生 API 的行为:
-
window.addEventListener('resize', handler)— 订阅窗口大小变化事件 -
window.addEventListener('online', handler)— 订阅网络状态变化 -
window.addEventListener('message', handler)— 订阅跨窗口消息(iframe 通信) - 这些都遵循同样的"先订阅、后触发、最后取消订阅"模式。
-
-
揭示了事件委托的原理: 由于事件会冒泡,在
window或document上订阅一个事件,可以接收到任何子元素触发的事件。这正是利用了"一个调度中心可以接收所有发布"的特性。// 事件委托:在 window 上订阅,捕获所有点击 window.addEventListener('click', (event) => { if (event.target.matches('.btn-delete')) { console.log('删除按钮被点击'); } });
应用场景
理解原理后,更重要的是知道它在哪些场景下能真正派上用场。
- 跨组件通信:在大型前端应用中,用于解决没有直接关系的组件(如兄弟组件、跨层级组件)之间的通信问题,可以避免通过父组件层层传递回调函数的麻烦。
- 异步编程:在处理AJAX请求、图片加载、脚本加载等异步操作时,可以用发布-订阅模式来管理成功、失败、完成等不同状态下的回调,让代码更清晰。
- 模块解耦:将一个复杂系统中的不同功能模块(如购物车、用户中心、商品展示)通过事件中心进行通信,可以显著降低模块间的直接依赖,使得各个模块可以独立开发、测试和维护。
-
MV 框架的底层实现*:Vue.js 中组件间的
$on/$emit方法,本质上就是基于发布-订阅模式的实现。
注意事项
在使用这种模式时,有几个“坑”需要特别注意:
-
内存泄漏:当一个组件或对象被销毁时,一定要记得调用
unsubscribe或off方法,将它之前订阅的事件从事件中心移除。否则,事件中心的回调函数依然持有对已销毁对象的引用,导致其无法被垃圾回收,从而造成内存泄漏。 - 过度使用:虽然模式好用,但过度使用会使应用中的数据流变得非常隐蔽和难以追踪。当一个事件的触发会引发一连串不可见的连锁反应时,代码的调试和维护会变得异常困难。对于简单的父子组件通信,直接传递 props 或调用方法仍是更清晰的选择。
-
事件命名冲突:在大型项目中,事件名称容易重复,引发非预期的行为。建议使用一套清晰的命名规范,如
模块名:动作名(例如user:login,cart:add)。
三、解法
答案
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
</head>
<body>
<script type="text/javascript">
class EventEmitter {
constructor (){
this.events = {};
}
on(eventName , callback ){
if (!this.events[eventName]){
this.events[eventName] = [] ;
}
this.events[eventName].push(callback);
}
emit(eventName , ...args){
const callbacks = this.events[eventName];
if (callbacks && callbacks.length){
callbacks.forEach(callback => {
callback(...args);
});
}
}
}
</script>
</body>
</html>
根据题目要求,我们需要实现一个 EventEmitter 类,支持:
- 同一名称事件可以有多个不同的执行函数
-
on方法添加事件监听 -
emit方法触发事件
class EventEmitter {
constructor() {
// 存储事件及其对应的回调函数列表
this.events = {};
}
// 添加事件监听
on(eventName, callback) {
// 如果该事件还没有对应的回调数组,则初始化一个空数组
if (!this.events[eventName]) {
this.events[eventName] = [];
}
// 将回调函数添加到数组中
this.events[eventName].push(callback);
}
// 触发事件
emit(eventName, ...args) {
// 获取该事件对应的回调函数列表
const callbacks = this.events[eventName];
// 如果存在回调函数,则依次执行
if (callbacks && callbacks.length) {
callbacks.forEach(callback => {
callback(...args);
});
}
}
}
使用示例
const emitter = new EventEmitter();
// 添加多个监听同一个事件
emitter.on('click', () => console.log('clicked 1'));
emitter.on('click', (msg) => console.log('clicked 2:', msg));
emitter.on('click', (msg) => console.log('clicked 3:', msg));
// 触发事件
emitter.emit('click', 'hello');
// 输出:
// clicked 1
// clicked 2: hello
// clicked 3: hello
代码说明
-
constructor:初始化一个空对象events用于存储事件名和对应的回调函数数组 -
on(eventName, callback):- 检查
events对象中是否已存在该事件名的回调数组 - 如果不存在,创建空数组
- 将回调函数添加到数组中
- 检查
-
emit(eventName, ...args):- 获取该事件对应的回调函数数组
- 如果存在,遍历数组并依次执行每个回调函数
- 使用扩展运算符
...args将传入的参数传递给每个回调函数
这个实现满足题目的所有要求:支持同一事件的多个回调函数,通过 on 添加,通过 emit 触发。
四、题目 FED20 观察者模式
描述
请补全JavaScript代码,完成"Observer"、"Observerd"类实现观察者模式。要求如下:
-
被观察者构造函数需要包含"name"属性和"state"属性且"state"初始值为"走路"
-
被观察者创建"setObserver"函数用于保存观察者们
-
被观察者创建"setState"函数用于设置该观察者"state"并且通知所有观察者
-
观察者创建"update"函数用于被观察者进行消息通知,该函数需要打印(console.log)数据,数据格式为:小明正在走路。其中"小明"为被观察者的"name"属性,"走路"为被观察者的"state"属性
注意:"Observer"为观察者,"Observerd"为被观察者
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
</head>
<body>
<script type="text/javascript">
// 补全代码
class Observerd {
}
class Observer {
}
</script>
</body>
</html>
五、观察者模式
在 JavaScript 中,经常会遇到一个问题:你需要一种方法来响应特定事件,并利用这些事件提供的数据来更新页面的某些部分。
例如,用户输入后,你需要将其应用到一个或多个组件中。这会导致代码中出现大量的推送和拉取操作,以保持所有内容的同步。
观察者模式正是在这种情况下发挥作用——它支持元素之间的一对多数据绑定。
这种单向数据绑定可以由事件驱动。借助这种模式,您可以构建可重用的代码,以满足您的特定需求。
核心概念
- 被观察者(Observable):维护一组观察者,状态变化时自动通知它们
- 观察者(Observer):订阅被观察者,当被通知时执行相应逻辑
被观察者的三个核心部分
EventObserver
│
├── subscribe: adds new observable events
│
├── unsubscribe: removes observable events
|
└── broadcast: executes all events with bound data
| 部分 | 作用 |
|---|---|
observers |
数组,存储所有观察者 |
subscribe() |
添加观察者 |
unsubscribe() |
移除观察者 |
notify(data) |
通知所有观察者 |
基础实现(ES6 Class)
class Observable {
constructor() {
this.observers = [];
}
subscribe(func) {
this.observers.push(func);
}
unsubscribe(func) {
this.observers = this.observers.filter(observer => observer !== func);
}
notify(data) {
this.observers.forEach(observer => observer(data));
}
}
观察者模式的实际应用
例如博客字数统计演示:
创建一个博客文章输入框,系统自动统计字数。用户每次按键输入,都通过观察者模式触发同步更新。
- 观察者模式追踪文本区域的变化
- 字数统计实时显示在输入框下方
- 箭头函数实现单行事件绑定
- 广播事件驱动变更给所有订阅者
字数统计函数
const getWordCount = (text) => text ? text.trim().split(/\s+/).length : 0;
单元测试示例
// 准备
const blogPost = 'This is a blog \n\n post with a word count. ';
// 执行
const count = getWordCount(blogPost);
// 验证
assert.strictEqual(count, 9);
注:该函数能处理多种边界情况,包括换行、多个空格等。
DOM 集成步骤
- HTML 结构
<textarea id="blogPost" placeholder="Enter your blog post..." class="blogPost">
</textarea>
- JavaScript 实现
// 创建字数显示元素
const wordCountElement = document.createElement('p');
wordCountElement.className = 'wordCount';
wordCountElement.innerHTML = 'Word Count: <strong id="blogWordCount">0</strong>';
document.body.appendChild(wordCountElement);
// 创建观察者实例
const blogObserver = new EventObserver();
// 订阅更新
blogObserver.subscribe((text) => {
const blogCount = document.getElementById('blogWordCount');
blogCount.textContent = getWordCount(text);
});
// 绑定事件
const blogPost = document.getElementById('blogPost');
blogPost.addEventListener('keyup', () => blogObserver.broadcast(blogPost.value));
扩展:RxJS
RxJS 是一个库,它通过使用 observable 序列来编写异步和基于事件的程序。它提供了一个核心类型 Observable,附属类型 (Observer、 Schedulers、 Subjects) 和受 [Array#extras] 启发的操作符 (map、filter、reduce、every, 等等),这些数组操作符可以把异步事件作为集合来处理。
可以把
RxJS当做是用来处理事件的 Lodash 。
ReactiveX 结合了 观察者模式、迭代器模式 和 使用集合的函数式编程,以满足以一种理想方式来管理事件序列所需要的一切。
在 RxJS 中用来解决异步事件管理的的基本概念是:
- Observable (可观察对象): 表示一个概念,这个概念是一个可调用的未来值或事件的集合。
- Observer (观察者): 一个回调函数的集合,它知道如何去监听由 Observable 提供的值。
- Subscription (订阅): 表示 Observable 的执行,主要用于取消 Observable 的执行。
-
Operators (操作符): 采用函数式编程风格的纯函数 (pure function),使用像
map、filter、concat、flatMap等这样的操作符来处理集合。 -
Subject (主体): 相当于
EventEmitter,并且是将值或事件多路推送给多个 Observer 的唯一方式。 -
Schedulers (调度器): 用来控制并发并且是中央集权的调度员,允许我们在发生计算时进行协调,例如
setTimeout或requestAnimationFrame或其他。
结合观察者模式 + 迭代器模式 + 函数式编程
示例:监听鼠标拖拽事件
merge(
fromEvent(document, "mousedown").pipe(mapTo(false)),
fromEvent(document, "mousemove").pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, "mouseup")))
.subscribe(isDragging => console.log(isDragging));
优点
- 关注点分离
- 符合单一职责原则
- 观察者和被观察者低耦合
缺点
- 观察者过多时,通知所有观察者可能带来性能问题
六、解法
答案
<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8>
</head>
<body>
<script type="text/javascript">
class Observerd {
constructor(name){
this.name = name ;
this.state = '走路' ;
this.observers = [];
}
setObserver(observer){
this.observers.push (observer);
}
setState(state){
this.state = state ;
this.notifyAllObservers();
}
notifyAllObservers(){
this.observers.forEach(observer => {
observer.update(this);
});
}
}
class Observer {
update(observed){
console.log(`${observed.name}正在${observed.state}`);
}
}
</script>
</body>
</html>
根据题目要求,需要实现观察者模式。
以下是补全的代码:
class Observerd {
constructor(name) {
this.name = name;
this.state = "走路";
this.observers = [];
}
setObserver(observer) {
this.observers.push(observer);
}
setState(state) {
this.state = state;
this.notifyAllObservers();
}
notifyAllObservers() {
this.observers.forEach(observer => {
observer.update(this);
});
}
}
class Observer {
update(observed) {
console.log(`${observed.name}正在${observed.state}`);
}
}
代码说明:
-
Observerd(被观察者)类:
-
constructor(name):构造函数接收name参数,初始化name属性、state属性(初始值为"走路")和observers数组(存储观察者) -
setObserver(observer):添加观察者到observers数组 -
setState(state):更新state属性,并通知所有观察者 -
notifyAllObservers():遍历所有观察者,调用每个观察者的update方法,传入当前被观察者实例
-
-
Observer(观察者)类:
-
update(observed):接收被观察者对象,按照指定格式打印日志(如:"小明正在走路")
-
使用示例:
const observed = new Observerd("小明");
const observer = new Observer();
observed.setObserver(observer);
observed.setState("跑步"); // 控制台输出:小明正在跑步
七、总结
观察者模式与发布者-订阅者模式有何不同?
虽然两种模式都涉及一对多依赖关系,但关键区别在于主体(或发布者)与其观察者(或订阅者)之间的通信方式。
-
在观察者模式中,主体直接通知其观察者。
-
在发布-订阅模式中,发布者将通知发送到中介(或通道),然后由中介将通知推送给订阅者。
这种额外的抽象层使得通知过程更加灵活和可定制。
原文:
How does the Observer Pattern differ from the Publisher-Subscriber Pattern?
While both patterns involve one-to-many dependencies, the key difference lies in how the subject (or publisher) communicates with its observers (or subscribers). In the Observer Pattern, the subject directly notifies its observers. In the Publisher-Subscriber Pattern, the publisher sends notifications to a mediator (or channel), which then pushes the notifications to the subscribers. This extra level of abstraction allows for greater flexibility and customization of the notification process.
| 发布订阅模式 | 观察者模式 | |
|---|---|---|
| 有没有中间人 | 有(事件中心) | 没有(直接通知) |
| 双方知不知道对方存在 | 不知道(通过事件名交流) | 知道(被观察者存着观察者列表) |
| 生活类比 | 微信群:发消息的人不知道谁在看 | 你订阅了某人的微博:他更新了主动推给你 |
说实话,这两题面试手撕代码题实际上背负了很多抽象概念,单独的内容也都可以抽出来好好讲讲,难度并不低。
对于初学者建议按这个顺序来:
- 先熟悉上面的代码(应付面试)
- 然后自己手敲 3 遍(不要复制粘贴)
- 再去看本文的"应用场景"部分(这时候才有共鸣)
- 最后再去理解 RxJS、优缺点这些进阶内容
我也是初次深入学习一下这些概念,信息量大得有点懵。
但是回头看看我实际写过的项目代码,很多已经用到了这些思想,只是当时没有注意到这个模式。不妨现在好好回头去整理整理。
限于个人写作,文中若有疏漏,还请不吝赐教。
参考文档
发布订阅模式 vs 观察者模式:它们真的是一回事吗?本文深入解析发布订阅与观察者模式的核心差异:发布订阅通过事件中心实现 - 掘金
JavaScript Design Patterns: The Observer Pattern — SitePoint