如果你也常被「狀態改了要通知誰」這題追著跑,別急著塞更多 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設計模式 : Flyweight(享元模式)
javaScript設計模式 : Chain of Responsibility(責任鏈)
javaScript設計模式 : Command Pattern(命令模式)
javaScript設計模式 : Interpreter(直譯器)
javaScript設計模式 : Iterator(迭代器)
javaScript設計模式 : Mediator(仲裁者)
javaScript設計模式 : Strategy(策略模式)
