同じサイトを2つのタブで開いているとき、片方でログアウトしても、もう片方はログイン中の画面のままです。そこで操作を続けるとエラーになり、利用者は何が起きたのか分かりません。ダークモードの切り替えや、カートに商品を追加したときも同じことが起こります。
この「タブ同士が互いを知らない」状態を解消するのが BroadcastChannel です。同じサイトで開いているタブやウィンドウに、一斉にメッセージを配れます。
この記事では、基本的な送受信の書き方、実際の使いどころ、localStorage を使った従来の方法との違い、そして後始末や注意点までを解説します。
目次
同じ名前のチャンネルにつなぐだけ
使い方はとても簡単です。チャンネル名を決めて BroadcastChannel を作り、postMessage() で送る。受け取る側は同じ名前でチャンネルを作り、message イベントを待つ。それだけです。
// 同じ名前を指定したタブ同士がつながる
const channel = new BroadcastChannel('my-app');
// 送る
document.querySelector('#logout').addEventListener('click', () => {
channel.postMessage({ type: 'logout' });
});
// 受け取る
channel.addEventListener('message', (event) => {
if (event.data.type === 'logout') {
location.href = '/login';
}
});
チャンネル名は文字列なら何でも構いません。サイト内で用途ごとに分けたいなら、auth や cart のように名前を分けます。名前が違うチャンネル同士ではメッセージは届きません。
ここで重要なのが、送信元のチャンネルには自分のメッセージが届かないという仕様です。上のコードで、ログアウトボタンを押したタブ自身の message ハンドラーは呼ばれません。「自分の処理は自分で書き、他のタブへは通知する」という形になります。この性質のおかげで、無限にメッセージが跳ね返るような事故が起きません。
どんなときに使うか
ログイン状態をそろえる
最も分かりやすい用途です。ログアウトやログインが起きたら全タブに知らせ、画面を更新させます。
const authChannel = new BroadcastChannel('auth');
export async function logout() {
await fetch('/api/logout', { method: 'POST' });
// 他のタブに知らせる
authChannel.postMessage({ type: 'logout' });
// 自分のタブの処理は自分で書く(送信元には届かないため)
location.href = '/login';
}
authChannel.addEventListener('message', (event) => {
switch (event.data.type) {
case 'logout':
location.href = '/login';
break;
case 'login':
// ログインされたら最新の状態を読み直す
location.reload();
break;
}
});
表示設定を同期する
ダークモードや言語の切り替えも、全タブに反映されたほうが自然です。設定の保存は localStorage に任せ、「変わったことを知らせる」役割だけを BroadcastChannel が受け持つ形にすると、役割分担がすっきりします。
const themeChannel = new BroadcastChannel('theme');
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
}
export function setTheme(theme) {
localStorage.setItem('theme', theme); // 保存は localStorage
applyTheme(theme); // 自分のタブに適用
themeChannel.postMessage(theme); // 他のタブに通知
}
themeChannel.addEventListener('message', (event) => {
applyTheme(event.data);
});
重い処理を1つのタブだけに任せる
定期的にサーバーへ問い合わせる処理を全タブで走らせると、無駄なリクエストが何倍にもなります。BroadcastChannel で「取得した結果」を配れば、通信は1タブぶんで済みます。
const channel = new BroadcastChannel('notifications');
async function fetchAndShare() {
const res = await fetch('/api/notifications');
const data = await res.json();
render(data);
// 取得した結果を他のタブにも配る
channel.postMessage({ type: 'updated', data });
}
channel.addEventListener('message', (event) => {
if (event.data.type === 'updated') {
render(event.data.data);
}
});
なお「どのタブが取得役をやるか」を厳密に1つへ絞るには、別途仕組みが必要です。タブが閉じられる可能性もあるので、簡単に済ませたい場合は、画面が表示されているタブだけが取得する(document.visibilityState で判定する)といった割り切りが現実的です。
storage イベントとの違い
BroadcastChannel が使えるようになる前は、localStorage の値を書き換えて storage イベントで検知する、という方法がよく使われていました。今でも動きますが、目的が違うぶん扱いにくい部分があります。
| 項目 | BroadcastChannel | storage イベント |
|---|---|---|
| 目的 | タブ間の通知そのもの | 保存領域の変更検知 |
| 送れるデータ | オブジェクトや配列をそのまま | 文字列のみ(JSON 化が必要) |
| 送信元のタブ | 届かない | 届かない |
| 保存が残るか | 残らない(通知だけ) | 残る(消すには別処理) |
| Worker から使えるか | 使える | 使えない |
storage イベントは「保存の副作用として通知が飛ぶ」仕組みなので、通知したいだけなのに値を保存し、同じ値を連続で書くとイベントが飛ばない、といった面倒がありました。通知が目的なら BroadcastChannel を使うほうが素直です。
一方で、値そのものを保持しておきたい場合は localStorage が必要です。前述のテーマ切り替えのように、保存は localStorage、通知は BroadcastChannel と組み合わせるのが実用的です。あとから開いたタブは localStorage から初期値を読み、開いている間の変更は BroadcastChannel で受け取ります。
送れるデータと後始末
送れるデータは構造化複製アルゴリズムでコピーできるものに限られます。文字列や数値、配列、プレーンなオブジェクト、Date、Map、Blob などは送れますが、関数や DOM 要素は送れません。渡そうとすると DataCloneError になります。
使い終わったチャンネルは close() で閉じます。ページを離れるときに閉じておくと、不要な参照が残りません。
const channel = new BroadcastChannel('my-app');
// 複製できないデータが届いたとき
channel.addEventListener('messageerror', (event) => {
console.error('メッセージを復元できませんでした', event);
});
// ページを離れるときに閉じる
window.addEventListener('pagehide', () => {
channel.close();
});
一度 close() したチャンネルに postMessage() すると InvalidStateError になります。閉じたあとも送受信したい場合は、あらためて new BroadcastChannel() でチャンネルを作り直してください。
なお unload ではなく pagehide を使っているのは、モバイル環境で unload が発火しないことがあるためです。
届かないときの確認ポイント
送信したタブに届かない
仕様どおりの動作です。postMessage() したチャンネル自身には配信されません。自分のタブの表示も更新したい場合は、送信と同じ場所で処理を呼んでください。
ただし、同じタブの中で別に作ったチャンネルには届きます。同じページで new BroadcastChannel('my-app') を2つ作れば、片方から送ったメッセージをもう片方が受け取ります。動作確認のときはこの性質が役に立ちます。
別のタブなのに届かない
まずチャンネル名のつづりを確認してください。名前が1文字でも違えば別のチャンネルです。
次にオリジンです。BroadcastChannel が届くのは同一オリジン、つまりプロトコル・ホスト名・ポートがすべて同じページ同士だけです。http と https、example.com と www.example.com、開発サーバーのポート違いは、すべて別オリジンとして扱われます。
シークレットウィンドウと通常のウィンドウの間でも届きません。保存領域が分離されているためです。
バックグラウンドのタブで処理が遅れる
メッセージ自体は届きますが、非表示のタブではブラウザーが処理を間引くため、描画などの反映が遅れることがあります。重要な処理は、タブが表示されたタイミング(visibilitychange)にも実行されるようにしておくと確実です。
古いブラウザーで動かない
Safari は比較的あとから対応したため、古いバージョンでは使えません。存在を確認してから使うようにしておけば、未対応の環境でもエラーで止まらずに済みます。
// 対応していない環境では通知をあきらめる(機能自体は動かす)
const channel =
typeof BroadcastChannel !== 'undefined'
? new BroadcastChannel('my-app')
: null;
channel?.postMessage({ type: 'logout' });
まとめ
BroadcastChannel は、同じオリジンで開いているタブやウィンドウにメッセージを配るための API です。同じ名前でチャンネルを作り、postMessage() で送り、message イベントで受け取る。覚えることはこれだけです。
押さえておきたいのは、送信元のチャンネルには届かないこと、届くのは同一オリジンのページだけであること、そして送れるのはコピーできるデータに限られることの3点です。
ログアウトの同期やテーマの切り替えなど、「全タブでそろっていてほしい状態」がある場面では効果が大きい機能です。値の保存は localStorage、変更の通知は BroadcastChannel、と役割を分けて組み合わせてみてください。