🐾

複数タブの状態同期に便利な BroadcastChannel APIを使ってみよう

に公開

最近、タブ間でのデータ同期方法としてBroadcast Channel APIの存在を知ったので、使い方やユースケースをなどを紹介します。
MDN - Broadcast Channel API

Broadcast Channel APIとは

同一オリジンの複数コンテキスト(タブ/window/iframe/Worker)間で、シンプルなPub/Subを実現するWeb標準APIです。

同名のチャンネルを各コンテキストで生成し、messageイベントにリスナーを登録すると、postMessageで送った値が同名チャネルの全コンテキストに届きます。

<script type="module">
  // 任意のタブ/ウィンドウで同一オリジン、同一チャンネル名を使用
  const appBcChannel = new BroadcastChannel('app');

  // 受信
  appBcChannel.onmessage = (ev) => {
    console.log('[recv]', ev.data);
  };

  // 送信
  document.querySelector('#send').addEventListener('click', () => {
    appBcChannel.postMessage({ type: 'PING', message: 'Hello' });
  });

  // クリーンアップ
  window.addEventListener('beforeunload', () => appBcChannel.close());
</script>

<button id="send">Send</button>

タブ間通信を実現させる他の方法

BroadcastChannelのよくある使い方としては、タブ間でのシンプルな双方向通信だと思います。

タブ間の軽量な通知に向いていますが、同様のことを実現する手段はいくつかあるため、代表的なものをまとめました(※「保存性」は“クライアント単体で値を保持できるか”の目安)。

手段 主な使用方法 保存性
BroadcastChannel 同一オリジン内の複数タブ/iframe/Worker間でメッセージをブロードキャスト。状態変更やキャッシュ更新の通知など。
LocalStorage 簡易的な設定共有やセッション情報の同期など。
Service Worker + postMessage SW経由でタブ間通信。プッシュ通知やオフラインキャッシュ制御など。 実装次第
SharedWorker 複数タブで1つのWorkerを共有。WebSocket接続や計算処理の一元化など。
WebSocket サーバー経由のリアルタイム通信。複数端末間や他ユーザーとの同期、チャットなど。 サーバー実装次第

具体的なユースケース

ログイン / ログアウト状態の即時反映

よくある実装例としては、あるタブでログアウトしたら、開いている他タブも同時にログアウトさせる/通知を出す などがあります。

LocalStorage でも実装は可能ですが、次のような扱いが必要で煩雑になりがちです。

  • 同一タブで発火しない
    • storage イベントは他タブでしか発火しないため、自タブは別処理が必要
  • クリーンアップ
    • removeItemまで通知になる/履歴の残し方を工夫する必要がある
  • シリアライズ
    • JSON化/復元が必要

以下、BroadCastChannelを使用したReact Hooksによる簡易的な実装例です。

useBroadCastChannel.tsx
import { useCallback, useEffect, useRef, useState } from "react";

export function useBroadcastChannel<T = unknown>(channelName: string) {
  const channelRef = useRef<BroadcastChannel | null>(null);
  const [message, setMessage] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (typeof window === "undefined" || !("BroadcastChannel" in window)) {
      setError(new Error("BroadcastChannel is not available"));
      return;
    }

    const ch = new BroadcastChannel(channelName);
    channelRef.current = ch;

    const onMessage = (e: MessageEvent<T>) => setMessage(e.data);
    ch.addEventListener("message", onMessage);

    return () => {
      ch.removeEventListener("message", onMessage);
      ch.close();
      if (channelRef.current === ch) channelRef.current = null;
    };
  }, [channelName]);

  const sendMessage = useCallback((msg: T) => {
    channelRef.current?.postMessage(msg);
  }, []);

  const close = useCallback(() => {
    channelRef.current?.close();
    channelRef.current = null;
  }, []);

  return { message, sendMessage, channel: channelRef.current, close, error };
}

以下使用するコンポーネントの実装例です。

import { useEffect } from "react";
import { useBroadcastChannel } from "../hooks/useBroadCast";

type AuthEvent = { type: "LOGOUT" };

const CHANNEL = "app:auth";

export function AuthListener() {
  const { message, close } = useBroadcastChannel<AuthEvent>(CHANNEL);

  useEffect(() => {
    if (message?.type === "LOGOUT") {
      // ログアウト処理・遷移など
      close();
    }
  }, [message]);

  return null;
}

export function LogoutButton() {
  const { sendMessage } = useBroadcastChannel<AuthEvent>(CHANNEL);

  return (
    <button
      type="button"
      onClick={() => {
        sendMessage({ type: "LOGOUT" });
        // ログアウト処理・遷移など
      }}
    >
      ログアウト
    </button>
  );
}

その他注意点など

  • 構造化複製(structured clone)で送受信
    • 送れるのは構造化複製可能な値なので関数やDOMノードは不可
    • 巨大な配列・バイナリはコピーコストが高く、UIをブロックし得うる
  • メッセージは永続しない
    • 開いているコンテキストにのみ届いて、履歴バッファは無く、後から開いたタブには過去メッセージは届かない
  • セキュリティ
    • 同一オリジン内のあらゆるコンテキストに届くため、シークレットやトークンをは直接流さないようにする
  • クリーンアップ
    • channelの多重登録による重複受信を防ぐため、SPAなどでコンポーネントが破棄される際は close() すること
  • チャネル名の設計
    • 衝突防止と拡張性のため、app:<domain>:<feature> のように 名前にスコープ を持たせると管理しやすい。

まとめ

BroadcastChannel APIはシンプルかつ手軽に実装できる反面、閲覧コンテキストを跨ぐことになるのでハンドリングをきちんと行わないとバグを引き起こす原因になると感じました。

クリーンアップの処理や、メッセージ送受信タイミングの調整など適切に行なっていい感じに活用していきましょう。

株式会社ガラパゴス(有志)

Discussion