发布订阅模式是前端面试的高频手写题,但大多数人只会写一个基础版的
on/emit。面试官真正想考的不是你会不会写,而是你写完之后能不能接住追问。我上次手写完 EventEmitter 后被连续追问了 6 个问题,第 4 个关于内存泄漏的问题当场没答上来。这篇文章把完整实现和 6 个追问全部写出来,下次遇到直接拿满分。
面试的时候不要上来就写"完美版",先快速写一个核心能跑的版本,再根据面试官的追问逐步完善。
class EventEmitter {
private events: Map<string, Set<Function>> = new Map();
on(event: string, listener: Function) {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(listener);
return this;
}
off(event: string, listener: Function) {
this.events.get(event)?.delete(listener);
return this;
}
emit(event: string, ...args: any[]) {
this.events.get(event)?.forEach(listener => {
listener(...args);
});
return this;
}
once(event: string, listener: Function) {
const wrapper = (...args: any[]) => {
listener(...args);
this.off(event, wrapper);
};
this.on(event, wrapper);
return this;
}
}
30 行,4 个核心方法:on(订阅)、off(取消)、emit(触发)、once(只触发一次)。
为什么用 Map + Set 而不是普通对象 + 数组?
| 数据结构 | 查找/删除 | 去重 | 说明 |
|---|---|---|---|
| 对象 + 数组 | O(n) | 需手动判断 | 删除要 splice,性能差 |
| Map + Set | O(1) | 自动去重 |
delete 直接删,不用遍历 |
写完这个版本,面试官会满意吗?不会。追问才刚开始。
你可能注意到了,每个方法最后都 return this。这不是多余的——它让你可以这样写:
const emitter = new EventEmitter();
emitter
.on('login', user => console.log(`${user} 登录了`))
.on('logout', user => console.log(`${user} 登出了`))
.once('firstVisit', () => console.log('首次访问'));
面试加分点: 提一句"jQuery、RxJS、Promise 都用了这个模式——叫 Fluent Interface(流式接口)"。
once 的核心是用一个 wrapper 函数把原始 listener 包了一层:
once(event: string, listener: Function) {
const wrapper = (...args: any[]) => {
listener(...args); // 先执行原始回调
this.off(event, wrapper); // 再把 wrapper 从事件列表中删掉
};
this.on(event, wrapper); // 注册的是 wrapper,不是 listener
}
为什么不能直接 this.off(event, listener)? 因为你注册的是 wrapper,事件列表里存的也是 wrapper。如果你 off(listener),找不到匹配项,删不掉。
追问陷阱: 面试官可能会问"如果我在 once 回调执行之前就手动 off 这个 listener,会发生什么?"
答:off(event, listener) 找不到(因为注册的是 wrapper),不会删除。要解决这个问题,需要在 wrapper 上挂一个原始引用:
once(event: string, listener: Function) {
const wrapper = (...args: any[]) => {
listener(...args);
this.off(event, wrapper);
};
wrapper._original = listener; // 保存原始引用
this.on(event, wrapper);
}
off(event: string, listener: Function) {
const listeners = this.events.get(event);
if (!listeners) return this;
for (const fn of listeners) {
if (fn === listener || fn._original === listener) {
listeners.delete(fn);
break;
}
}
return this;
}
这个细节能答上来,面试官会认为你对设计模式的理解不是停留在"背代码"层面。
当前实现:不会。 因为 forEach 中某个 listener 抛异常后,整个循环就中断了。
emitter.on('data', () => { throw new Error('boom'); });
emitter.on('data', () => console.log('我不会执行'));
emitter.emit('data'); // 第二个 listener 被跳过了
解决方案: 每个 listener 独立 try-catch。
emit(event: string, ...args: any[]) {
this.events.get(event)?.forEach(listener => {
try {
listener(...args);
} catch (error) {
console.error(`Event "${event}" listener error:`, error);
}
});
return this;
}
延伸: Node.js 的 EventEmitter 不会帮你 catch,它会直接抛出。如果没有监听 error 事件,进程会崩。这就是为什么 Node.js 里经常看到 emitter.on('error', handler) 这种写法——它是兜底用的。
会。 这是我当时没答上来的问题。
场景:一个 React 组件在 useEffect 里注册了事件,但组件卸载时没有 off。
// ❌ 内存泄漏
useEffect(() => {
emitter.on('update', handleUpdate);
// 组件卸载了,但 handleUpdate 还在 emitter 的事件列表里
// emitter 持有 handleUpdate 的引用 → handleUpdate 持有组件闭包的引用 → 组件无法被 GC
}, []);
// ✅ 正确写法
useEffect(() => {
emitter.on('update', handleUpdate);
return () => emitter.off('update', handleUpdate);
}, []);
面试加分答法:
"EventEmitter 的内存泄漏本质是引用链问题:emitter → listener → 闭包 → 组件状态。解决方案有三个:
- 手动
off(最基本)- 用
WeakRef弱引用(进阶)- 用
AbortController统一管理生命周期(现代方案)"
如果面试官继续追问 AbortController 方案:
on(event: string, listener: Function, signal?: AbortSignal) {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
this.events.get(event)!.add(listener);
signal?.addEventListener('abort', () => {
this.off(event, listener);
});
return this;
}
// 使用
const controller = new AbortController();
emitter.on('update', handleUpdate, controller.signal);
// 组件卸载时一键取消所有事件
controller.abort();
这个 AbortController 方案和 fetch 取消请求是同一个 API,前端新标准正在往这个方向统一。
实际项目中,事件名经常需要层级结构:user.login、user.logout、order.create。
如果要支持 emitter.emit('user.*') 触发所有 user. 开头的事件:
emit(event: string, ...args: any[]) {
// 精确匹配
this.events.get(event)?.forEach(fn => {
try { fn(...args); } catch(e) { console.error(e); }
});
// 通配符匹配
if (event.includes('.')) {
const prefix = event.split('.')[0] + '.*';
this.events.get(prefix)?.forEach(fn => {
try { fn(...args); } catch(e) { console.error(e); }
});
}
return this;
}
不需要完整实现通配符匹配,面试中说出思路就够了。
| 特性 | 手写版 | Node.js EventEmitter |
|---|---|---|
| 最大监听数 | 无限制 | 默认 10 个,超过会警告(防泄漏) |
| 错误处理 | 手动 try-catch | 必须监听 error 事件,否则进程崩 |
prependListener |
不支持 | 支持(在队列头部插入) |
eventNames() |
不支持 | 返回所有已注册的事件名 |
listenerCount() |
不支持 | 返回指定事件的监听器数量 |
| 异步支持 | 不支持 | 本身是同步的,但生态有 EventEmitter2
|
答这个问题的关键是"最大监听数"。 Node.js 默认限制 10 个 listener,超过会打印警告:
MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
这不是 bug,是故意的——防止你忘了 off 导致内存泄漏。可以通过 emitter.setMaxListeners(20) 调整。
把 6 个追问的优化点都加上:
class EventEmitter {
private events: Map<string, Set<Function>> = new Map();
private maxListeners: number = 10;
on(event: string, listener: Function, signal?: AbortSignal) {
if (!this.events.has(event)) {
this.events.set(event, new Set());
}
const listeners = this.events.get(event)!;
if (listeners.size >= this.maxListeners) {
console.warn(`Warning: ${event} has ${listeners.size} listeners.`);
}
listeners.add(listener);
signal?.addEventListener('abort', () => this.off(event, listener));
return this;
}
off(event: string, listener: Function) {
const listeners = this.events.get(event);
if (!listeners) return this;
for (const fn of listeners) {
if (fn === listener || (fn as any)._original === listener) {
listeners.delete(fn);
break;
}
}
if (listeners.size === 0) this.events.delete(event);
return this;
}
emit(event: string, ...args: any[]) {
this.events.get(event)?.forEach(fn => {
try { fn(...args); } catch (e) { console.error(e); }
});
return this;
}
once(event: string, listener: Function) {
const wrapper = (...args: any[]) => {
listener(...args);
this.off(event, wrapper);
};
(wrapper as any)._original = listener;
this.on(event, wrapper);
return this;
}
setMaxListeners(n: number) {
this.maxListeners = n;
return this;
}
listenerCount(event: string): number {
return this.events.get(event)?.size ?? 0;
}
removeAllListeners(event?: string) {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
}
从 30 行的基础版到完整版,每一行新增代码都对应一个追问。面试时先写基础版,面试官追问时再逐步加——这比一上来就写完整版更能展示你的思维过程。
你面试中被手写题难住过吗?最难的是哪道?评论区聊聊。