题干与适用场景
实现一个 EventEmitter 类,提供四个方法:on(eventName, listener) 注册持续监听器,once(eventName, listener) 注册一次性监听器,off(eventName, listener) 移除一次指定注册,emit(eventName, ...args) 同步派发事件。事件名可以是字符串或 Symbol,同一函数允许对同一事件重复注册。
本题采用一组明确的 Node 风格核心语义:监听器按注册顺序同步调用;普通函数监听器中的 this 指向 emitter;emit 有监听器时返回 true,否则返回 false;监听器返回值被忽略。once 必须先注销再调用,避免监听器内部再次 emit 时重复执行。
派发开始时要固定本轮监听器快照。某个监听器在派发过程中移除另一个监听器,不影响已经开始的本轮派发,只影响后续派发;本轮新增的监听器也要等到下一次派发。实现不复制 Node.js 的特殊 error 事件、监听器数量警告、prependListener、异步迭代和 Promise rejection 捕获,这些属于另一层兼容性要求。
面试官考察点
强回答会先定义事件契约,再选择容器。Map<eventName, entries[]> 负责按事件名定位监听器数组,数组保留注册顺序;每个 entry 保存原始函数、是否 once、是否已触发和是否仍有效。直接使用 Set 虽然方便删除,却会自动合并重复函数,改变“重复注册就重复调用”的约定。
第二个信号是能否区分实时数组和派发快照。直接遍历实时数组时,前一个监听器执行 off 可能让数组左移并跳过下一项,执行 on 又可能让新监听器意外进入本轮。复制本轮有效 entry 后再调用,可以把修改的生效边界固定在下一次 emit。
第三个信号是 once 的重入正确性。只在回调结束后删除会失败:一次性监听器内部再次派发相同事件时,它仍在列表里。实现应在调用前标记 fired 并设为无效;fired 还要防止嵌套派发已经调用该 entry 后,外层快照再次调用它。
复杂度也应如实说明。设某事件有 k 个监听器:on 和 once 的数组追加是摊销 O(1);off 反向查找并删除为 O(k);emit 建快照、调用和清理为 O(k),另加监听器自身运行时间。事件名查找在常见 Map 实现中平均可视为 O(1),但 ECMAScript 只要求平均访问时间优于线性。不能因为使用 Map 就把四个操作无条件写成 O(1)。
回答前需要澄清的问题
- 需要哪些方法和返回值? 只有
on、off、emit时,无需为一次性监听器保存状态;加入once后必须定义移除时机。若emit要返回各监听器结果,数据流会改变;本题只返回是否存在监听器。 - 同一函数能否重复注册?
off删除一次还是全部? 本题允许重复,off从后向前移除最近的一次注册。若要求去重,可改用 Set,但那是不同契约。 - 派发期间的增删何时生效? 本题采用快照语义:本轮开始时存在的监听器都进入本轮,新注册和移除从下一轮生效。若要求“移除后本轮立刻跳过”,循环中就必须重新检查有效状态。
- 监听器是否同步执行,异常如何处理? 本题同步按顺序调用。监听器抛错会从
emit继续向调用方抛出,后续监听器不再执行;实现仍须在finally中完成失效 entry 的清理。 - 要完整兼容 Node.js 吗? 若答案是“要”,还需实现特殊
error事件、元事件、监听器上限和更多 API。本题只实现已声明的核心子集,类名相同不代表规范完全等价。
这些问题会改变存储结构、循环条件、返回值和错误边界,应在写代码前确定。
30 秒回答框架
“我会用 Map 保存事件名到 entry 数组,同步按注册顺序派发并允许重复注册。off 只删除最近一次匹配,派发中的增删从下一轮生效。emit 先复制有效 entry;调用 once 前把它标为 inactive 和 fired,挡住重入,再用 finally 清理。在常见 Map 平均查找假设下,注册为摊销 O(1),off 和 emit 为 O(k)。”
分步骤深入解答
最小版本可以把每个事件映射到函数数组,然后依次调用。它能通过“注册两个函数再派发”的样例,却没有回答四个会改变结果的问题:重复函数如何删除、派发时数组发生修改怎么办、一次性监听器何时移除、监听器抛错后如何清理。
推荐实现把“注册记录”和“函数”分开。这样同一个函数的两次注册仍是两个独立 entry,once 也不需要用包装函数替换原始函数:
class EventEmitter {
constructor() {
this.events = new Map();
}
on(eventName, listener) {
return this._add(eventName, listener, false);
}
once(eventName, listener) {
return this._add(eventName, listener, true);
}
off(eventName, listener) {
const entries = this.events.get(eventName);
if (!entries) return this;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.active && entry.listener === listener) {
entry.active = false;
entries.splice(index, 1);
break;
}
}
if (entries.length === 0) {
this.events.delete(eventName);
}
return this;
}
emit(eventName, ...args) {
const entries = this.events.get(eventName);
if (!entries) return false;
const snapshot = entries.filter((entry) => entry.active);
if (snapshot.length === 0) return false;
try {
for (const entry of snapshot) {
if (entry.once) {
if (entry.fired) continue;
entry.fired = true;
entry.active = false;
}
entry.listener.apply(this, args);
}
} finally {
const currentEntries = this.events.get(eventName);
if (currentEntries) {
const activeEntries = currentEntries.filter((entry) => entry.active);
if (activeEntries.length === 0) {
this.events.delete(eventName);
} else if (activeEntries.length !== currentEntries.length) {
this.events.set(eventName, activeEntries);
}
}
}
return true;
}
_add(eventName, listener, once) {
if (typeof listener !== "function") {
throw new TypeError("listener must be a function");
}
const entries = this.events.get(eventName);
const entry = { listener, once, fired: false, active: true };
if (entries) {
entries.push(entry);
} else {
this.events.set(eventName, [entry]);
}
return this;
}
}snapshot 固定本轮的成员和顺序,但保存的是 entry 引用。普通 entry 即使在派发中被 off 标为无效,仍会按本轮快照调用;下一轮建快照时才会消失。新增 entry 只进入实时数组,不在旧快照中,因此不会插入当前派发。
once 需要两个状态。active = false 让嵌套的下一次 emit 看不到它;fired = true 处理更隐蔽的重入:嵌套派发可能先调用该 entry,而外层快照仍持有同一引用。外层随后看到 fired,就会跳过第二次调用。标记发生在 listener 之前,因此 listener 抛错也不会让一次性注册复活。
finally 负责压缩失效 entry。没有它,监听器一旦抛错,逻辑上已经删除的 once entry 会滞留在数组中。异常仍会自然抛给 emit 的调用方,finally 只维护内部不变量,不吞掉错误。
至少验证以下边界:无监听器时返回 false;参数与 this 正确传递;重复注册会重复调用,off 只删最近一项;once 连续派发只执行一次;监听器在本轮移除下一项时,下一项仍完成本轮;本轮新增项要到下一轮才执行;嵌套派发不能让 once 执行两次;监听器抛错后一次性 entry 仍被清理。
高质量示范回答
“我先确定这是一组 Node 风格核心语义,不做完整兼容:事件名支持字符串或 Symbol,监听器同步按注册顺序执行,允许重复注册,off 删除最近的一次注册。派发开始后使用快照,所以本轮增删只影响后续派发;emit 返回是否找到监听器,监听器异常直接抛给调用方。
我会用 Map 保存事件名到 entry 数组。数组保持顺序,entry 保存原始函数和 once 状态。emit 先过滤出本轮快照。一次性 entry 必须在调用前设为 inactive 和 fired:inactive 阻止嵌套派发再次选中它,fired 阻止嵌套派发已经调用后外层快照再调一次。循环放在 try...finally 中,确保监听器抛错时仍清理失效 entry,但不捕获异常。
这个结构保留了重复注册和快照语义。在常见 Map 平均查找假设下,on、once 追加为摊销 O(1);off 要扫描并移动数组元素,emit 要复制、调用和清理,所以两者是 O(k)。如果题目要求移除为 O(1),我会改成链表 entry 加索引,但代码和内存成本都会增加,当前约束没有必要。”
常见错误
- 用
Set保存监听器 → 相同函数注册两次只剩一项,重复注册语义被改变 → 使用 entry 数组,把每次注册当作独立记录。 - 直接遍历实时数组 → 监听器执行
off会移动下标,执行on可能把新项带入本轮 → 派发开始时创建有效 entry 快照。 once在回调结束后才删除 → 回调内部重入相同事件时会再次调用自己,抛错时也可能永远删不到 → 调用前设置active = false和fired = true。- 只设置 inactive,不记录 fired → 嵌套派发先调用 once 后,外层旧快照仍可能再次调用同一 entry → 用共享 entry 上的 fired 标记挡住旧快照。
off用filter删除所有同名函数 → 一次调用清除了所有重复注册,与“移除一次”不符 → 从后向前找到最近的有效 entry,只删除一项。- 捕获监听器异常后继续执行 → 调用方看不到失败,错误边界被悄悄改变 → 用
finally清理状态,让异常继续向外抛出。 - 宣称所有操作都是
O(1)→ Map 规范没有承诺严格常数查找,数组查找、删除、快照和遍历也与监听器数相关 → 写明 Map 假设,并分别给出on/once摊销O(1)与off/emit O(k)。
追问及应对
追问一:为什么派发中删除的监听器仍要完成本轮?
快照让一次 emit 的参与者在开始时确定,避免前一个监听器通过修改数组改变后续下标。实现中快照持有 entry 引用,但普通监听器调用时不重新检查 active,所以本轮仍执行;下一次快照会排除它。若产品希望注销立即生效,可以在每次调用前检查 active,但必须明确接受不同语义并补充测试。
追问二:如何证明 once 在重入时至多执行一次?
一次性 entry 只有在 fired 为 false 时可进入调用路径。第一次到达它的派发先把 fired 改为 true,再执行 listener。任何嵌套或外层快照持有的都是同一 entry 引用,之后都会看到 true 并跳过。因此无论嵌套顺序如何,该注册至多调用一次。用“普通监听器只在第一次触发时嵌套 emit,后面再放一个 once”的用例可以验证最难的顺序。
追问三:如果要求 off 为 O(1) 怎么办?
数组无法同时保证任意 entry 删除为 O(1) 和稳定顺序。可以为每个事件维护双向链表,并用 Map<listener, nodes[]> 定位某函数最近注册的节点;off 断开节点为 O(1),emit 仍为 O(k)。代价是每项多两个指针、重复函数索引维护和更复杂的派发快照规则。监听器规模不大且删除不频繁时,数组方案更容易验证。
追问四:监听器返回 Promise 或抛错时怎么处理?
当前 emit 是同步 API,忽略返回值。同步抛错会中止本轮并向调用方传播,finally 只清理状态;返回的 Promise 不会被等待。若要等待所有监听器,应新增独立的 emitAsync 契约,并明确串行还是并行、失败是快速结束还是收集全部结果,不能只在现有循环前加 await。
追问五:怎样验证派发中新增监听器不会进入本轮?
先注册 A,A 执行时注册 B。第一次 emit 的快照只有 A,所以输出只有 A;第二次建快照时才包含 A 和 B,输出顺序为 A、B。再让 A 同时移除一个已经进入快照的 C,可验证第一次仍输出 A、C,第二次才排除 C。两个断言一起覆盖新增和删除的生效边界。