具代表性的面試主題

如何手寫 JavaScript EventEmitter,正確處理 on、off、once 與 emit?

前端中等
OfferCC 編輯團隊發佈 更新

題幹

請實作一個 JavaScript EventEmitter,支援 on、off、once 與 emit,並說明監聽器順序、派發中修改及重複註冊的處理規則。

題目與適用情境

實作一個 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 也不需要用包裝函式取代原始函式:

javascript
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。兩個斷言一起覆蓋新增與刪除的生效邊界。

公開來源

同類題目