手写发布订阅模式:从零实现 Event Emitter 的完整指南
大家好,欢迎来到今天的讲座。今天我们不聊框架、不谈算法,而是深入到 JavaScript 底层机制中,一起手写一个经典的 Event Emitter(事件发射器) 实现。它虽然看似简单,但却是 Node.js 核心模块、前端状态管理库(如 Redux、Vuex)、以及各类异步通信系统的基础。
你可能在项目中用过 eventEmitter.on('click', handler) 这样的代码,但你知道它是怎么工作的吗?我们今天的目标就是——亲手实现一套完整的 on、emit、off 和 once 方法,让你真正理解“发布-订阅”模式的本质。
一、什么是发布订阅模式?
发布订阅是一种行为设计模式,允许对象之间解耦地通信。它有两个角色:
| 角色 | 职责 |
|---|---|
| 发布者(Publisher) | 发送事件(emit),通知所有监听者 |
| 订阅者(Subscriber) | 监听特定事件(on),执行回调函数 |
这种模式的好处是:
- 解耦:发布者不需要知道谁在监听
- 灵活扩展:可以动态添加或移除监听器
- 支持多对多通信:一个事件可被多个监听者处理
Node.js 中的 EventEmitter 就是一个经典例子。我们的目标就是复刻它的核心功能。
二、基础结构设计
首先定义一个类 EventEmitter,内部维护一个事件映射表(Map 或 Object),用来存储每个事件对应的监听器数组。
class EventEmitter {
constructor() {
this.events = {};
}
// 后续方法将在这里实现
}
✅ 基础数据结构说明:
| 属性 | 类型 | 描述 |
|---|---|---|
events |
Object<string, Array<Function>> |
键为事件名,值为该事件的所有监听器数组 |
为什么用数组而不是 Set?
因为我们需要支持重复注册同一个回调(比如多次调用 on('foo', fn)),而且要能按顺序执行(FIFO)。Set 不适合记录重复项,而数组天然有序且可重复。
三、实现 on(event, listener) —— 添加监听器
这是最基础的方法,用于注册某个事件的监听器。
on(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
return this; // 支持链式调用
}
💡 注意点:
- 如果事件不存在,创建空数组;
- 使用
push添加监听器; - 返回
this是为了支持链式调用,例如:emitter.on('a', fn).on('b', fn2)。
测试一下:
const emitter = new EventEmitter();
emitter.on('hello', () => console.log('Hello!'));
emitter.on('hello', () => console.log('Hi there!'));
emitter.emit('hello');
// 输出:
// Hello!
// Hi there!
✅ 成功!两个监听器都被触发了。
四、实现 emit(event, ...args) —— 触发事件
这个方法负责遍历指定事件的所有监听器并依次执行它们,同时传入参数。
emit(event, ...args) {
const listeners = this.events[event];
if (!listeners || listeners.length === 0) {
return false; // 没有监听器返回 false 表示未处理
}
listeners.forEach(listener => {
try {
listener.apply(this, args); // 保证 this 指向 emitter
} catch (err) {
console.error(`Listener error for event "${event}":`, err);
}
});
return true;
}
🔍 关键细节:
- 若无监听器直接返回
false,避免无效操作; - 使用
forEach遍历,确保顺序性; - 包裹
try-catch,防止单个监听器出错影响整体流程; apply(this, args)保持上下文一致性,即this在监听器内指向 EventEmitter 实例。
测试:
emitter.emit('hello', 'world');
// 输出:
// Hello!
// Hi there!
完美匹配预期!
五、实现 off(event, listener) —— 移除监听器
删除某个事件下的指定监听器,注意不能删错。
off(event, listener) {
const listeners = this.events[event];
if (!listeners) return this;
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
// 如果列表为空,可选地清理 key(非必须)
if (listeners.length === 0) {
delete this.events[event];
}
return this;
}
⚠️ 注意事项:
- 必须先判断是否存在该事件;
- 使用
indexOf找到索引,然后splice删除; - 删除后如果数组为空,可以主动删除 key(可选优化);
- 返回
this支持链式调用。
测试:
const fn1 = () => console.log('fn1');
const fn2 = () => console.log('fn2');
emitter.on('test', fn1);
emitter.on('test', fn2);
emitter.off('test', fn1); // 删除 fn1
emitter.emit('test'); // 只输出 fn2
✅ 正确移除指定监听器!
六、实现 once(event, listener) —— 仅执行一次的监听器
这是高级特性,常用于一次性任务,比如加载完成、连接建立等场景。
思路:包装原始监听器,在第一次执行后自动移除自己。
once(event, listener) {
const onceWrapper = (...args) => {
this.off(event, onceWrapper); // 自动移除自身
listener.apply(this, args); // 执行原监听器
};
this.on(event, onceWrapper);
return this;
}
📌 核心逻辑:
- 创建一个新的包装函数
onceWrapper; - 它会先调用
off移除自己,再执行原始监听器; - 然后把这个 wrapper 注册为监听器;
- 这样就实现了“只运行一次”的效果。
测试:
let count = 0;
const handler = () => {
count++;
console.log(`Called ${count} times`);
};
emitter.once('single', handler);
emitter.emit('single'); // 输出: Called 1 times
emitter.emit('single'); // 无输出(已移除)
emitter.emit('single'); // 无输出
✅ 完美!只执行了一次。
七、增强版:支持通配符和命名空间(进阶)
虽然不是必须,但我们也可以扩展一些实用功能,比如:
- 通配符监听:如
*匹配所有事件; - 命名空间:如
'user.login'、'user.logout',便于组织事件。
但这部分属于进阶内容,建议先掌握基础版本后再考虑添加。
🧠 提示:如果你正在开发大型应用,这类能力非常有用,比如 React 的 Context API 或 Vue 的 EventBus 都会用到类似机制。
八、完整代码整合与使用示例
下面是最终的完整实现:
class EventEmitter {
constructor() {
this.events = {};
}
on(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
return this;
}
emit(event, ...args) {
const listeners = this.events[event];
if (!listeners || listeners.length === 0) {
return false;
}
listeners.forEach(listener => {
try {
listener.apply(this, args);
} catch (err) {
console.error(`Listener error for event "${event}":`, err);
}
});
return true;
}
off(event, listener) {
const listeners = this.events[event];
if (!listeners) return this;
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length === 0) {
delete this.events[event];
}
return this;
}
once(event, listener) {
const onceWrapper = (...args) => {
this.off(event, onceWrapper);
listener.apply(this, args);
};
this.on(event, onceWrapper);
return this;
}
}
// 使用示例
const emitter = new EventEmitter();
emitter.on('greet', name => console.log(`Hello, ${name}!`));
emitter.once('once', () => console.log('This runs only once.'));
emitter.on('greet', name => console.log(`Welcome, ${name}!`));
emitter.emit('greet', 'Alice'); // Hello, Alice! Welcome, Alice!
emitter.emit('once'); // This runs only once.
emitter.emit('once'); // No output
九、常见问题与陷阱解析
| 问题 | 解释 | 如何解决 |
|---|---|---|
| 内存泄漏? | 如果忘记 off,可能导致监听器无法释放 |
推荐配合 once 或手动管理生命周期 |
| 错误传播? | 单个监听器报错导致整个事件中断 | 使用 try-catch 包裹每个监听器 |
| this 上下文混乱? | 监听器中的 this 不等于 EventEmitter |
使用 .apply(this, args) 显式绑定 |
| 性能瓶颈? | 大量监听器时遍历开销大 | 对于高频事件,可考虑分组或限制最大数量 |
| 重复注册? | 多次调用 on 导致冗余监听器 |
可加去重逻辑(如用 Set 存储监听器引用) |
✅ 总结:这些都不是致命缺陷,而是需要开发者根据业务场景权衡取舍的问题。
十、实际应用场景举例
-
前端状态管理
const store = new EventEmitter(); store.on('stateChange', newState => updateUI(newState)); -
WebSocket 通信封装
class Socket extends EventEmitter { connect() { this.emit('connect'); } onData(data) { this.emit('data', data); } } -
插件架构
const pluginManager = new EventEmitter(); pluginManager.on('pluginLoaded', plugin => registerPlugin(plugin)); -
日志系统
const logger = new EventEmitter(); logger.once('start', () => log('App started'));
你会发现,几乎任何需要“事件驱动”的地方都可以用它来简化设计。
十一、总结与升华
今天我们从头开始构建了一个完整的 Event Emitter,涵盖了四个核心方法:
| 方法 | 功能 | 是否推荐 |
|---|---|---|
on |
注册监听器 | ✅ 必备 |
emit |
触发事件 | ✅ 必备 |
off |
移除监听器 | ✅ 必备 |
once |
一次性监听器 | ✅ 强烈推荐 |
通过这次实践,你应该已经掌握了:
- 事件系统的底层原理;
- 如何优雅地处理异常;
- 如何让代码更健壮、易维护;
- 以及如何将其融入真实项目中。
这不是一个简单的“玩具代码”,而是你在未来工作中随时可能用到的核心技能之一。
📌 最后一句忠告:
“不要害怕复杂的设计,要学会拆解问题。”
发布订阅模式看似简单,但它背后体现的是解耦思想和事件驱动编程范式——这正是现代软件工程的灵魂所在。
希望今天的讲解对你有所启发,也欢迎你在评论区分享你的理解和改进方案!我们下次再见!