🐾
複数タブの状態同期に便利な 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()
すること
- channelの多重登録による重複受信を防ぐため、SPAなどでコンポーネントが破棄される際は
- チャネル名の設計
- 衝突防止と拡張性のため、app:<domain>:<feature> のように 名前にスコープ を持たせると管理しやすい。
まとめ
BroadcastChannel APIはシンプルかつ手軽に実装できる反面、閲覧コンテキストを跨ぐことになるのでハンドリングをきちんと行わないとバグを引き起こす原因になると感じました。
クリーンアップの処理や、メッセージ送受信タイミングの調整など適切に行なっていい感じに活用していきましょう。
Discussion