聊天讨论 我手写了一个 EventEmitter,面试官追问了 6 个问题——第 4 个我没答上来

193577746(kyriewen) · June 30, 2026 · 16 hits

发布订阅模式是前端面试的高频手写题,但大多数人只会写一个基础版的 on / emit。面试官真正想考的不是你会不会写,而是你写完之后能不能接住追问。我上次手写完 EventEmitter 后被连续追问了 6 个问题,第 4 个关于内存泄漏的问题当场没答上来。这篇文章把完整实现和 6 个追问全部写出来,下次遇到直接拿满分。

先用 30 行写一个能用的版本

面试的时候不要上来就写"完美版",先快速写一个核心能跑的版本,再根据面试官的追问逐步完善。

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 直接删,不用遍历

写完这个版本,面试官会满意吗?不会。追问才刚开始。


追问 1:"链式调用怎么实现的?"

你可能注意到了,每个方法最后都 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(流式接口)"。


追问 2:"once 的实现原理是什么?为什么要用 wrapper?"

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;
}

这个细节能答上来,面试官会认为你对设计模式的理解不是停留在"背代码"层面。


追问 3:"emit 的时候如果 listener 抛了异常,后面的 listener 还会执行吗?"

当前实现:不会。 因为 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) 这种写法——它是兜底用的。


追问 4:"如果忘了 off,会不会内存泄漏?"

会。 这是我当时没答上来的问题。

场景:一个 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 → 闭包 → 组件状态。解决方案有三个:

  1. 手动 off(最基本)
  2. WeakRef 弱引用(进阶)
  3. 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,前端新标准正在往这个方向统一。


追问 5:"怎么实现带命名空间的事件?"

实际项目中,事件名经常需要层级结构:user.loginuser.logoutorder.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;
}

不需要完整实现通配符匹配,面试中说出思路就够了。


追问 6:"Node.js 的 EventEmitter 和你写的有什么区别?"

特性 手写版 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 行的基础版到完整版,每一行新增代码都对应一个追问。面试时先写基础版,面试官追问时再逐步加——这比一上来就写完整版更能展示你的思维过程。


你面试中被手写题难住过吗?最难的是哪道?评论区聊聊。

No Reply at the moment.
You need to Sign in before reply, if you don't have an account, please Sign up first.