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

 


如果你也常被「狀態改了要通知誰」這題追著跑,別急著塞更多 if/else。把思路換成 Observer:有人變動,就廣播一聲;誰在意,誰自己訂閱。

這樣模組之間不需要彼此認識,換元件、拆頁面也不會牽一髮動全身。本文用 JavaScript 從零寫出可訂閱、可退訂、可排序、可非同步的主體,搭配 WebSocket、表單驗證、全域小型 store 的實例,幫你看懂什麼時候用、什麼時候不用。

最後還整理一份踩雷清單,像是重複訂閱、事件風暴、回呼出錯等,一次補齊。輕鬆讀完,就會用。希望本篇文章可以幫助到需要的您。


目錄

{tocify} $title={目錄} 


為什麼需要 Observer?

前端應用越做越大,「某個地方的狀態改了,很多地方都要跟著變」幾乎每天都在發生。拿電商來說:

        購物車數量更新 → 頁首徽章更新、結帳按鈕狀態更新、價格小計重算。

        使用者登入 → 導覽列改變、頁面權限切換、API header 帶 token。

        WebSocket 收到最新報價 → 圖表更新、漲跌顏色變化、通知列推送。

如果每個地方都去「主動」查詢狀態或彼此硬連線,會形成緊耦合與交叉依賴地獄。觀察者模式的解法是:

主體(Subject)負責發出變化通知,觀察者(Observer)被動接收。主體不用知道誰在聽,只要專心「發」。觀察者也不用知道主體內部,只要「訂閱」與「退訂」


核心概念與角色

Subject(被觀察者/主體):持有狀態,並在狀態改變時通知觀察者。

Observer(觀察者):訂閱主體,接收通知並做相應行為。

Attach/Detach(訂閱/退訂):觀察者加入或移除。

Notify(通知):主體把變化廣播給所有觀察者。


流程圖(文字版):

1.    觀察者呼叫 subject.subscribe(handler) 訂閱。

2.    主體有變化時呼叫 subject.next(value)。

3.    主體遍歷所有觀察者的 handler,逐一呼叫。


JS 世界裡的「觀察」

JavaScript 生態本身就有許多觀察模型的影子:

DOM 事件:addEventListener / removeEventListener 天然就是觀察者模式。

Node.js EventEmitter:emitter.on('data', cb) / emit('data')。

MutationObserver / IntersectionObserver:原生 API 用於觀察 DOM 變化或元素曝光。

BroadcastChannel / postMessage:頁籤與 worker 間傳遞事件。

RxJS Observable:強化版觀察者,支援操作子(operators)與取消訂閱機制。

框架的響應式系統(如 Vue、Svelte):內部也有觀察/依賴追蹤的概念。

本篇聚焦「不依賴框架」的手寫實作與實務心法。


何時該用?何時別用?

適用情境

一個資料來源,需要多個接收端:UI、記錄器、快取器、通知器……

跨模組事件解耦:模組只關心訂閱/發佈,不彼此相依。

即時流(WebSocket、SSE、輪詢)分發給多個消費者。


不適用情境

僅有一對一回傳、且一次性結果:用 Promise 更直覺。

需要歷史回放、時間軸操作、背壓控制:考慮 RxJS 或專門的流工具。

只有單點更新,且耦合度不構成問題:直接呼叫函式就好,別過度設計。


從零手寫:簡潔版 Observable/Subject

先來一個最小可用版本,支援訂閱、退訂、通知與錯誤隔離。

// Minimal Observable/Subject
class Observable {
  #observers = new Set();

  subscribe(fn) {
    if (typeof fn !== 'function') throw new TypeError('Observer must be a function');
    this.#observers.add(fn);
    // 回傳退訂函式,方便隨手清理
    return () => this.#observers.delete(fn);
  }

  next(value) {
    // 複製一份,避免迭代中被移除影響遍歷
    for (const fn of [...this.#observers]) {
      try {
        fn(value);
      } catch (err) {
        // 不讓單一觀察者的錯誤中斷其他觀察者
        console.error('[Observer error]', err);
      }
    }
  }

  clear() {
    this.#observers.clear();
  }

  get size() {
    return this.#observers.size;
  }
}

// 使用示例
const price$ = new Observable();

const unsubLog = price$.subscribe((p) => console.log('最新價格:', p));
const unsubWarn = price$.subscribe((p) => { if (p < 10) console.warn('跌破 10!'); });

price$.next(12);
price$.next(8);

unsubWarn(); // 不再關心
price$.next(15);


要點:

用 Set 去重,避免重複訂閱(也能快速刪除)。

通知時先「展開成陣列」,避免遍歷中集合被修改造成不穩定。

任何觀察者拋錯都要被捕捉,不要讓它影響其他觀察者。


事件型 Subject(多通道、多次性、once/priority)

很多時候我們需要「事件名稱」→「回呼清單」這種 Pub/Sub 風格:

class Subject {
  #map = new Map(); // event -> Set<fn>

  on(event, fn, { priority = 0 } = {}) {
    if (!this.#map.has(event)) this.#map.set(event, new Set());
    const entry = { fn, priority };
    const bucket = this.#map.get(event);
    bucket.add(entry);

    // 回傳退訂
    return () => bucket.delete(entry);
  }

  once(event, fn) {
    const off = this.on(event, (payload) => {
      off();
      fn(payload);
    });
    return off;
  }

  emit(event, payload, { async = false } = {}) {
    const bucket = this.#map.get(event);
    if (!bucket || bucket.size === 0) return;

    // 依 priority 由大到小排序(高優先先執行)
    const queue = [...bucket].sort((a, b) => b.priority - a.priority);

    const runner = () => {
      for (const { fn } of queue) {
        try {
          fn(payload);
        } catch (err) {
          console.error(`[${event}] observer error`, err);
        }
      }
    };

    if (async) {
      queueMicrotask(runner); // 微任務:不阻塞目前同步流程
    } else {
      runner();
    }
  }

  off(event, fn) {
    const bucket = this.#map.get(event);
    if (!bucket) return;
    for (const entry of bucket) {
      if (entry.fn === fn) {
        bucket.delete(entry);
        break;
      }
    }
  }

  clear(event) {
    if (event) this.#map.delete(event);
    else this.#map.clear();
  }
}

// 使用示例
const bus = new Subject();

bus.on('login', (user) => console.log('歡迎', user.name), { priority: 10 });
bus.on('login', (user) => localStorage.setItem('token', user.token));
bus.once('login', () => console.log('這句只會出現一次'));

bus.emit('login', { name: 'Ava', token: 'abc123' });
bus.emit('login', { name: 'Ben', token: 'xyz789' });


要點:

priority 幫你掌控先後(例如:先驗證、再渲染)。

once 適合只需要一次的初始化邏輯。

async 讓通知進入微任務,避免阻塞 UI。


實戰範例 1:表單驗證管線(多個觀察者協作)

場景:使用者輸入 Email,我們要同步檢查格式、非同步檢查是否已被註冊、並且提示 UI。

const email$ = new Observable();

// 規則:格式檢查(同步)
email$.subscribe((value) => {
  const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
  document.querySelector('#emailMsg').textContent = ok ? '' : 'Email 格式錯誤';
});

// 規則:占用檢查(非同步,注意節流)
let lastCheck = 0;
email$.subscribe(async (value) => {
  const now = Date.now();
  if (now - lastCheck < 350) return; // 簡單節流
  lastCheck = now;

  try {
    const taken = await fakeRemoteCheck(value); // 用你的 API 替換
    document.querySelector('#emailMsg2').textContent = taken ? 'Email 已被註冊' : '';
  } catch (e) {
    console.error(e);
  }
});

// UI 綁定
document.querySelector('#email').addEventListener('input', (e) => {
  email$.next(e.target.value.trim());
});

function fakeRemoteCheck(email) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(email.endsWith('@taken.com')), 200);
  });
}


要點:

同步與非同步觀察者可以共存。

對非同步檢查做節流/防抖,避免每字都打 API。

観察者內部錯誤不應影響其他觀察者。


實戰範例 2:WebSocket 報價分發(多消費者)

class QuoteHub extends Observable {
  constructor(url) {
    super();
    this.ws = new WebSocket(url);
    this.ws.onmessage = (evt) => {
      try {
        const data = JSON.parse(evt.data); // { symbol, price, ts }
        this.next(data); // 分發給所有觀察者
      } catch (e) {
        console.error('WS parse error', e);
      }
    };
    this.ws.onerror = (e) => console.error('WS error', e);
  }
}

const hub = new QuoteHub('wss://example.com/quote');

// UI1:即時表格
const offTable = hub.subscribe(({ symbol, price }) => {
  updateTableCell(symbol, price);
});

// UI2:大圖走勢
const offChart = hub.subscribe(({ symbol, price, ts }) => {
  if (symbol === 'AAPL') pushChartPoint(ts, price);
});

// 需要時關閉觀察者,避免記憶體洩漏
window.addEventListener('beforeunload', () => {
  offTable();
  offChart();
});


要點:

Observable 成為分發中心,多個 UI/邏輯共享同一串流。

策略:頁面切換或元件卸載時務必退訂。


實戰範例 3:輕量全域狀態(無框架)

用觀察者維持一個最小的「可觀察狀態」,類似小型 store:

function createStore(initialState) {
  const state = { ...initialState };
  const change$ = new Observable();

  return {
    getState: () => ({ ...state }), // 避免外部直接改動
    set(patch) {
      const changed = Object.assign(state, patch);
      change$.next({ state: { ...state }, patch });
      return changed;
    },
    subscribe: (fn) => change$.subscribe(fn)
  };
}

const store = createStore({ theme: 'light', count: 0 });

const offHeader = store.subscribe(({ state }) => {
  document.documentElement.dataset.theme = state.theme;
});

const offCounter = store.subscribe(({ state }) => {
  document.querySelector('#counter').textContent = state.count;
});

document.querySelector('#inc').onclick = () => store.set({ count: store.getState().count + 1 });
document.querySelector('#toggleTheme').onclick = () =>
  store.set({ theme: store.getState().theme === 'light' ? 'dark' : 'light' });


要點:

getState 回傳拷貝,避免外部直接 mutate。

set 後發出狀態與 patch,利於細粒度更新。


Observer vs Pub/Sub vs Promise:別混了

Observer(觀察者):主體知道誰在聽(保有引用),直接通知;偏「推播」。

Pub/Sub(發佈/訂閱):透過中介的「事件匯流排」,發佈者與訂閱者彼此完全不認識;更鬆耦合。

Promise:一次性、單值、完成後不會再改變;適合「請求-回應」。

Observable/RxJS:多值、可取消、可組合;適合資料流處理(防抖、緩衝、切換)。


實務上你會同時用到:DOM 事件(觀察)、EventBus(Pub/Sub)、API 回應(Promise)。


常見錯誤與雷點

1.    未退訂導致記憶體洩漏

現象:頁面切換後,回呼還在跑,重複綁定越來越多。

對策:訂閱時就存下 off;在 componentWillUnmount/cleanup 或 beforeunload 統一清理。DOM 事件同理。


2.    在通知中修改主體,造成遞迴或抖動

現象:next() 觸發觀察者,觀察者又 next(),陷入循環或頻繁觸發。

對策:在觀察者中避免直接回寫主體;若必要,加入節流/防抖或狀態鎖。


3.    同步與非同步混用,順序難以掌控

現象:有的觀察者同步改 UI,有的等 API;呈現短暫不一致。

對策:明確規範:全部同步或全部進微任務(queueMicrotask/setTimeout 0);或在通知後導入批量渲染。


4.    觀察者拋錯導致整個通知中斷

對策:try/catch 包住每個回呼,錯誤只記錄不影響其他觀察者。


5.    重複訂閱

現象:同一函式被加了多次,通知重複執行。

對策:用 Set 去重;或在訂閱前先 off。


6.    迭代時修改集合

現象:遍歷中 off() 導致指標錯亂、遺漏通知。

對策:通知前把集合展平成陣列快照:for (const fn of [...set]) { ... }


7.    阻塞主執行緒

現象:觀察者做重計算,導致卡頓。

對策:把重任務移到 Web Worker,或切片處理(分批 setTimeout/requestIdleCallback)。


8.    無回放(Replay)需求卻硬要自做

現象:新的觀察者需要拿到「最新一筆」,但主體沒有記錄。

對策:若常需要回放,主體保留最近值並在 subscribe 立刻推一次;或導入 RxJS ReplaySubject/BehaviorSubject。


9.    DOM 元素卸載後未解除事件監聽

對策:銜接框架生命週期,或建立「集中註冊/集中清理」的管理器。


10.    this 綁定錯誤

對策:用箭頭函式或 fn.bind(context);別在回呼中依賴動態 this。


11.    事件名稱拼寫/命名碰撞(對事件型 Subject)

對策:集中定義事件常數或 Symbol,避免魔法字串散落。


12.    跨頁面或長連線未考慮可取消

對策:提供通用 unsubscribe;對 fetch 用 AbortController;WebSocket 關閉時移除所有觀察者。


最佳實務清單

每個訂閱一定要有退訂:API design 上直接回傳 off 是最省事的模式。

錯誤隔離:任何觀察者的錯都不應阻止其他觀察者運作。

同步策略一致:要嘛全同步,要嘛全微任務,避免顯示閃爍。

避免在通知中「回寫主體」:若要回寫,使用節流/狀態鎖。

通道分離:不同性質的事件分開頻道,別用一個「萬用事件」。

測試觀察行為:用假函式(spy)驗證通知次數、順序、參數。

文件化事件表:列出能 emit 的事件與 payload 格式,減少隱性耦合。

效能:大規模觀察者時,審視是否真的都需要同頻率通知;必要時做取樣或批次。


進階:微型 Event Bus(Pub/Sub 口味)

雖然主題是 Observer,但實務常需要一個「不帶狀態」的事件匯流排,中介發佈者與訂閱者:

function createEventBus() {
  const map = new Map(); // event -> Set<fn>
  return {
    on(event, fn) {
      if (!map.has(event)) map.set(event, new Set());
      const set = map.get(event);
      set.add(fn);
      return () => set.delete(fn);
    },
    emit(event, payload) {
      const set = map.get(event);
      if (!set) return;
      for (const fn of [...set]) {
        try { fn(payload); } catch (e) { console.error(e); }
      }
    },
    clear(event) {
      if (event) map.delete(event);
      else map.clear();
    }
  };
}

// 使用
const bus = createEventBus();
const off = bus.on('toast', (msg) => showToast(msg));
bus.emit('toast', { type: 'success', text: '已儲存' });
off();


這種寫法在模組間穿梭很實用,但請記得:更鬆耦合 → 更難追蹤資料流,務必維護「事件清單」。


附加:帶回放的 Behavior 主體

常見需求:「新訂閱者要立刻拿到最新值」。做個簡版 BehaviorSubject:

class BehaviorSubject extends Observable {
  #current;
  constructor(initialValue) {
    super();
    this.#current = initialValue;
  }
  subscribe(fn) {
    const off = super.subscribe(fn);
    // 訂閱立刻回放一次
    try { fn(this.#current); } catch (e) { console.error(e); }
    return off;
  }
  next(value) {
    this.#current = value;
    super.next(value);
  }
  get value() {
    return this.#current;
  }
}

// 使用
const theme$ = new BehaviorSubject('light');
theme$.subscribe(v => console.log('主題:', v)); // 立刻收到 'light'
theme$.next('dark');


測試觀念:如何驗證通知正確

以 Vitest/Jest 為例的概念(示意):

test('observer should receive values in order', () => {
  const ob$ = new Observable();
  const fn = vi.fn();
  ob$.subscribe(fn);

  ob$.next(1);
  ob$.next(2);

  expect(fn).toHaveBeenCalledTimes(2);
  expect(fn).toHaveBeenNthCalledWith(1, 1);
  expect(fn).toHaveBeenNthCalledWith(2, 2);
});

test('unsubscribe should stop receiving', () => {
  const ob$ = new Observable();
  const fn = vi.fn();
  const off = ob$.subscribe(fn);

  ob$.next('a');
  off();
  ob$.next('b');

  expect(fn).toHaveBeenCalledTimes(1);
  expect(fn).toHaveBeenLastCalledWith('a');
});


重點測:次數、順序、參數與退訂行為。


效能與資源管理

大量觀察者:Set/Map 是好選擇;避免在迴圈中做額外分配。

事件風暴:對高頻事件(如 scroll、mousemove、WS 爆量訊息)進行防抖、節流、取樣或批次合併。

非同步切片:長任務分段處理(setTimeout 0 或 queueMicrotask)、或移到 Web Worker。

資源釋放:退訂、關閉連線、清理 DOM 監聽,做成統一的 dispose()。


問題集

Q1.    和 addEventListener 有何不同?

本文的 Observable/Subject 是一種抽象,可套用在任何資料源;addEventListener 是 DOM 的具體實作。

Q2.    要不要用 RxJS?

若你需要豐富的流操作(map、filter、merge、switchMap、retry…)與嚴謹取消機制,那就用。本文提供的是輕量核心。

Q3.    需要 Typescript 嗎?

純 JS 完全可行;若專案大、事件多、payload 格式複雜,型別會大幅降低踩雷率。


總結

觀察者模式不是華麗花招,而是一種讓「狀態變化」與「反應行為」解耦的基礎技術。在 JavaScript 裡,你每天都在用(DOM 事件等),差別只在於是否有意識地設計與養成清理習慣。把本文幾個重點握住:

訂閱回傳 off,退訂成習慣。

通知過程錯誤隔離,不要一個觀察者拖垮全局。

避免通知內回寫主體;必要時要節流/鎖。

同步策略一致,必要時批次更新。

事件表、測試與文件,讓多人協作更穩。


延伸閱讀推薦:

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設計模式 : Mediator(仲裁者)

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

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

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

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

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


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