javaScript設計模式 : Mediator(仲裁者)

 


專案做到一半,你是不是也遇過這種情況:表單一改「國家」,「城市」就要清空;清單在載入,按鈕要鎖;API 慢半拍回來,又把新結果覆蓋掉。

元件彼此拉線、互相喊話,最後整個頁面像糾結的耳機線。其實,很多痛苦不是功能難,而是「溝通」散落各處。

Mediator(仲裁者)模式做的事很單純:把互動規則收攏到一個中樞,讓元件各司其職、透過中樞對話。這篇不講玄學,我會用貼近實務的例子(聊天室、表單協作、搜尋流程)帶你看清它怎麼落地、怎麼測、哪裡會踩雷,寫起來更安靜,維護也不再焦慮。希望本篇文章能夠幫助到需要的您。


目錄

{tocify} $title={目錄} 


什麼是 Mediator?為何需要?

定義:建立一個「仲裁者」物件,封裝多個物件間的互動,讓彼此不再互相直接呼叫,只透過仲裁者溝通。這能明顯降低相互依賴,改善可維護性與可測試性。


痛點場景(你可能已經遇過):

        表單多個欄位彼此影響(比如國家→城市→郵遞區號),改一處就牽一髮動全身。

        頁面上 N 個元件彼此彼此訂閱、彼此呼叫,最後形成蜘蛛網依賴,debug 超痛。

        模組之間為了互通塞了一堆事件或 callback,事件風暴難以追蹤。

引入 Mediator 後:每個元件只需要知道仲裁者;仲裁者負責「誰該通知誰、什麼時候做什麼」,溝通規則集中管理,讓變更成本降低。


與 Observer/Pub-Sub 的差異

Observer:一對多「廣播」;被觀察者改變 → 通知訂閱者。

Mediator:多對多「對話協調」;物件間不直連,透過單一協調中心進行雙向或多向互動。

        簡言之:Observer 側重通知機制,Mediator 側重互動流程的編排與規則集中化。


很多團隊用 Event Bus(Pub-Sub) 取代一切互動,短期靈活,長期容易變成「誰都在發、誰也在聽」,規則散落各處;Mediator 則把「規則」收斂在中心。兩者可共存,但責任要切清楚。


模式結構與設計要點

角色

    Mediator:定義互動協議,接收各參與者的請求,據以轉派或下指令。

    Colleague(參與者):只與 Mediator 溝通,不直接互調。

設計要點

    保持參與者瘦身,把決策邏輯交給 Mediator。

    Mediator 僅負責溝通與流程;商業規則仍可分散在各參與者內部。

    當規則變複雜時,把 Mediator 拆成領域中介(多個)或以策略/狀態重構避免「上帝物件」。


實作一:極簡聊天室(Class 版 Mediator)

目標:使用者彼此不直接知道對方,透過 ChatRoomMediator 轉送訊息/廣播。

// 參與者
class User {
  constructor(name, mediator) {
    this.name = name;
    this.mediator = mediator;
    mediator.register(this);
  }
  send(to, text) {
    this.mediator.route({ from: this.name, to, text, ts: Date.now() });
  }
  receive({ from, text, ts }) {
    console.log(`[${new Date(ts).toLocaleTimeString()}] ${from} → ${this.name}: ${text}`);
  }
}

// 仲裁者
class ChatRoomMediator {
  constructor() {
    this.users = new Map();
  }
  register(user) {
    if (this.users.has(user.name)) throw new Error(`User ${user.name} already exists`);
    this.users.set(user.name, user);
  }
  route(msg) {
    if (msg.to === '*') {
      // 廣播
      for (const [name, u] of this.users) {
        if (name !== msg.from) u.receive(msg);
      }
    } else {
      const target = this.users.get(msg.to);
      if (!target) return console.warn(`User ${msg.to} not found.`);
      target.receive(msg);
    }
  }
}

// 使用
const room = new ChatRoomMediator();
const alice = new User('Alice', room);
const bob = new User('Bob', room);
alice.send('Bob', 'Hi!');
bob.send('*', 'Hello everyone!');


重點:User 不知道對方的引用,只知道 mediator。路由邏輯集中在 ChatRoomMediator,好測試、好擴充(如禁言、審核、記錄)。


實作二:前端表單協作(用 EventTarget/CustomEvent 當中介)

瀏覽器原生提供 EventTarget 與 CustomEvent,可作為輕量 Mediator:

        各元件向「仲裁者」派發事件;

        仲裁者判斷目前狀態後分發動作;

        好處:不需額外套件,原生跨瀏覽器支援好(IE 例外)。

場景:地址輸入表單

        改「國家」→ 重置「城市」與「郵遞區號」

        欄位齊全 → 啟用送出按鈕

// 輕量仲裁者:繼承 EventTarget
class FormMediator extends EventTarget {
  constructor() {
    super();
    this.state = { country: '', city: '', zip: '' };

    // 收敘各字段變更
    this.addEventListener('field:change', (e) => {
      const { name, value } = e.detail;
      this.state[name] = value;

      // 規則集中:改國家 → 清空城市與郵遞區號
      if (name === 'country') {
        this.state.city = '';
        this.state.zip = '';
        this.dispatchEvent(new CustomEvent('ui:update', { detail: { reset: ['city', 'zip'] } }));
      }

      // 規則集中:是否可送出?
      const ready = this.state.country && this.state.city && this.state.zip;
      this.dispatchEvent(new CustomEvent('form:ready', { detail: { ready } }));
    });
  }
}

// 視圖元件只負責回報與接收,不保規則
function bindField(id, name, mediator) {
  const el = document.getElementById(id);
  el.addEventListener('input', () => {
    mediator.dispatchEvent(new CustomEvent('field:change', { detail: { name, value: el.value } }));
  });
  mediator.addEventListener('ui:update', (e) => {
    if (e.detail.reset?.includes(name)) el.value = '';
  });
}

function bindSubmit(buttonId, mediator) {
  const btn = document.getElementById(buttonId);
  mediator.addEventListener('form:ready', (e) => (btn.disabled = !e.detail.ready));
}

// 啟動
const mediator = new FormMediator();
bindField('country', 'country', mediator);
bindField('city', 'city', mediator);
bindField('zip', 'zip', mediator);
bindSubmit('submitBtn', mediator);


上例把「誰該被重置、何時能送出」集中在 FormMediator。CustomEvent.detail 能攜帶自訂資料,事件分發與監聽使用 EventTarget 的 dispatchEvent/addEventListener。


實作三:搜尋流程協調器(API、載入指示、結果清單統一調度)

目標:輸入框、結果清單、載入指示器、API 模組互不耦合,由 SearchMediator 編排完整流程(去抖、併發控制、狀態收斂)。

class SearchMediator {
  constructor({ input, resultsView, spinner, api }) {
    this.input = input;
    this.resultsView = resultsView;
    this.spinner = spinner;
    this.api = api;

    this.timer = null;
    this.latestQuery = '';
    this.inflightToken = 0;

    this.input.onQuery = (q) => this.handleQuery(q);
  }

  handleQuery(q) {
    clearTimeout(this.timer);
    this.timer = setTimeout(() => this.search(q), 250); // 簡易 debounce
  }

  async search(q) {
    this.latestQuery = q.trim();
    if (!this.latestQuery) {
      this.resultsView.render([]);
      return;
    }
    const token = ++this.inflightToken; // 標記最新請求
    this.spinner.show();
    try {
      const list = await this.api.search(this.latestQuery);
      if (token !== this.inflightToken) return; // 舊結果丟棄
      this.resultsView.render(list);
    } catch (err) {
      if (token !== this.inflightToken) return;
      this.resultsView.error(err);
    } finally {
      if (token === this.inflightToken) this.spinner.hide();
    }
  }
}

// --- 假元件 ---
const input = {
  onQuery: null,
  bind(el) { el.addEventListener('input', () => this.onQuery?.(el.value)); }
};

const resultsView = {
  render(list) { /* 更新結果區 */ },
  error(e) { /* 顯示錯誤 */ }
};

const spinner = { show(){/*...*/}, hide(){/*...*/} };
const api = { search: (q) => fetch(`/api/search?q=${encodeURIComponent(q)}`).then(r => r.json()) };

// 啟動
const mediator = new SearchMediator({ input, resultsView, spinner, api });
input.bind(document.getElementById('q'));


亮點:

規則(去抖、取消舊請求、Loading 顯示時機)全部在 Mediator;

input/resultsView/spinner/api 都是瘦元件;

測試時可以替換 api 為 mock,或檢查 mediator 的流程是否正確。


常見錯誤與雷點

1.    把 Mediator 寫成「上帝物件」

        症狀:所有邏輯都塞進一個巨大類別,動不動上千行。

        修正:

            按領域切分成多個 mediator(如 FormMediator、SearchMediator)。    

            將複雜決策抽為策略或規則集合(例如 RuleSet 注入 mediator)。


2.    其實只是廣播需求,卻硬上 Mediator

        症狀:邏輯只是單向通知,卻引入仲裁者導致結構過度。

        修正:純通知請用 Observer/Pub-Sub;需要流程協調與路由規則再使用 Mediator。


3.    事件風暴(無節制的 CustomEvent)

        症狀:事件名稱到處定義,誰 dispatch、誰接都說不清。

        修正:

                統一事件命名(如 domain:action)。

                只允許事件由 mediator 發起或轉發;元件不互相直接派發。

                定期做「事件清單」稽核。


4.    記憶體洩漏(未解除監聽)

        症狀:元件銷毀後仍持有監聽器。

        修正:封裝 subscribe()/dispose(),

                    在元件 unmount 時呼叫,或使用 AbortController 管理監聽生命週期。


5.    把跨瀏覽器支援想得太美

        症狀:在 IE(老舊環境)使用 CustomEvent 出錯。

        修正:必要時加 polyfill,或用小型事件匯流排封裝。


6.    異步競賽條件

        症狀:舊請求晚回來覆蓋新結果。

        修正:加入請求序號或 AbortController;

                    Mediator 統一丟棄過期結果(見實作三)。


7.    測試困難

        症狀:流程跨多元件不好測。

        修正:

                Mediator 的輸入/輸出可觀察化(事件或回呼);

                對外部依賴(API/View)全部注入,測試改用假物件。


8.    命名與邊界不清

        症狀:Mediator 夾帶領域規則與 UI 細節,久了混亂。

        修正:把「流程協調」與「領域規則」分層,UI 細節留在視圖。


測試策略與重構建議

單元測試(以實作三為例)

    注入假 api 與假 resultsView,驗證:

        debounce 是否生效(快速輸入只觸發一次搜尋);

        舊請求是否被丟棄;

        錯誤時是否呼叫 resultsView.error;

        spinner.show/hide 的時序是否正確。


契約測試

        對各參與者定義最小介面(如 resultsView.render(list)),藉由共同的介面讓替換更安全。

重構指引

        當 Mediator 超過某複雜度(例如檔案 >300 行、圈複雜度 >15),先拆副流程到小物件(策略、命令、驗證器),必要時分裂 Mediator。


與其他模式的協同

Facade:

        對外提供簡化介面;Mediator 管內部元件互動。兩者常一起用:外部呼 Facade,內部由 Mediator 排程。

Command:

        把操作封裝成命令物件;Mediator 只負責何時與下達對象,不碰命令內容。

State/Strategy:

        當規則依狀態/策略而變時,把變化封裝再交由 Mediator 切換。

Observer:

        Mediator 內部也能用 Observer 作為通知機制,但規則仍在 Mediator 集中。


何時該用/不該用 & 結語

適用:

多元件交互複雜、修改常牽連到其他元件;

想降低耦合、集中互動規則,增進測試與維護性;

前端大型表單、流程編排(搜尋、結帳、預約)、多人協作邏輯(聊天室、遊戲房間)。


不適用:

單純通知、資料單向流(用 Observer/Pub-Sub 更直接);

規模很小、邏輯極簡(直接呼叫最清楚)。


延伸閱讀推薦:

javaScript設計模式 : Factory Method   (工廠方法)

javaScript設計模式 : Abstract Factory(抽象工廠)

javaScript設計模式 : Builder(建造者模式)

javaScript設計模式 : Prototype(原型)

javaScript設計模式 : Singleton (單例模式)

javaScript設計模式 : Adapter(轉接器模式)

javaScript設計模式 : Bridge( 橋接模式 )

javaScript設計模式 : Composite(組合模式)

 javaScript設計模式 : Decorator(裝飾者)

javaScript設計模式 : Facade(外觀模式)

javaScript設計模式 : Flyweight(享元模式)

javaScript設計模式 : Proxy(代理模式)

javaScript設計模式 : Chain of Responsibility(責任鏈)

javaScript設計模式 : Command Pattern(命令模式)

javaScript設計模式 : Interpreter(直譯器)

javaScript設計模式 : Iterator(迭代器)

javaScript設計模式 : Memento(備忘錄)

javaScript設計模式 : Observer( 觀察者 )

javaScript設計模式 : State(狀態模式)

javaScript設計模式 : Strategy(策略模式)

javaScript設計模式 : Template Method (模板方法)

javaScript設計模式 : Visitor(訪問者模式)


張貼留言 (0)
較新的 較舊