手写 EventEmitter
问题
实现一个 EventEmitter 类,支持 on、off、emit、once 等方法。这是前端面试中出现频率极高的手写题,也是理解发布订阅模式的核心实践。
答案
EventEmitter(事件发射器)是一种发布-订阅机制的实现,广泛应用于 Node.js 核心模块、前端组件通信、插件系统等场景。其核心思想是:维护一个事件名 -> 监听器列表的映射,通过 emit 触发事件时依次调用所有注册的监听器。
| 方法 | 作用 |
|---|---|
on(event, listener) | 注册监听器 |
off(event, listener) | 移除监听器 |
emit(event, ...args) | 触发事件 |
once(event, listener) | 注册一次性监听器 |
基础版本
type Listener = (...args: any[]) => void;
class EventEmitter {
private events: Map<string, Listener[]> = new Map();
/**
* 注册事件监听器
*/
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this; // 支持链式调用
}
/**
* 移除指定事件的监听器
*/
off(event: string, listener: Listener): this {
const listeners = this.events.get(event);
if (!listeners) return this;
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
// 如果监听器列表为空,删除该事件
if (listeners.length === 0) {
this.events.delete(event);
}
return this;
}
/**
* 触发事件,调用所有监听器
*/
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
// 使用副本遍历,防止 once 回调中 off 导致数组变化
const copy = [...listeners];
copy.forEach((listener) => {
listener(...args);
});
return true;
}
/**
* 注册一次性监听器,触发后自动移除
*/
once(event: string, listener: Listener): this {
// 包装函数:执行后立即 off
const wrapper: Listener = (...args: any[]) => {
listener(...args);
this.off(event, wrapper);
};
// 保存原始引用,方便 off 时用原始函数移除
wrapper._original = listener;
return this.on(event, wrapper);
}
/**
* 移除某个事件的所有监听器,或移除所有事件
*/
removeAllListeners(event?: string): this {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
return this;
}
/**
* 获取某个事件的监听器数量
*/
listenerCount(event: string): number {
return this.events.get(event)?.length || 0;
}
/**
* 获取所有已注册的事件名
*/
eventNames(): string[] {
return [...this.events.keys()];
}
}
once 注册的监听器被包装成了 wrapper,如果用户在触发前调用 off(event, originalListener) 来移除,直接 indexOf 是找不到的,因为数组中存的是 wrapper 而不是 originalListener。需要在 off 中额外检查 _original 属性:
off(event: string, listener: Listener): this {
const listeners = this.events.get(event);
if (!listeners) return this;
const index = listeners.findIndex(
(fn) => fn === listener || (fn as any)._original === listener
);
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length === 0) {
this.events.delete(event);
}
return this;
}
使用示例:
const emitter = new EventEmitter();
// on:注册监听器
emitter.on('data', (msg: string) => {
console.log('收到数据:', msg);
});
// once:只触发一次
emitter.once('connect', () => {
console.log('连接成功(只打印一次)');
});
// emit:触发事件
emitter.emit('data', 'hello'); // 收到数据: hello
emitter.emit('connect'); // 连接成功(只打印一次)
emitter.emit('connect'); // 不触发,已自动移除
// off:移除监听器
const handler = (msg: string) => console.log(msg);
emitter.on('log', handler);
emitter.off('log', handler);
emitter.emit('log', 'test'); // 不触发,已移除
TypeScript 泛型版本
基础版本中 on('data', listener) 的参数类型是 any,无法获得类型提示。通过泛型约束可以实现完全类型安全的事件系统,这也是面试中的加分项。
关于 TypeScript 泛型和工具类型的基础知识,可参考 泛型 和 工具类型。
// 定义事件映射类型:事件名 -> 回调函数签名
interface EventMap {
[event: string]: (...args: any[]) => void;
}
class TypedEventEmitter<T extends EventMap> {
private events = new Map<keyof T, Function[]>();
// K 约束为 T 的 key,listener 约束为 T[K] 对应的回调类型
on<K extends keyof T>(event: K, listener: T[K]): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
off<K extends keyof T>(event: K, listener: T[K]): this {
const listeners = this.events.get(event);
if (!listeners) return this;
const index = listeners.findIndex(
(fn) => fn === listener || (fn as any)._original === listener
);
if (index !== -1) listeners.splice(index, 1);
if (listeners.length === 0) this.events.delete(event);
return this;
}
// Parameters<T[K]> 自动提取回调函数的参数类型
emit<K extends keyof T>(event: K, ...args: Parameters<T[K]>): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
[...listeners].forEach((fn) => fn(...args));
return true;
}
once<K extends keyof T>(event: K, listener: T[K]): this {
const wrapper = ((...args: any[]) => {
(listener as Function)(...args);
this.off(event, wrapper as unknown as T[K]);
}) as unknown as T[K];
(wrapper as any)._original = listener;
return this.on(event, wrapper);
}
}
使用示例 -- 完全类型安全:
// 定义事件类型
interface AppEvents {
login: (userId: string, token: string) => void;
logout: () => void;
message: (from: string, content: string, timestamp: number) => void;
error: (err: Error) => void;
}
const emitter = new TypedEventEmitter<AppEvents>();
// 类型安全:参数类型自动推断
emitter.on('login', (userId, token) => {
// userId: string, token: string -- 自动推断
console.log(`${userId} 已登录,token: ${token}`);
});
// 类型错误示例
// emitter.emit('login', 123); // Error: 参数类型不匹配
// emitter.on('unknown', () => {}); // Error: 事件名不存在
// emitter.emit('message', 'alice'); // Error: 缺少参数
// 正确调用
emitter.emit('login', 'user123', 'abc-token');
emitter.emit('message', 'alice', 'hello', Date.now());
- 事件名自动补全:IDE 会提示所有可用的事件名
- 参数类型检查:
emit时传错参数类型会编译报错 - 回调参数推断:
on的回调函数参数自动获得正确类型 - 重构友好:修改事件定义后,所有使用处都会报类型错误
进阶功能
1. 最大监听器数量限制
防止因忘记 off 而导致内存泄漏,Node.js 默认限制为 10 个:
class EventEmitter {
private maxListeners: number = 10;
private events: Map<string, Listener[]> = new Map();
setMaxListeners(n: number): this {
this.maxListeners = n;
return this;
}
getMaxListeners(): number {
return this.maxListeners;
}
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
if (listeners.length >= this.maxListeners) {
console.warn(
`MaxListenersExceededWarning: ${listeners.length + 1} "${event}" ` +
`listeners added. Use emitter.setMaxListeners() to increase limit.`
);
}
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
}
2. 命名空间与通配符
支持 'user.login'、'user.*' 等命名空间模式:
class NamespacedEmitter {
private events: Map<string, Listener[]> = new Map();
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
emit(event: string, ...args: any[]): boolean {
let triggered = false;
this.events.forEach((listeners, key) => {
// 精确匹配 或 通配符匹配
if (key === event || this.matchWildcard(key, event)) {
listeners.forEach((fn) => fn(...args));
triggered = true;
}
});
return triggered;
}
/**
* 通配符匹配:'user.*' 匹配 'user.login'、'user.logout'
* '*' 匹配所有事件
*/
private matchWildcard(pattern: string, event: string): boolean {
if (pattern === '*') return true;
if (!pattern.includes('*')) return false;
const regex = new RegExp(
'^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$'
);
return regex.test(event);
}
}
// 使用示例
const emitter = new NamespacedEmitter();
emitter.on('user.*', (data) => {
console.log('用户事件:', data);
});
emitter.emit('user.login', { id: 1 }); // 匹配 user.*
emitter.emit('user.logout', { id: 1 }); // 匹配 user.*
emitter.emit('order.create', {}); // 不匹配
3. 异步事件
支持异步监听器,emitAsync 返回 Promise:
class AsyncEventEmitter {
private events: Map<string, Listener[]> = new Map();
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
/**
* 串行执行异步监听器
*/
async emitAsync(event: string, ...args: any[]): Promise<boolean> {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
for (const listener of [...listeners]) {
await listener(...args);
}
return true;
}
/**
* 并行执行异步监听器(更快但不保证顺序)
*/
async emitParallel(event: string, ...args: any[]): Promise<boolean> {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
await Promise.allSettled(
[...listeners].map((listener) => listener(...args))
);
return true;
}
}
// 使用示例
const emitter = new AsyncEventEmitter();
emitter.on('save', async (data: string) => {
await saveToDatabase(data);
console.log('保存到数据库完成');
});
emitter.on('save', async (data: string) => {
await sendNotification(data);
console.log('发送通知完成');
});
// 串行执行:先保存数据库,再发送通知
await emitter.emitAsync('save', 'hello');
4. 优先级监听器
支持 prependListener,将监听器添加到列表头部:
class PriorityEventEmitter {
private events: Map<string, Listener[]> = new Map();
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
/**
* 将监听器添加到列表头部,优先执行
* 与 Node.js EventEmitter.prependListener 一致
*/
prependListener(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.unshift(listener);
this.events.set(event, listeners);
return this;
}
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
[...listeners].forEach((fn) => fn(...args));
return true;
}
}
5. 错误处理
Node.js 约定:如果 error 事件没有注册监听器,emit error 时会直接抛出异常:
class EventEmitter {
private events: Map<string, Listener[]> = new Map();
emit(event: string, ...args: any[]): boolean {
// 特殊处理:error 事件没有监听器时抛出异常
if (event === 'error') {
const listeners = this.events.get('error');
if (!listeners || listeners.length === 0) {
const err = args[0];
if (err instanceof Error) {
throw err;
}
throw new Error('Unhandled error event');
}
}
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
[...listeners].forEach((fn) => fn(...args));
return true;
}
}
Node.js EventEmitter 源码分析
Node.js 内置的 EventEmitter 是整个 Node.js 事件驱动架构的基石,几乎所有核心模块(http、fs、stream)都继承自它。
- 存储结构:内部使用
Object.create(null)而非Map,避免原型链干扰 - 单个监听器优化:如果事件只有一个监听器,直接存函数而非数组,减少内存分配
- _events 和 _eventsCount:直接在实例上维护,避免 Map 的开销
- newListener / removeListener 事件:注册/移除监听器时会触发对应元事件
// Node.js EventEmitter 核心结构(简化版)
class NodeEventEmitter {
// 使用纯对象而非 Map,性能更好
private _events: Record<string, Listener | Listener[]> = Object.create(null);
private _eventsCount: number = 0;
private _maxListeners: number = 10;
on(event: string, listener: Listener): this {
const existing = this._events[event];
if (!existing) {
// 第一个监听器直接存函数,不创建数组
this._events[event] = listener;
this._eventsCount++;
} else if (typeof existing === 'function') {
// 第二个监听器时转为数组
this._events[event] = [existing, listener];
} else {
existing.push(listener);
}
return this;
}
emit(event: string, ...args: any[]): boolean {
const handler = this._events[event];
if (!handler) return false;
if (typeof handler === 'function') {
// 单个监听器:直接调用,避免数组遍历开销
handler(...args);
} else {
const len = handler.length;
const listeners = [...handler]; // 副本,防止回调中修改
for (let i = 0; i < len; i++) {
listeners[i](...args);
}
}
return true;
}
/**
* 获取监听器列表(始终返回数组)
*/
listeners(event: string): Listener[] {
const handler = this._events[event];
if (!handler) return [];
return typeof handler === 'function' ? [handler] : [...handler];
}
/**
* 获取监听器数量
*/
static listenerCount(emitter: NodeEventEmitter, event: string): number {
const handler = emitter._events[event];
if (!handler) return 0;
return typeof handler === 'function' ? 1 : handler.length;
}
/**
* 获取所有注册事件名
*/
eventNames(): string[] {
return Object.keys(this._events);
}
}
Node.js EventEmitter 完整 API 速查:
| API | 说明 |
|---|---|
on(event, listener) | 添加监听器到末尾 |
addListener(event, listener) | on 的别名 |
prependListener(event, listener) | 添加监听器到头部 |
once(event, listener) | 添加一次性监听器 |
prependOnceListener(event, listener) | 一次性 + 头部 |
off(event, listener) | 移除监听器 |
removeListener(event, listener) | off 的别名 |
removeAllListeners([event]) | 移除所有监听器 |
emit(event, ...args) | 触发事件 |
listeners(event) | 获取监听器数组副本 |
rawListeners(event) | 包含 once 包装器的原始列表 |
listenerCount(event) | 监听器数量 |
eventNames() | 所有事件名 |
setMaxListeners(n) | 设置最大监听器数 |
getMaxListeners() | 获取最大监听器数 |
实际应用场景
1. 组件通信(Vue EventBus)
在 Vue 2 中,常用 EventBus 实现跨组件通信(Vue 3 已移除,推荐使用 mitt):
import mitt from 'mitt';
// 定义事件类型
type Events = {
'cart:add': { productId: string; quantity: number };
'cart:remove': { productId: string };
'user:login': { userId: string };
};
export const bus = mitt<Events>();
// 组件 A:发送事件
bus.emit('cart:add', { productId: 'p001', quantity: 2 });
// 组件 B:监听事件
bus.on('cart:add', (data) => {
console.log(`添加商品 ${data.productId},数量 ${data.quantity}`);
});
2. WebSocket 消息分发
interface WsEvents {
message: (data: any) => void;
notification: (msg: string) => void;
error: (err: Error) => void;
close: (code: number, reason: string) => void;
}
class WebSocketClient extends TypedEventEmitter<WsEvents> {
private ws: WebSocket;
constructor(url: string) {
super();
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 根据消息类型分发到不同事件
switch (data.type) {
case 'message':
this.emit('message', data.payload);
break;
case 'notification':
this.emit('notification', data.payload);
break;
}
};
this.ws.onerror = () => this.emit('error', new Error('WebSocket error'));
this.ws.onclose = (e) => this.emit('close', e.code, e.reason);
}
}
3. 插件系统
利用 EventEmitter 实现生命周期钩子,是 SDK 和框架设计的常见模式。更多关于 SDK 架构的内容,可参考前端 SDK 通用架构设计。
interface PluginHooks {
'before:request': (config: RequestConfig) => void;
'after:response': (response: Response) => void;
'error': (err: Error) => void;
}
class HttpClient extends TypedEventEmitter<PluginHooks> {
async request(config: RequestConfig): Promise<Response> {
// 触发请求前钩子(插件可以修改 config)
this.emit('before:request', config);
try {
const response = await fetch(config.url, config);
this.emit('after:response', response);
return response;
} catch (err) {
this.emit('error', err as Error);
throw err;
}
}
}
// 注册插件
const client = new HttpClient();
client.on('before:request', (config) => {
config.headers = { ...config.headers, Authorization: 'Bearer xxx' };
});
client.on('after:response', (response) => {
console.log(`请求完成: ${response.status}`);
});
4. DOM 事件模拟
在非浏览器环境(如 Node.js、Web Worker)中模拟 DOM 事件行为:
// 模拟 DOM EventTarget 接口
class EventTarget {
private emitter = new EventEmitter();
addEventListener(type: string, listener: Listener): void {
this.emitter.on(type, listener);
}
removeEventListener(type: string, listener: Listener): void {
this.emitter.off(type, listener);
}
dispatchEvent(type: string, event?: any): boolean {
return this.emitter.emit(type, event);
}
}
EventEmitter vs DOM addEventListener
| 特性 | EventEmitter | DOM addEventListener |
|---|---|---|
| 环境 | Node.js / 通用 | 浏览器 DOM |
| 事件传播 | 无冒泡/捕获 | 冒泡、捕获阶段 |
| 事件对象 | 无固定格式,自定义参数 | 标准 Event 对象 |
| 返回值 | emit 返回 boolean | dispatchEvent 返回 boolean |
| 移除方式 | off(event, fn) | removeEventListener(event, fn) |
| 一次性 | once() 方法 | { once: true } 选项 |
| 阻止默认 | 无 | preventDefault() |
| 停止传播 | 无 | stopPropagation() |
| 最大监听器 | 默认 10,可配置 | 无限制 |
更多关于 DOM 事件机制的内容,可参考浏览器事件机制。
开源库对比:mitt vs tiny-emitter vs EventEmitter3
| 特性 | mitt | tiny-emitter | EventEmitter3 |
|---|---|---|---|
| 体积 | ~200B | ~500B | ~1KB |
| TypeScript | 原生支持 | 需 @types | 原生支持 |
通配符 * | 支持 | 不支持 | 不支持 |
once | 不支持(需手动) | 支持 | 支持 |
| 命名空间 | 不支持 | 不支持 | 不支持 |
| Node.js 兼容 API | 否 | 否 | 是 |
- 极致体积:mitt(~200B),适合浏览器场景
- 功能完整:EventEmitter3,兼容 Node.js API
- Node.js:直接使用内置
events模块
常见面试问题
Q1: 手写一个基础的 EventEmitter,支持 on/off/emit/once
答案:
这是最基础也是最高频的考点。核心是用 Map<string, Function[]> 存储事件映射:
type Listener = (...args: any[]) => void;
class EventEmitter {
private events: Map<string, Listener[]> = new Map();
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
off(event: string, listener: Listener): this {
const listeners = this.events.get(event);
if (!listeners) return this;
const index = listeners.findIndex(
(fn) => fn === listener || (fn as any)._original === listener
);
if (index !== -1) listeners.splice(index, 1);
if (listeners.length === 0) this.events.delete(event);
return this;
}
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
[...listeners].forEach((fn) => fn(...args)); // 用副本遍历
return true;
}
once(event: string, listener: Listener): this {
const wrapper: Listener = (...args) => {
listener(...args);
this.off(event, wrapper);
};
wrapper._original = listener;
return this.on(event, wrapper);
}
}
面试时注意三个关键点:
emit遍历时使用副本([...listeners]),防止once中off导致数组变化once的wrapper需要保存_original引用,使off能通过原始函数移除- 返回
this支持链式调用
Q2: once 方法如何实现?有什么需要注意的?
答案:
once 的核心思路是用一个 wrapper 包装原始 listener,在 wrapper 内部执行完 listener 后立即调用 off 移除自身。
once(event: string, listener: Listener): this {
const wrapper: Listener = (...args) => {
listener(...args); // 执行原始回调
this.off(event, wrapper); // 自动移除
};
wrapper._original = listener; // 保存原始引用
return this.on(event, wrapper);
}
需要注意的问题:
- off 兼容性:用户可能在事件触发前用原始函数调用
off,此时数组中存的是wrapper而不是原始函数,必须在off中检查_original属性 - emit 副本遍历:
once的wrapper执行时会调用off修改数组,如果emit直接遍历原数组会导致跳过元素 - 内存泄漏:如果
once注册的事件一直没触发,wrapper和原始listener都会被保留在内存中
Q3: EventEmitter 如何实现 TypeScript 类型安全?
答案:
通过泛型约束 EventMap 类型,让事件名和回调参数都有类型检查:
// 1. 定义事件映射接口
interface EventMap {
[event: string]: (...args: any[]) => void;
}
// 2. 泛型类,T 约束为 EventMap
class TypedEventEmitter<T extends EventMap> {
private events = new Map<keyof T, Function[]>();
// K extends keyof T => 事件名只能是 T 中定义的 key
// T[K] => 回调类型自动关联
on<K extends keyof T>(event: K, listener: T[K]): this {
/* ... */
return this;
}
// Parameters<T[K]> => 自动提取回调的参数类型
emit<K extends keyof T>(event: K, ...args: Parameters<T[K]>): boolean {
/* ... */
return true;
}
}
// 3. 使用
interface ChatEvents {
join: (room: string, user: string) => void;
leave: (room: string) => void;
message: (from: string, text: string) => void;
}
const chat = new TypedEventEmitter<ChatEvents>();
chat.on('join', (room, user) => {}); // room: string, user: string
chat.emit('join', 'room1', 'alice'); // 参数类型安全
// chat.emit('join', 123); // Error!
核心技巧:
keyof T约束事件名枚举T[K]关联具体回调类型Parameters<T[K]>提取参数元组类型
Q4: EventEmitter 和 DOM addEventListener 有什么区别?
答案:
| 维度 | EventEmitter | DOM addEventListener |
|---|---|---|
| 事件传播 | 无冒泡/捕获机制,直接调用监听器 | 支持冒泡(bubble)和捕获(capture)阶段 |
| 事件对象 | 无标准格式,emit 参数直接透传 | 接收标准 Event 对象,含 target、currentTarget 等 |
| 阻止行为 | 无内置机制 | preventDefault()、stopPropagation() |
| 执行环境 | Node.js / 通用 JavaScript | 浏览器 DOM 环境 |
| 一次性 | once() 方法 | addEventListener('click', fn, { once: true }) |
| 上限保护 | maxListeners 默认 10 个 | 无限制,但浏览器有性能监控 |
| 错误处理 | error 事件无监听器时抛异常 | 不自动抛异常 |
| this 绑定 | 默认绑定 emitter 实例 | 默认绑定触发事件的 DOM 元素 |
本质区别:EventEmitter 是纯逻辑层的事件系统,适合任意 JavaScript 环境;DOM addEventListener 是浏览器 DOM 专用的事件系统,与 DOM 树结构深度耦合。
Q5: 如何防止 EventEmitter 内存泄漏?
答案:
EventEmitter 导致内存泄漏的根本原因是监听器未被正确移除,常见场景:
// 典型泄漏场景:组件销毁时未移除监听
class Component {
constructor(emitter: EventEmitter) {
// 每次创建组件都添加监听,但从未移除
emitter.on('data', this.handleData);
}
handleData = (data: any) => {
// 引用了 this,导致组件无法被 GC
};
}
防护措施:
- maxListeners 告警:设置上限,超出时打印警告
emitter.setMaxListeners(10); // Node.js 默认值
- 及时 off:组件销毁时清理所有监听
class Component {
private cleanups: Array<() => void> = [];
mount(emitter: EventEmitter) {
const handler = (data: any) => console.log(data);
emitter.on('data', handler);
this.cleanups.push(() => emitter.off('data', handler));
}
destroy() {
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
}
- WeakRef 弱引用(高级):监听器持有弱引用,对象被 GC 后自动失效
class SafeEventEmitter {
private events = new Map<string, Array<WeakRef<Function>>>();
on(event: string, listener: Function): this {
const listeners = this.events.get(event) || [];
listeners.push(new WeakRef(listener));
this.events.set(event, listeners);
return this;
}
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners) return false;
// 遍历时清理已被 GC 的引用
const alive = listeners.filter((ref) => {
const fn = ref.deref();
if (fn) {
(fn as Function)(...args);
return true;
}
return false; // 已被 GC,移除
});
this.events.set(event, alive);
return alive.length > 0;
}
}
关于内存管理和垃圾回收的更多知识,可参考 V8 垃圾回收与内存泄露。
Q6: 如何实现事件命名空间和通配符匹配?
答案:
命名空间通过 . 分隔层级,通配符 * 匹配单层,** 匹配多层:
class NamespacedEmitter {
private events = new Map<string, Listener[]>();
on(event: string, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
emit(event: string, ...args: any[]): boolean {
let triggered = false;
this.events.forEach((listeners, pattern) => {
if (this.matches(pattern, event)) {
[...listeners].forEach((fn) => fn(...args));
triggered = true;
}
});
return triggered;
}
private matches(pattern: string, event: string): boolean {
// 精确匹配
if (pattern === event) return true;
// 全局通配
if (pattern === '*' || pattern === '**') return true;
const patternParts = pattern.split('.');
const eventParts = event.split('.');
let pi = 0;
let ei = 0;
while (pi < patternParts.length && ei < eventParts.length) {
if (patternParts[pi] === '**') {
// ** 匹配剩余所有层级
return true;
}
if (patternParts[pi] === '*') {
// * 匹配当前层级任意值
pi++;
ei++;
continue;
}
if (patternParts[pi] !== eventParts[ei]) {
return false;
}
pi++;
ei++;
}
return pi === patternParts.length && ei === eventParts.length;
}
}
// 使用示例
const emitter = new NamespacedEmitter();
emitter.on('user.*', (data) => console.log('用户操作:', data));
emitter.on('user.**', (data) => console.log('所有用户事件:', data));
emitter.on('*.login', (data) => console.log('登录事件:', data));
emitter.emit('user.login', { id: 1 });
// 输出:
// 用户操作: { id: 1 } -- 匹配 user.*
// 所有用户事件: { id: 1 } -- 匹配 user.**
// 登录事件: { id: 1 } -- 匹配 *.login
Q7: EventEmitter 中 emit 时如果某个 listener 报错了怎么办?
答案:
默认情况下,如果某个 listener 抛出异常,后续的 listener 不会被执行。需要用 try-catch 包裹每个调用:
class SafeEventEmitter {
private events = new Map<string, Listener[]>();
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
for (const listener of [...listeners]) {
try {
listener(...args);
} catch (err) {
// 方案 1:触发 error 事件(Node.js 风格)
if (event !== 'error') {
this.emit('error', err);
} else {
// error 事件本身报错,避免无限递归
console.error('Error in error handler:', err);
}
}
}
return true;
}
}
如果在 error 事件的处理函数中又抛出异常,递归调用 emit('error') 会导致死循环。必须对 event === 'error' 的情况做特殊处理。
三种错误处理策略对比:
| 策略 | 优点 | 缺点 |
|---|---|---|
| 不 catch(默认) | 简单,错误不会被吞 | 一个 listener 报错,后续都不执行 |
| try-catch + error 事件 | Node.js 标准做法,统一错误处理 | 需防止递归 |
| try-catch + 忽略 | 所有 listener 都能执行 | 错误被静默吞掉 |
Q8: 如何实现异步 EventEmitter?
答案:
标准 EventEmitter 的 emit 是同步的。对于需要等待异步操作的场景,可以实现 emitAsync:
type AsyncListener = (...args: any[]) => void | Promise<void>;
class AsyncEventEmitter {
private events = new Map<string, AsyncListener[]>();
on(event: string, listener: AsyncListener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
/**
* 串行执行:按注册顺序依次 await
* 适合有顺序依赖的场景(如中间件管线)
*/
async emitSerial(event: string, ...args: any[]): Promise<boolean> {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
for (const listener of [...listeners]) {
await listener(...args);
}
return true;
}
/**
* 并行执行:所有 listener 同时开始
* 适合无依赖关系的场景
*/
async emitParallel(event: string, ...args: any[]): Promise<boolean> {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
const results = await Promise.allSettled(
[...listeners].map((fn) => fn(...args))
);
// 检查是否有失败的
const errors = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map((r) => r.reason);
if (errors.length > 0) {
throw new AggregateError(errors, 'Some listeners failed');
}
return true;
}
}
// 使用示例
const emitter = new AsyncEventEmitter();
emitter.on('save', async (data) => {
await db.save(data); // 耗时 200ms
});
emitter.on('save', async (data) => {
await cache.invalidate(); // 耗时 100ms
});
// 串行:200ms + 100ms = 300ms
await emitter.emitSerial('save', { id: 1 });
// 并行:max(200ms, 100ms) = 200ms
await emitter.emitParallel('save', { id: 1 });
Q9: Vue 3 为什么移除了 off/$once?用什么替代?
答案:
Vue 2 中 $on/$off/$once 允许任何组件实例充当 EventBus:
// Vue 2 EventBus(已废弃)
const bus = new Vue();
bus.$on('event', handler);
bus.$emit('event', data);
Vue 3 移除的原因:
- 滥用导致维护困难:事件总线在大型项目中会形成隐式依赖,难以追踪数据流向
- 不符合单向数据流:Vue 推崇 props down / events up 的单向数据流
- 更好的替代方案:Pinia 状态管理 +
provide/inject+ Composition API 已能覆盖绝大多数场景
替代方案:
| 方案 | 适用场景 |
|---|---|
| mitt | 需要 EventBus 时的轻量替代(~200B) |
| tiny-emitter | 支持 once 的轻量方案 |
| Pinia | 复杂状态管理,可追踪的数据流 |
provide / inject | 跨层级组件通信 |
defineEmits | 父子组件通信(推荐) |
// Vue 3 推荐用 mitt 替代
import mitt from 'mitt';
type Events = {
'theme:change': 'light' | 'dark';
'locale:change': string;
};
export const emitter = mitt<Events>();
// 组件中使用
import { onMounted, onUnmounted } from 'vue';
function useEventBus() {
onMounted(() => {
emitter.on('theme:change', (theme) => {
document.body.className = theme;
});
});
onUnmounted(() => {
// 组件卸载时清理,防止内存泄漏
emitter.off('theme:change');
});
}
更多 Vue 组件通信方式可参考 Vue 组件通信方式。
Q10: 实际项目中你是如何使用发布订阅模式的?
答案:
以下是几个真实项目中的应用场景:
场景 1:WebSocket 消息分发
在直播/IM 项目中,WebSocket 收到的消息类型繁多,用 EventEmitter 按类型分发可以解耦消息处理逻辑:
interface WsMessageEvents {
'chat:message': (msg: ChatMessage) => void;
'chat:typing': (userId: string) => void;
'gift:send': (gift: GiftData) => void;
'room:update': (info: RoomInfo) => void;
'system:notice': (text: string) => void;
}
class WsMessageDispatcher extends TypedEventEmitter<WsMessageEvents> {
dispatch(raw: string): void {
const { type, payload } = JSON.parse(raw);
// 统一分发,各业务模块独立监听
this.emit(type as keyof WsMessageEvents, payload);
}
}
// 聊天模块只关心聊天消息
dispatcher.on('chat:message', (msg) => renderMessage(msg));
// 礼物模块只关心礼物
dispatcher.on('gift:send', (gift) => playGiftAnimation(gift));
场景 2:SDK 生命周期钩子
设计 SDK 时,通过 EventEmitter 暴露生命周期事件,让使用方灵活插入自定义逻辑:
interface UploadSDKEvents {
'upload:start': (file: File) => void;
'upload:progress': (percent: number) => void;
'upload:success': (url: string) => void;
'upload:error': (err: Error) => void;
'upload:complete': () => void;
}
class UploadSDK extends TypedEventEmitter<UploadSDKEvents> {
async upload(file: File): Promise<string> {
this.emit('upload:start', file);
try {
const url = await this.doUpload(file, (percent) => {
this.emit('upload:progress', percent);
});
this.emit('upload:success', url);
return url;
} catch (err) {
this.emit('upload:error', err as Error);
throw err;
} finally {
this.emit('upload:complete');
}
}
}
场景 3:跨模块解耦
在中大型 SPA 中,用 EventEmitter 实现模块间松耦合通信,避免模块直接相互引用:
// 全局事件总线
const appBus = new TypedEventEmitter<{
'auth:expired': () => void;
'network:offline': () => void;
'network:online': () => void;
}>();
// 鉴权模块:监听过期事件
appBus.on('auth:expired', () => {
router.push('/login');
clearToken();
});
// HTTP 模块:401 时触发事件(不直接依赖路由模块)
httpClient.interceptors.response.use(null, (error) => {
if (error.response?.status === 401) {
appBus.emit('auth:expired'); // 只触发事件,不关心谁处理
}
return Promise.reject(error);
});
关于发布订阅模式与观察者模式的理论对比,可参考观察者模式与发布订阅模式。
Q11: 如何让 EventEmitter 支持 Symbol 作为事件名?
答案:
使用 Symbol 作为事件名可以避免命名冲突,特别适合库/框架内部事件:
type EventKey = string | symbol;
type Listener = (...args: any[]) => void;
class SymbolEventEmitter {
private events = new Map<EventKey, Listener[]>();
on(event: EventKey, listener: Listener): this {
const listeners = this.events.get(event) || [];
listeners.push(listener);
this.events.set(event, listeners);
return this;
}
emit(event: EventKey, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners || listeners.length === 0) return false;
[...listeners].forEach((fn) => fn(...args));
return true;
}
eventNames(): EventKey[] {
return [...this.events.keys()];
}
}
// 使用 Symbol 定义私有事件
const INTERNAL_INIT = Symbol('internal:init');
const INTERNAL_DESTROY = Symbol('internal:destroy');
class SDK extends SymbolEventEmitter {
init() {
// 内部事件,外部无法监听(因为拿不到 Symbol 引用)
this.emit(INTERNAL_INIT);
}
}
Symbol 事件名的优势:
- 天然避免与用户自定义事件名冲突
- 外部无法伪造或意外触发内部事件
- 配合 TypeScript 可实现更精确的类型约束
Q12: emit 中遍历 listeners 时为什么要用副本?
答案:
如果直接遍历原数组,在 once 的回调中调用 off 会修改数组,导致遍历跳过元素:
// 错误示范:直接遍历原数组
emit(event: string, ...args: any[]): boolean {
const listeners = this.events.get(event);
if (!listeners) return false;
// 危险!如果某个 listener 调用了 off,数组长度会变化
listeners.forEach((fn) => fn(...args));
return true;
}
问题复现:
const emitter = new EventEmitter();
emitter.once('test', () => console.log('A')); // 触发后 off 移除
emitter.on('test', () => console.log('B'));
emitter.emit('test');
// 错误结果:只输出 "A","B" 被跳过
// 原因:A 的 once wrapper 执行后 splice 移除自身,B 的索引从 1 变成 0,forEach 已到 index=1,跳过了 B
正确做法:
const copy = [...listeners]; // 创建副本
copy.forEach((fn) => fn(...args));
// 正确结果:输出 "A" 和 "B"
这是一个非常经典的面试考点,考察候选人对数组遍历和引用的理解。