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(購読者)の間に独立したメッセージチャネルを置きます。発行者はチャネルにメッセージを送るだけで、誰が受け取るかを知りません。購読者はチャネルからメッセージを受け取るだけで、誰が送ったかを知りません。

Observer パターン

Subject(通知元)が Observer(通知先)の参照を直接保持する。両者は互いを認識しており、結合度が高い。

Pub/Sub パターン

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 を閉じ込め、外部からは subscribepublishclear だけがアクセスできます。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 の選択基準

両パターンは「変更を通知する」という目的は共通していますが、適した場面が異なります。

Observer が向く場面

通知元と通知先の関係が明確で、1 対少数の構造に収まるとき。DOM イベントや小規模な状態管理では、直接的な結合のほうが流れを追いやすい。

Pub/Sub が向く場面

モジュール間の依存を最小限にしたいとき。マイクロフロントエンドや大規模 SPA など、コンポーネント同士が直接参照し合うべきでない構造で威力を発揮する。

Pub/Sub の疎結合は大きな利点ですが、同時にデバッグの困難さというトレードオフも持っています。

チャネル名で間接的につながっているだけなので、データの流れをコードから追跡しにくい。ログ出力やデバッグツールの整備が不可欠になる。

Observer は結合が密な分だけ依存関係が見えやすく、Pub/Sub は疎結合な分だけ追跡が難しくなります。プロジェクトの規模やチームの慣習に応じて、適切なほうを選択するのが現実的な判断といえるでしょう。

Observer パターンと Pub/Sub パターンの最も本質的な違いはどれですか?

  • Observer は同期的で Pub/Sub は非同期的である
  • Observer は通知元と通知先が互いを知っているが Pub/Sub は仲介者を介して分離されている
  • Observer はイベント名を使わないが Pub/Sub はイベント名を使う
  • Observer はクラスベースで Pub/Sub は関数ベースである
__RESULT__

Observer では Subject が Observer の参照を直接持ち、Pub/Sub では EventBus が間に入ることで Publisher と Subscriber が互いの存在を知らずに通信します。同期・非同期やクラス・関数の違いは実装上の選択であり、パターンの本質的な区別ではありません。

Pub/Sub パターンは、アプリケーションが成長してモジュール間の通信が複雑になったときに、その整理手段として検討すべきパターンです。ただし、何でもかんでも EventBus 経由にするとデータの流れが不透明になり、かえって保守性を損なうことがあります。直接的な呼び出しで済む場面では Observer や単純な関数呼び出しを使い、モジュール境界を越える通信に限って Pub/Sub を導入するのが、バランスのとれた設計方針になるでしょう。