JavaScript の Pub/Sub パターン - Observer との違いと使い分け
あるモジュールの状態が変わったとき、それを知りたいモジュールに通知する。この仕組みを実現するパターンとして Observer と Pub/Sub がよく挙げられますが、両者は似ているようで設計思想が異なります。Observer は通知元と通知先が互いを知っている関係であるのに対し、Pub/Sub は間にメッセージブローカーを挟むことで、送り手と受け手を完全に切り離します。
Observer パターンの復習
Pub/Sub との違いを明確にするため、まず Observer パターンの基本形を確認しておきましょう。
class EventEmitter {
constructor() {
this.listeners = {};
}
on(event, fn) {
if (!this.listeners[event]) this.listeners[event] = [];
this.listeners[event].push(fn);
}
emit(event, data) {
(this.listeners[event] || []).forEach(fn => fn(data));
}
}Observer パターンでは、通知を受けたい側が対象オブジェクトに直接登録します。つまり、リスナーは「誰が通知してくるか」を知っており、通知元も「誰に通知するか」のリストを自分で管理しています。
const store = new EventEmitter();
store.on("update", data => {
console.log("View A received:", data);
});
store.on("update", data => {
console.log("View B received:", data);
});
store.emit("update", { item: "apple", count: 3 });
// View A received: { item: "apple", count: 3 }
// View B received: { item: "apple", count: 3 }この構造はシンプルで分かりやすいものの、リスナーが store というオブジェクトを直接参照している点が特徴です。コンポーネントが増えて依存関係が複雑になると、誰が誰を監視しているのか追いにくくなっていきます。
Pub/Sub パターンの構造
Pub/Sub パターンでは、Publisher(発行者)と Subscriber(購読者)の間に独立したメッセージチャネルを置きます。発行者はチャネルにメッセージを送るだけで、誰が受け取るかを知りません。購読者はチャネルからメッセージを受け取るだけで、誰が送ったかを知りません。
Subject(通知元)が Observer(通知先)の参照を直接保持する。両者は互いを認識しており、結合度が高い。
Publisher と Subscriber の間に独立した EventBus が介在する。双方は EventBus だけを知っていればよく、互いの存在を認識しない。
この「互いを知らない」という性質が、大規模アプリケーションにおけるモジュール分離の鍵になります。
EventBus の実装
Pub/Sub の中核となる EventBus を実装してみましょう。
const EventBus = (() => {
const channels = {};
return {
subscribe(channel, fn) {
if (!channels[channel]) channels[channel] = [];
channels[channel].push(fn);
return () => {
channels[channel] = channels[channel].filter(f => f !== fn);
};
},
publish(channel, data) {
(channels[channel] || []).forEach(fn => fn(data));
},
clear(channel) {
if (channel) {
delete channels[channel];
} else {
Object.keys(channels).forEach(key => delete channels[key]);
}
},
};
})();即時関数で channels を閉じ込め、外部からは subscribe、publish、clear だけがアクセスできます。subscribe が購読解除用の関数を返している点も重要です。
const unsubscribe = EventBus.subscribe("cart:updated", data => {
console.log("Cart badge:", data.totalItems);
});
EventBus.publish("cart:updated", { totalItems: 5 });
// Cart badge: 5
unsubscribe();
EventBus.publish("cart:updated", { totalItems: 8 });
// (何も出力されない)発行側と購読側のコードに相互参照がまったくないことを確認してください。両者をつないでいるのは "cart:updated" という文字列のチャネル名だけです。
実践例:EC サイトのモジュール間通信
EC サイトで商品をカートに追加したとき、ヘッダーのバッジ、サイドバーのカート一覧、合計金額表示など、複数のコンポーネントが反応する必要があります。これらを Observer で実装すると、カートモジュールが各コンポーネントへの参照を持たなければなりません。Pub/Sub なら、カートは「追加された」という事実を発行するだけで済みます。
// カートモジュール(Publisher)
const CartModule = {
items: [],
add(product) {
this.items.push(product);
EventBus.publish("cart:item-added", {
product,
totalItems: this.items.length,
totalPrice: this.items.reduce((sum, p) => sum + p.price, 0),
});
},
remove(productId) {
this.items = this.items.filter(p => p.id !== productId);
EventBus.publish("cart:item-removed", {
productId,
totalItems: this.items.length,
totalPrice: this.items.reduce((sum, p) => sum + p.price, 0),
});
},
};// ヘッダーバッジ(Subscriber)
EventBus.subscribe("cart:item-added", data => {
console.log(`Badge: ${data.totalItems} items`);
});
EventBus.subscribe("cart:item-removed", data => {
console.log(`Badge: ${data.totalItems} items`);
});
// 合計金額表示(Subscriber)
EventBus.subscribe("cart:item-added", data => {
console.log(`Total: ¥${data.totalPrice.toLocaleString()}`);
});
// 分析モジュール(Subscriber)
EventBus.subscribe("cart:item-added", data => {
console.log("Analytics: track add_to_cart", data.product.name);
});CartModule.add({ id: 1, name: "TypeScript 入門", price: 2800 });
// Badge: 1 items
// Total: ¥2,800
// Analytics: track add_to_cart TypeScript 入門
CartModule.add({ id: 2, name: "デザインパターン", price: 3200 });
// Badge: 2 items
// Total: ¥6,000
// Analytics: track add_to_cart デザインパターン分析モジュールが後から追加されても、CartModule のコードには一切変更が入りません。新しい Subscriber を EventBus に登録するだけで拡張が完了します。これが Pub/Sub の最大の強みです。
チャネル名の設計
Pub/Sub では、発行者と購読者をつなぐ唯一の手がかりがチャネル名(トピック名)です。命名規則を決めておかないと、プロジェクトが大きくなるにつれて混乱が生じます。
"cart:item-added" や "user:logged-in" のように、モジュール名とイベント名をコロンで区切ると衝突を防げます。チーム内で命名規則を統一しておくことが前提です。
チャネル名を文字列リテラルで散在させず、定数オブジェクトにまとめて管理する方法があります。タイポによるバグを防ぎ、IDE の補完も効くようになります。
const EVENTS = Object.freeze({
CART: {
ITEM_ADDED: "cart:item-added",
ITEM_REMOVED: "cart:item-removed",
CLEARED: "cart:cleared",
},
USER: {
LOGGED_IN: "user:logged-in",
LOGGED_OUT: "user:logged-out",
},
});
EventBus.subscribe(EVENTS.CART.ITEM_ADDED, data => {
console.log("Item added:", data.product.name);
});
EventBus.publish(EVENTS.CART.ITEM_ADDED, {
product: { name: "JavaScript 入門" },
});文字列を直接書く代わりに定数を参照することで、存在しないチャネル名を使うミスをコードレビューや静的解析で検出しやすくなります。
メモリリークへの対策
Pub/Sub で最も注意すべき落とし穴がメモリリークです。購読を解除しないまま放置すると、不要になったモジュールがいつまでもメモリに残り続けます。
class Component {
constructor(name) {
this.name = name;
this.unsubscribers = [];
}
mount() {
const unsub1 = EventBus.subscribe("cart:item-added", data => {
console.log(`${this.name}: item added`);
});
const unsub2 = EventBus.subscribe("cart:cleared", () => {
console.log(`${this.name}: cart cleared`);
});
this.unsubscribers.push(unsub1, unsub2);
}
unmount() {
this.unsubscribers.forEach(fn => fn());
this.unsubscribers = [];
console.log(`${this.name}: all subscriptions removed`);
}
}const sidebar = new Component("Sidebar");
sidebar.mount();
EventBus.publish("cart:item-added", { product: { name: "Book" } });
// Sidebar: item added
sidebar.unmount();
// Sidebar: all subscriptions removed
EventBus.publish("cart:item-added", { product: { name: "Pen" } });
// (何も出力されない)mount 時に subscribe し解除関数を配列に保存
unmount 時に全解除関数を呼び出す
不要な参照が残らずガベージコレクション可能になる
SPA のコンポーネントライフサイクルでは、この mount/unmount パターンが事実上の必須になります。React なら useEffect のクリーンアップ関数、Vue なら onUnmounted フックで同じことを行います。
Observer と Pub/Sub の選択基準
両パターンは「変更を通知する」という目的は共通していますが、適した場面が異なります。
通知元と通知先の関係が明確で、1 対少数の構造に収まるとき。DOM イベントや小規模な状態管理では、直接的な結合のほうが流れを追いやすい。
モジュール間の依存を最小限にしたいとき。マイクロフロントエンドや大規模 SPA など、コンポーネント同士が直接参照し合うべきでない構造で威力を発揮する。
Pub/Sub の疎結合は大きな利点ですが、同時にデバッグの困難さというトレードオフも持っています。
チャネル名で間接的につながっているだけなので、データの流れをコードから追跡しにくい。ログ出力やデバッグツールの整備が不可欠になる。
Observer は結合が密な分だけ依存関係が見えやすく、Pub/Sub は疎結合な分だけ追跡が難しくなります。プロジェクトの規模やチームの慣習に応じて、適切なほうを選択するのが現実的な判断といえるでしょう。
Observer パターンと Pub/Sub パターンの最も本質的な違いはどれですか?
- Observer は同期的で Pub/Sub は非同期的である
- Observer は通知元と通知先が互いを知っているが Pub/Sub は仲介者を介して分離されている
- Observer はイベント名を使わないが Pub/Sub はイベント名を使う
- Observer はクラスベースで Pub/Sub は関数ベースである
Pub/Sub パターンは、アプリケーションが成長してモジュール間の通信が複雑になったときに、その整理手段として検討すべきパターンです。ただし、何でもかんでも EventBus 経由にするとデータの流れが不透明になり、かえって保守性を損なうことがあります。直接的な呼び出しで済む場面では Observer や単純な関数呼び出しを使い、モジュール境界を越える通信に限って Pub/Sub を導入するのが、バランスのとれた設計方針になるでしょう。
Observer では Subject が Observer の参照を直接持ち、Pub/Sub では EventBus が間に入ることで Publisher と Subscriber が互いの存在を知らずに通信します。同期・非同期やクラス・関数の違いは実装上の選択であり、パターンの本質的な区別ではありません。