手写 Event Emitter(事件发射器)

1. 核心干货整理

💡 核心思路

Map<事件名, Set<回调函数>> 为存储基石,通过高阶函数包装实现 once 语义,并在触发时增加错误隔离与内存自动回收机制,构建一个健壮、可链式调用的发布订阅系统。

🏗️ 解决方案架构图(文本版)
text 复制代码
[ Event Emitter 架构设计 ]
│
├── 数据存储层 (Storage)
│   └── Map<EventName, Set<Callback>>
│       ├── Key: string/symbol (事件标识)
│       └── Value: Set (自动去重 + O(1) 增删查)
│
├── 核心API层 (Core API)
│   ├── on(event, cb)      → 注册监听 (返回this支持链式调用)
│   ├── emit(event, ...args) → 触发执行 (try-catch隔离 + 参数透传)
│   ├── off(event, cb?)    → 移除监听 (支持移除单个或全部 + 空集合清理)
│   └── once(event, cb)    → 单次监听 (Wrapper高阶函数包装 + 自动off)
│
└── 工程增强层 (Enhancement)
    ├── 错误隔离: 单个回调异常不阻断后续执行
    ├── 内存安全: off后检测Set.size===0则删除Map Key
    ├── 上下文绑定: 保持回调函数原始this指向
    └── 类型安全: TypeScript泛型约束(可选加分项)
⚖️ 矛盾分析
  • 主要矛盾功能完备性 vs 实现复杂度 。面试官不仅要求实现基础的订阅/发布,还要求处理 once 这种特殊语义以及边界情况。如果只写基础版,无法体现高级前端能力;如果过度设计(如引入异步队列、优先级),又可能偏离"手写基础库"的考察初衷。
  • 次要矛盾
    • 性能 vs 安全 :遍历执行回调时,直接调用性能最高,但缺乏容错;加 try-catch 有微小开销但保证了系统稳定性。
    • 去重 vs 多次订阅 :使用 Set 天然去重,但如果业务确实需要同一个函数注册两次(极少见),Set 就不适用了。注:标准 Node.js EventEmitter 允许重复注册,但面试场景下使用 Set 是更优的工程选择,需在回答中说明权衡。
    • 内存占用 vs 清理成本 :空的 Set 残留在 Map 中是内存泄漏隐患,每次 off 都检查 size 有微小计算成本,但对于长生命周期应用是必须的。

2. 知识点更正与补充

常见误区 更正/补充说明 原因/原理
数据结构只用 Object ❌ 推荐用 Map + Set Object 键只能是字符串/Symbol,且有原型链污染风险;Map 查找 O(1),键类型任意。Set 天然去重,避免手动 indexOf 判断。
once 直接用标志位 ❌ 必须用 Wrapper 包装函数 如果在 emit 里判断标志位,会导致 emit 逻辑耦合 once 状态。包装函数将"执行一次后移除"的逻辑封装在注册阶段,符合单一职责原则。
emit 时直接 callback() ⚠️ 需考虑 this 指向与错误隔离 直接调用可能导致 this 丢失(虽然箭头函数无此问题,但普通函数有)。更重要的是,一个回调报错不应阻塞后续回调,必须 try-catch。
off 只删回调不删事件 ⚠️ 需清理空集合 当某个事件的监听器全部被移除后,Map 中残留的空 Set 是内存泄漏。必须在 off 后检查 set.size === 0 并 delete key。
缺少返回值 ⚠️ on/off 应返回 this 链式调用(Fluent Interface)是现代 API 设计的标配,能显著提升使用体验。
未提及并发/异步 ℹ️ 补充说明 本实现为同步模型。若需异步(如 Node.js 的 nextTick 触发),需额外设计。面试中默认同步即可,但口述提及异步场景是加分项。

3. 使用场景与边界场景

✅ 使用场景
  1. 跨组件通信:Vue/React 中非父子组件的状态同步(EventBus)。
  2. 解耦业务逻辑:将日志上报、埋点统计从核心业务流程中剥离。
  3. 插件系统:编辑器、构建工具的生命周期钩子(如 Webpack Tapable)。
  4. WebSocket 消息分发:根据消息 type 分发到不同处理器。
⚠️ 边界场景
  1. 回调内再次注册/注销 :在 emit 遍历过程中,如果回调内部调用了 onoff,可能导致迭代器失效或行为不可预期。解决方案:遍历前拷贝一份快照数组。
  2. 大量监听器 :单事件注册上千个回调时,同步遍历会阻塞主线程。解决方案:分片执行或异步调度。
  3. 内存泄漏 :组件卸载时未调用 off,导致闭包引用无法释放。解决方案:提供 removeAllListeners 或与框架生命周期绑定。
  4. 同名函数多次注册 :使用 Set 会自动去重。若业务真需多次触发同一函数,应改用 Array 或在注册时包装一层匿名函数。

4. 完整示例代码(含详细注释)

javascript 复制代码
/**
 * 生产级 Event Emitter 实现
 * 特性:Map+Set存储、链式调用、错误隔离、内存自动回收、once包装
 */
class EventEmitter {
  constructor() {
    // 使用 Map 存储事件,key 为事件名,value 为 Set<Function>
    // Map: O(1) 查找,支持任意类型 key;Set: 自动去重,O(1) 增删
    this._events = new Map();
  }

  /**
   * 订阅事件
   * @param {string|symbol} event - 事件名称
   * @param {Function} callback - 回调函数
   * @returns {EventEmitter} 返回 this,支持链式调用
   */
  on(event, callback) {
    if (typeof callback !== 'function') {
      throw new TypeError('Listener must be a function');
    }

    // 获取或创建该事件的监听器集合
    if (!this._events.has(event)) {
      this._events.set(event, new Set());
    }

    // Set.add 自动去重,同一函数引用不会重复添加
    this._events.get(event).add(callback);

    return this; // 🔑 链式调用关键
  }

  /**
   * 触发事件
   * @param {string|symbol} event - 事件名称
   * @param {...any} args - 传递给回调的参数
   * @returns {boolean} 是否有监听器被执行
   */
  emit(event, ...args) {
    const listeners = this._events.get(event);

    // 无监听器直接返回
    if (!listeners || listeners.size === 0) {
      return false;
    }

    // 🔑 关键:遍历前拷贝快照,防止回调内 on/off 修改集合导致迭代异常
    // 这也是 Node.js EventEmitter 的标准行为
    const snapshot = [...listeners];

    for (const callback of snapshot) {
      try {
        // 使用 .apply 保持 this 指向 undefined(严格模式)或全局对象
        // 若需绑定特定 context,可扩展 on(event, cb, context)
        callback.apply(undefined, args);
      } catch (error) {
        // 🔑 错误隔离:单个回调异常不影响其他回调执行
        // 生产环境建议接入全局错误监控
        console.error(
          `[EventEmitter] Error in "${String(event)}" listener:`,
          error
        );
      }
    }

    return true;
  }

  /**
   * 取消订阅
   * @param {string|symbol} event - 事件名称
   * @param {Function} [callback] - 指定回调;不传则移除该事件所有监听
   * @returns {EventEmitter} 返回 this,支持链式调用
   */
  off(event, callback) {
    // 未传 callback:移除该事件的所有监听器
    if (callback === undefined) {
      this._events.delete(event);
      return this;
    }

    const listeners = this._events.get(event);
    if (listeners) {
      listeners.delete(callback);

      // 🔑 内存优化:集合为空时清除 Map 中的 key,防止内存泄漏
      if (listeners.size === 0) {
        this._events.delete(event);
      }
    }

    return this; // 🔑 链式调用
  }

  /**
   * 单次订阅:触发一次后自动移除
   * @param {string|symbol} event - 事件名称
   * @param {Function} callback - 回调函数
   * @returns {EventEmitter} 返回 this,支持链式调用
   */
  once(event, callback) {
    if (typeof callback !== 'function') {
      throw new TypeError('Listener must be a function');
    }

    // 🔑 核心:高阶函数包装,将"执行后自移除"逻辑封装在 wrapper 中
    const wrapper = (...args) => {
      // 先执行原始回调
      callback.apply(undefined, args);
      // 执行后立即移除自身(wrapper 是实际注册的函数)
      this.off(event, wrapper);
    };

    // 注册包装函数而非原始回调
    this.on(event, wrapper);

    return this;
  }

  /**
   * 移除所有事件的所有监听器(补充方法)
   * @returns {EventEmitter}
   */
  removeAllListeners() {
    this._events.clear();
    return this;
  }
}

5. 更好的解决方案补充

真实工程中还可进一步优化:

优化方向 方案 适用场景
类型安全 TypeScript 泛型 EventEmitter<EventMap> 大型项目,编译期捕获事件名/参数错误
异步触发 emitAsync + Promise.all 监听器含异步操作且需等待全部完成
通配符 支持 * / namespace:* 匹配 调试工具、日志系统
最大监听器警告 setMaxListeners(n) 防止内存泄漏,Node.js 默认 10
WeakRef 弱引用 监听器使用 WeakRef 包装 GC 可自动回收已销毁对象的监听器
模块化替代 使用 mitt / eventemitter3 生产环境优先用成熟库,手写仅用于学习/面试

💡 面试建议:先写出上述满分代码,再口头提及这些进阶方向,展示你的技术视野广度。


6. 🏆 满分答案速记卡

一句话总结 :用 Map+Set 做高效去重存储,四大 API 精准实现,once 用高阶函数包装自移除,emit 加快照遍历和 try-catch 隔离错误,off 后清理空集合防泄漏,所有修改方法返回 this 支持链式调用。

答题结构模板

  1. 数据结构选型:为什么选 Map+Set(O(1) + 去重)
  2. 四要素实现:on / emit / off / once 的核心逻辑
  3. 三大工程细节:错误隔离 + 内存回收 + 链式调用
  4. 一个亮点:快照遍历防迭代异常 / TypeScript 类型约束
  5. 主动提问:是否需要支持异步?是否需要通配符?引导面试官进入你熟悉的领域

掌握以上要点,这道题不仅是"会写",而是展现出高级工程师的代码品味与系统性思维

相关推荐
数智启示录3 小时前
Apache Kafka Consumer 扩到 40 个仍不提速:Partition 上限锁死有效并行度 【Kafka合集】
数据库·经验分享·分布式·缓存·面试·kafka·apache
2601_962297253 小时前
python自带缓存lru_cache用法及扩展的使用_python
python·缓存·装饰器·lru_cache·my_cache
xixingzhe25 小时前
SpringBoot 接口缓存实现
spring boot·后端·缓存
莫得感情 o16 小时前
Redis 05 · 持久化:RDB 与 AOF 怎么保证数据不丢
redis·缓存
curd_boy17 小时前
【Redis】Redis从缓存到AI向量平台
人工智能·redis·缓存
kiracrimson17 小时前
从缓存的角度看链表与线性表的差异
数据结构·链表·缓存
2601_9623010121 小时前
深入理解缓存(Cache):原理、应用与优化
缓存·计算机原理·优化策略·数据访问·系统性能
数智启示录21 小时前
PostgreSQL 计划缓存实战(第 10 篇):预编译 SQL 前五次都快,第六次为什么可能变慢
经验分享·sql·缓存·postgresql·面试
跨境数据猎手1 天前
京东开放平台商品详情接口(jd.item_get):签名、缓存与批量采集
java·spring·缓存