題目與適用情境
實作一個 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。兩個斷言一起覆蓋新增與刪除的生效邊界。