本記事では、BroadcastChannel APIを活用して、遷移元のウィンドウと遷移先のウィンドウ間で効率的に通信する方法について解説します。特に、window.openerを使用した従来の方法のデメリットを回避する手法となっています。
window.openerを利用したタブ間通信方法Web アプリケーションにおいて、遷移先のウィンドウで何らかの処理を行い、その結果を元のウィンドウに通知する必要がある場面は少なからずあります。このような場合はwindow.openerを利用して実現できます。これは一般的に利用される方法で、遷移元のタブから遷移先のタブを開き、遷移先のタブが遷移元のタブにアクセスするための手段です。
遷移元のタブ(タブ A とします)で以下のようにリンクを設定し、遷移先のタブ(タブ B とします)を開けるようにします。この時、rel="opener"を指定することで、遷移先のタブからwindow.openerを介して遷移元のタブへアクセスできるようになります。
※window.openerが参照できるようにする方法はいくつかありますが、ここではrel="opener"を利用した方法を紹介します。
tabA.html
<a href="https://example.com" target="_blank" rel="opener">新しいタブを開く</a>
<script>
  window.addEventListener("message", (event) => {
    console.log("タブBからのメッセージ:", event.data)
  })
</script>
遷移先のタブから開かれた遷移先のタブでは、以下のようにwindow.openerを利用して遷移元のタブにアクセスできます。
tabB.html
<!-- タブBのコード -->
<div>
  <h1>タブB</h1>
</div>
<script>
  // タブAに対してwindow.openerを利用してメッセージを送信
  if (window.opener) {
    window.opener.postMessage("Hello from Tab B", "*")
  }
</script>
これは、window.openerによって遷移元のタブのオブジェクトを参照し、postMessageメソッドを使用してメッセージを送信する方法です。ウィンドウオブジェクトとなっているので、他のウィンドウオブジェクトと同様に、遷移元のタブのプロパティやメソッドにアクセスできます。
window.openerを利用した方法では、遷移元のタブと遷移先のタブ間の通信は可能ですが、デメリットもあります。それは、遷移元のウィンドウオブジェクトを参照として保持することで、タブごとのメモリ使用量が増加することです。
実際にメモリ使用量の違いを検証してみましょう。
大きくメモリを使用する遷移元のタブを作成し、そこからwindow.openerありとなしで遷移先のタブを開いて比較します。
以下の例では、500 万個の DOM 要素をオブジェクトの参照として保持する遷移元のタブを作成します。
tabA.html
<!DOCTYPE html>
<html lang="ja">
  <body>
    <!-- openerあり -->
    <button id="with-opener">タブBを開く(openerあり)</button>
    <br />
    <!-- openerなし -->
    <button id="no-opener">タブBを開く(openerなし)</button>
    <br />
  </body>
  <script>
    const withOpenerButton = document.getElementById("with-opener")
    const noOpenerButton = document.getElementById("no-opener")
    withOpenerButton.addEventListener("click", () => {
      // openerありでタブBを開く
      window.open("tabB.html", "_blank", "opener")
    })
    noOpenerButton.addEventListener("click", () => {
      // openerなしでタブBを開く
      window.open("tabB.html", "_blank", "noopener")
    })
    // 大量のDOM要素を生成してメモリを消費
    const largeArray = new Array(5000000)
      .fill(null)
      .map(() => document.createElement("div"))
    // メモリ使用量を表示(performance.memoryは非推奨ですが、デモのために使用)
    if (performance.memory) {
      console.log("タブAのメモリ使用量:", performance.memory.usedJSHeapSize)
    }
  </script>
</html>
以下のコードの遷移先のタブを開いた時のメモリ使用量を比較します。
tabB.html
<!DOCTYPE html>
<html lang="ja">
  <body>
    <h1>タブB</h1>
  </body>
  <script>
    // メモリ使用量を表示(performance.memoryは非推奨ですが、デモのために使用)
    if (performance.memory) {
      console.log("タブBのメモリ使用量:", performance.memory.usedJSHeapSize)
    }
  </script>
</html>
開発者ツールでメモリ使用量を確認すると、遷移元のタブのウィンドウオブジェクトの参照を持っている場合は持っていない場合と比較して、メモリ使用量は増加していることがわかります。
| 遷移元のタブのウィンドウオブジェクトの参照あり | 遷移元のタブのウィンドウオブジェクトの参照なし | 
|---|---|
  | 
  | 
タブ単位で保持するメモリ量が増加すると、パフォーマンスに影響を与える可能性があり、最悪の場合 Out of Memory エラーによりアプリケーションがクラッシュする可能性もあります。
今回はwindow.openerを利用したオブジェクトウィンドウの参照なしに遷移元・遷移先の通信を可能にするBroadcastChannelを活用したアプローチを紹介します。
BroadcastChannelは、同一オリジン内の複数のブラウザタブやウィンドウ間でメッセージを送受信するための Web API です。
BroadcastChannelは、主要なモダンブラウザ(Chrome,Firefox,Safari,Edge)でサポートされています。ただし、古いブラウザや一部の環境ではサポートされていないため、注意が必要です。
古いブラウザをサポートする必要がある場合は、polyfillの使用すれば擬似的にBroadcastChannelを実現できます。
詳細なサポート状況はMDN のドキュメントを参照してください。
複数タブで同じチャンネル名を使ってBroadcastChannelインスタンスを作成します。
const channel = new BroadcastChannel("my_channel")
BroadcastChannelを利用してメッセージを送信するには、postMessageメソッドを使用します。送信できるデータは文字列やオブジェクトなど、シリアライズ可能なものに限られます。このとき、送信されたデータは、同じチャンネル名を持つすべてのタブやウィンドウで受信されます。
// 文字列の送信
channel.postMessage("Hello, World!")
// オブジェクトの送信
channel.postMessage({
type: "notification",
data: { userId: 123, message: "処理が完了しました" },
})
受信側では、messageイベントをリッスンしてメッセージを受け取ります。受信したメッセージは、イベントオブジェクトのdataプロパティに格納されます。
channel.addEventListener("message", (event) => {
console.log("Received message:", event.data)
})
使用が完了したら、チャンネルを閉じてリソースを開放します。
channel.close()
BroadcastChannelは同一オリジン内の複数のタブへ一斉にメッセージを送信します。そのため、遷移先のタブから遷移元のタブのみにはメッセージを送信できません。
たとえば、タブ A がタブ B を開いた状態で、別のタブ C で同じチャンネル名を使用したBroadcastChannelインスタンスが作成されていた場合、タブ B から送信されたメッセージはタブ A とタブ C の両方で受信されてしまいます。
そのため、一対一通信(厳密には遷移先から遷移元への通信)を実現するためには、遷移元のタブと遷移先のタブで利用するチャンネル名を問題ない範囲で一意にする必要があります。
ここでいう「問題ない範囲」とは後述する、window.openerを利用した方法の同等の制約を満たす範囲です。
そもそもwindow.openerを使った通信はいくつか制約が挙げられます。
これを踏まえると、遷移先から遷移元への通信のみ行えることがポイントです。タブ A ・タブ B 間と、タブ A・タブ C 間で通信する場合、タブ B とタブ C では送信のみ行い受信をしなければ、同じチャンネル名を利用しても問題ありません。 したがって、遷移元のタブごとに一意なチャンネル名を利用すれば、遷移元・遷移先のタブ間での通信を実現できます。
上記から特定のタブ間で通信したい場合、遷移先タブは遷移元のタブ固有の情報を持つことでそのままチャンネル名として利用できれば必要な範囲で一意にチャンネル名をきめられます。例えば、遷移元のタブに割り当てられた UUID をチャンネル名として利用できれば、遷移先タブは遷移元タブに対して特定のメッセージを送信できます。
しかし、遷移元タブに割り当てられた UUID を遷移先タブにどのように渡すかが問題となります。ここでいくつかの方法を考えることができます。
遷移元のタブから遷移先のタブを開く際に、URL パラメータとして UUID を渡す方法です。
しかし、この方法にはいくつかのデメリットがあり、URL パラメータが存在する状態でリロードを行ったときと見分けがつかなかったり、URL パラメータを変更してしまうとチャンネル名が変わってしまうため、タブ間通信ができなくなる可能性もあります。
document.referrerを利用して遷移元のタブの情報を取得し、チャンネル名として使用する方法です。
まずは遷移元のタブで、BroadcastChannelを利用して遷移先のタブを開く際に、ユニークなチャンネル名を生成し、そのチャンネル名を 自身の URL のパスパラメーターとして設定します。そして遷移します。
<button>タブBを開く</button>
<script>
  const link = document.querySelector("button")
  link.addEventListener("click", onClick)
  /** 遷移元のタブでの処理 */
  function onClick(e) {
    e.preventDefault() // デフォルトのリンク遷移を防ぐ
    const uuid = crypto.randomUUID() // ユニークなIDを生成
    const channel = new BroadcastChannel(uuid)
    channel.onmessage = (event) => {
      console.log("遷移先のタブからのメッセージ:", event.data)
      // 必要に応じてチャンネルをクローズ
      channel.close()
    }
    // 自身のURLを書き換えて、UUIDを含める
    const originalUrl = location.href.split("?")[0]
    history.replaceState(null, "", `${originalUrl}?channel=${uuid}`)
    // 遷移先のタブを開く
    window.open("tabB.html", "_blank", "noopener")
    // 遷移元のタブのURLを元に戻す(オプション)
    history.replaceState(null, "", originalUrl)
  }
</script>
次に、遷移先のタブではdocument.referrerを利用して遷移元のタブの URL を取得し、そこからチャンネル名を抽出してBroadcastChannelインスタンスを作成します。
/**  遷移先のタブでの処理 */
let channel = null
if (document.referrer) {
  try {
    const referrerUrl = new URL(document.referrer)
    const parentChannelId = referrerUrl.searchParams.get("channel")
    if (parentChannelId) {
      channel = new BroadcastChannel(parentChannelId)
    }
  } catch (error) {
    console.error("チャンネル確立に失敗:", error)
  }
}
// 何かしらの処理後に遷移先のタブから遷移元のタブに送信
function onClick() {
  if (channel) {
    channel.postMessage("Hello from Tab B")
    channel.close() // チャンネルを閉じる
  } else {
    console.error("チャンネルが確立されていません")
  }
}
UUID を利用することで、タブごとに一意のチャンネル名を生成し、特定のタブ間での通信を実現できますが UUID の生成や管理が必要になります。特にページのリロードやタブの再オープン時に UUID を適切に管理する必要があります。これにはsessionStorageが利用できます。
document.referrerを利用する方法では、以下の点がwindow.openerを利用した通信と異なります。
遷移元タブのヒストリーを一時的に汚すことになります。(ただし、結果的には history の履歴を汚すことはありません。)
BroadcastChannelは文字列やオブジェクトなど、シリアライズ可能なデータを送受信できますが、クラスのインスタンスや関数, DOM 要素は送信できません。
仮にアンカータグに設定する場合はクリックイベントで遷移することになるので「新しいウィンドウで開く」などの独自の振る舞いが損なわれます。ユーザー体験にも影響を与えるため一般的には良くないとされています。できるだけ避けましょう。
今回は、BroadcastChannelを利用して遷移元のタブと遷移先のタブ間で通信する方法を紹介しました。
ただ、今回紹介したアプローチを利用するケースは、window.openerを利用した方法と比較して、特定のタブ間での通信を実現するために UUID を生成・管理する必要があるため少し複雑になります。その結果得られるメリットは、遷移元のウィンドウオブジェクトの参照を保持しなくてもタブ間通信が可能になることだけなので、シンプルなケースではwindow.openerを利用した方法で十分です。
加えて、実際ページ単位でメモリを大量に消費しているケースでは、まずはメモリリークを疑いページ単位での改善を試みてください。
みなさんも、次回タブ間通信が必要になったときは、ぜひBroadcastChannelを利用した方法を検討してみてください。遷移元のウィンドウオブジェクトの参照を持たなくても通信可能で、メモリ効率の良い実装が可能になります。