2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptAdvent Calendar 2023

Day 19

ブラウザの親子ウィンドウ間で通信する方法を2つ紹介するよ!

Last updated at Posted at 2023-12-18

概要

JavaScriptの Window.open で開いた子ウィンドウと通信する方法として

  1. Window.opener プロパティを使う方法
  2. Window.postMessage メソッドを使う方法

の2種類を知ったのでまとめてみた話です。

子ウィンドウてなに?

こういうやつです。ボタンを押すと子ウィンドウでQiitaが開くと思います。

See the Pen Untitled by www-tacos (@www-tacos) on CodePen.

子ウィンドウは Window.open メソッドを使って開くことができます。

window.open('https://qiita.com', '_blank', 'popup,width=390px,height=844px')

Window.open メソッドには細かい仕様がいくつかありますが、特に今回は 同一オリジンポリシー の仕様を知っておく必要があります。

同一オリジンポリシー

リファレンスでは同一オリジンポリシーは以下のように説明されています。

同一オリジンポリシーは重要なセキュリティの仕組みであり、あるオリジンによって読み込まれた文書やスクリプトが、他のオリジンにあるリソースにアクセスできる方法を制限するものです。

これにより、悪意のある可能性のあるドキュメントを隔離し、起こりうる攻撃のベクターを減らすことができます。例えば、インターネット上の悪意のあるウェブサイトがブラウザー内で JS を実行して、 (ユーザーがサインインしている) サードパーティのウェブメールサービスや (公開 IP アドレスを持たないことで攻撃者の直接アクセスから保護されている) 企業のイントラネットからデータを読み取り、そのデータを攻撃者に中継することを防ぎます。

同一オリジンポリシーがあると、例えば、サーバAにホスティングされているページが子ウィンドウでページを開くとき

  • 同じサーバAのページを開いた場合 : お互いのリソースに簡単にアクセスできる
  • 別のサーバBのページを開いた場合 : お互いのリソースに簡単にアクセスできない

となります。なので、もしサーバAのページに悪意があってもサーバBのページへの細工は防ぐことができます。

同一オリジンの定義は「ホスト名+ポート番号まで一致していること」で、以下の表が参考になると思います。(リファレンスから引用)

# URL 同一オリジン※1 理由
1 http://store.company.com/dir2/other.html 同一
オリジン
パスだけが異なる
2 http://store.company.com/dir/inner/another.html 同一
オリジン
パスだけが異なる
3 https://store.company.com/page.html 不一致 プロトコルが異なる
4 http://store.company.com:81/dir/page.html 不一致 ポート番号が異なる (http://は既定で80番ポート)
5 http://news.company.com/dir/page.html 不一致 ホストが異なる

※1 http://store.company.com/dir/page.html に対して同一オリジンであるか否か

同一オリジンの場合

親は Window.open の戻り値の WindowProxy オブジェクトを通じて子のWindowオブジェクトをほぼそのまま操作できます。

つまりWindowオブジェクトに定義したプロパティを操作できるのはもちろん、Window.document を操作できるので画面を書き換えたりもできるというわけですね。

また、子は Window.opener プロパティを通じて親のWindowオブジェクトをほぼ操作できるので、親の時と同様にWindowオブジェクトのプロパティ操作や画面の操作ができます。

同一オリジンでない場合

親は Window.open の戻り値で WindowProxy オブジェクトを取得できますが、同一オリジンの場合と異なりWindowオブジェクトのプロパティにアクセスできないようになっています。

WindowProxyのプロパティ数の違い
const w = window.open(/*URL等*/)
Object.entries(w).length
// → 同一オリジンの場合: 235
// → 同一オリジンでない場合: 6

子も同様に Window.opener で取得できるのは制限されたWindowオブジェクトとなっています。

そのため、同一オリジンでない場合は親子間で通信する方法が必要で、その1つが Window.postMessage です。

備考: IEの例外事項

IE (Internet Explorere) は特殊で、下記の例外が存在するようです。(こういう例外があるとIEからEdgeへの移行で困りそう...)

信頼済みゾーン

双方のドメインが高く信頼されたゾーン (企業のドメインなど) である場合は、同一オリジンの制限が適用されません。

ドキュメントには上記のように書かれており、おそらくIEのセキュリティレベルのことを言っているのだと思います。

こちらのサイト を参考にしましたが、おそらく「信頼済みサイト」に登録されたサイト間では制限されないということかと思います。(信頼済みサイトのセキュリティレベルをデフォルトから上げてない場合)

また、「ローカル イントラネット」は「信頼済みサイト」よりもデフォルトのセキュリティレベルが低いみたいなので、その場合も制限されないのだと思います。(ローカルホストでの開発時や社内環境とか?)

ポート番号

IEは同一オリジンポリシーの判定にポート番号を含めないそうです。
なので前述の表でいうと4番目も同一オリジンと認められることになります。

Window.postMessage

Window.postMessage は名前からわかる通り、messageを相手Windowに送るメソッドです。

だいたい以下のように使うみたいです。

  • 前提
    • 親のURL: http://localhost:8080
    • 子のURL: http://localhost:8081
送信
// 親が子に送信する場合
const child = window.open("http://localhost:8081", "_blank", "popup")
child.postMessage("Hello, Child! This is Parent.", "http://localhost:8081")

// 子が親に送信する場合
const parent = window.opener
parent.postMessage("Hello, Parent! This is Child.", "http://localhost:8080")
受信
// 親が子からのメッセージを受信する場合
window.addEventListener("message", (event) => {
  if (event.origin !== "http://localhost:8081") return;
  alert(event.data);  //→ 親ウィンドウで "Hello, Parent! This is Child." と表示される
}

// 子が親からのメッセージを受信する場合
window.addEventListener("message", (event) => {
  if (event.origin !== "http://localhost:8080") return;
  alert(event.data);  //→ 子ウィンドウで "Hello, Child! This is Parent." と表示される
}

ポイントは、postMessage自体はあくまでデータを送るだけで、それを受けて何をするかは子側に任せるというところでしょうか。

また、送信時と受信時のそれぞれで相手方のオリジンを確認するというのを推奨しているようです。

送信では、postMessageの第2引数の targetOrigin に相手方のオリジンを明記することで、悪意を持ってchildオブジェクトを書き換えられた場合にデータを送らないようにすることができます。

// childが http://evil.hacking.com:8080 に書き換えられた場合

// child.origin と taretOrigin が一致しないので送信されない
child.postMessage("ID=hoge,PASSWORD=1234", "http://localhost:8081")
// taretOrigin が任意なので送信される
child.postMessage("ID=hoge,PASSWORD=1234", "*")

受信では、eventの送信元を判定することで、悪意あるサイトからのメッセージ送信を無視することができます。

window.addEventListener("message", (event) => {
  // 送信元が想定外なら処理しない
  if (event.origin !== "http://localhost:8080") return;
  /* 以降、event.dataに対して処理 */
}

サンプル

Window.openWindow.postMessage それぞれで親子ウィンドウ間通信を行うサンプルプログラムを作ってみました。ウィンドウ間通信の処理フローを理解するのに役立てば幸いです。

構成は以下のようにしていて、npmパッケージの http-server を使って2つのローカルサーバを立てて動かす想定です。

src
├ server1  // server1配下を http://localhost:8080 で立てる
│ ├ parent.html
│ └ child.html
│
└ server2  // server2配下を http://localhost:8081 で立てる
  └ child.html
すべてのコード
server1/parent.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="data:,">
  <style>
    body { display: flex; flex-direction: column; width: 400px;  }
    body > * { margin: 8px 0; }
    body { background-color: #bbffbb; }
  </style>
  <title>親ウィンドウ</title>
</head>
<body>
  <h1>親ウィンドウ</h1>
  <label>子ウィンドウを開くときのオリジン</label>
  <div>
    <input id="mode1" name="mode" type="radio" value="http://localhost:8080" checked>
    <label for="mode1">同一オリジン</label>
  </div>
  <div>
    <input id="mode2" name="mode" type="radio" value="http://localhost:8081">
    <label for="mode2">非同一オリジン</label>
  </div>
  <label>子ウィンドウを開くときの権限</label>
  <div>
    <input id="auth1" name="auth" type="radio" value="admin" checked>
    <label for="auth1">ADMIN</label>
  </div>
  <div>
    <input id="auth2" name="auth" type="radio" value="guest">
    <label for="auth2">GUEST</label>
  </div>
  <button id="btn">子ウィンドウを開く</button>


<script>
/*--------------------------------------------------
親ウィンドウの処理
--------------------------------------------------*/
// ユーザ情報
window.userInfo = {
  id: "0001",                   // 常に変更不可
  name: "tacos",                // adminのみ変更可能
  password: "p@ssword",         // adminのみ変更可能
  email: "hogehoge@gmail.com",  // 誰でも変更可能
}


// 子ウィンドウを開く処理
document.querySelector("#btn").onclick = async () => {
  // 入力値の取得
  const targetOrigin = document.querySelector('input[type="radio"][name="mode"]:checked').value
  const authority = document.querySelector('input[type="radio"][name="auth"]:checked').value

  // ユーザ情報に権限を追加
  window.userInfo.auth = authority

  // 子ウィンドウを開く
  const child = window.open(`${targetOrigin}/child.html`, "_blank", "popup,width=540px,height=540px")

  // 同一オリジンの場合  : 子ウィンドウから親ウィンドウの参考/変更が可能なので親ウィンドウは特に何もしない
  // 非同一オリジンの場合: 子ウィンドウに権限情報を送信し、子ウィンドウからの更新要求に対応する処理を定義する
  if (window.origin === targetOrigin) {
    ;
  } else {
    // 権限情報の送信(子ウィンドウのload完了を待つ必要があるためとりあえず2秒くらい待つ)
    await new Promise((resolve) => setTimeout(resolve, 2 * 1000))
    child.postMessage(window.userInfo, targetOrigin)

    // 子ウィンドウからの更新要求に対応する処理
    window.addEventListener('message', (e) => {
      // 送信元の確認
      if (e.origin !== targetOrigin) return

      // 受信データで上書き
      if (e.data.name) { window.userInfo.name = e.data.name }
      if (e.data.password) { window.userInfo.password = e.data.password }
      if (e.data.email) { window.userInfo.email = e.data.email }
    })
  }
}
</script>
</body>
</html>
server1/child.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="data:,">
  <style>
    body { display: flex; flex-direction: column; width: 400px;  }
    body > * { margin: 8px 0; }
    body { background-color: #ffbbbb; }
  </style>
  <title>子ウィンドウ(同一オリジン)</title>
</head>
<body>
  <h1>子ウィンドウ(同一オリジン)</h1>
  <label>ユーザ情報</label>
  <div>
    <label>ID   :</label>
    <input id="id" type="text" disabled>
  </div>
  <div>
    <label>名前   :</label>
    <input id="name" type="text">
  </div>
  <div>
    <label>パスワード:</label>
    <input id="password" type="text">
  </div>
  <div>
    <label>Eメール :</label>
    <input id="email" type="text">
  </div>
  <button id="btn">ユーザ情報を更新</button>


<script>
/*--------------------------------------------------
子ウィンドウ(同一オリジン)の処理
--------------------------------------------------*/
// フィールド
const iptId = document.querySelector('input#id')
const iptName = document.querySelector('input#name')
const iptPassword = document.querySelector('input#password')
const iptEmail = document.querySelector('input#email')


// 親ウィンドウのuserInfoを直接参照してフィールドを埋める
// valueの設定
iptId.value = window.opener.userInfo.id
iptName.value = window.opener.userInfo.name
iptPassword.value = window.opener.userInfo.password
iptEmail.value = window.opener.userInfo.email
// disabledの設定
const isAdmin = window.opener.userInfo.auth === "admin"
iptName.disabled = !isAdmin
iptPassword.disabled = !isAdmin


// 更新処理
document.querySelector("#btn").onclick = () => {
  // 親ウィンドウのuserInfoを直接編集して更新する
  if (isAdmin) {
    window.opener.userInfo.name = iptName.value
    window.opener.userInfo.password = iptPassword.value
  }
  window.opener.userInfo.email = iptEmail.value
}
</script>
</body>
</html>
server2/child.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" href="data:,">
  <style>
    body { display: flex; flex-direction: column; width: 400px;  }
    body > * { margin: 8px 0; }
    body { background-color: #bbbbff; }
  </style>
  <title>子ウィンドウ(非同一オリジン)</title>
</head>
<body>
  <h1>子ウィンドウ(非同一オリジン)</h1>
  <label>ユーザ情報</label>
  <div>
    <label>ID   :</label>
    <input id="id" type="text" disabled>
  </div>
  <div>
    <label>名前   :</label>
    <input id="name" type="text">
  </div>
  <div>
    <label>パスワード:</label>
    <input id="password" type="text">
  </div>
  <div>
    <label>Eメール :</label>
    <input id="email" type="text">
  </div>
  <button id="btn">ユーザ情報を更新</button>


<script>
/*--------------------------------------------------
子ウィンドウ(非同一オリジン)の処理
--------------------------------------------------*/
// フィールド
const iptId = document.querySelector('input#id')
const iptName = document.querySelector('input#name')
const iptPassword = document.querySelector('input#password')
const iptEmail = document.querySelector('input#email')

// 同一オリジンでない場合は子ウィンドウが親ウィンドウのオリジンを知っている必要がある
const PARENT_ORIGIN = "http://localhost:8080"

// メッセージでuserInfoを受け取りフィールドを埋める
let isAdmin;
window.addEventListener('message', (e) => {
  // 送信元の確認
  if (e.origin !== PARENT_ORIGIN) return
  // valueの設定
  iptId.value = e.data.id
  iptName.value = e.data.name
  iptPassword.value = e.data.password
  iptEmail.value = e.data.email
  // disabledの設定
  isAdmin = e.data.auth === "admin"
  iptName.disabled = !isAdmin
  iptPassword.disabled = !isAdmin
})


// 更新処理
document.querySelector("#btn").onclick = () => {
  // userInfoの更新情報を親ウィンドウに送信する
  window.opener.postMessage({
    name: isAdmin ? iptName.value : null,
    password: isAdmin ? iptPassword.value : null,
    email: iptEmail.value
  }, PARENT_ORIGIN)
}
</script>
</body>
</html>

親ウィンドウ

親ウィンドウは以下のような画面で、同一オリジンかそうでないかと、権限を選択して子ウィンドウを開く機能を持ちます。

See the Pen Untitled by www-tacos (@www-tacos) on CodePen.

また、windowオブジェクトのプロパティに userInfo を持たせていて、子ウィンドウにはこれを更新する機能を持たせています。

window.userInfo = {
  id: "0001",                   // 常に変更不可
  name: "tacos",                // adminのみ変更可能
  password: "p@ssword",         // adminのみ変更可能
  email: "hogehoge@gmail.com",  // 誰でも変更可能
}

子ウィンドウ

子ウィンドウは以下のような画面で、親ウィンドウのユーザ情報を表示して更新できる機能を持ちます。

See the Pen screen-child by www-tacos (@www-tacos) on CodePen.

処理の流れ

同一オリジンの場合

親ウィンドウは子ウィンドウを開くだけで、そのあとは特に何もしないです。

const child = window.open(`${targetOrigin}/child.html`, "_blank", "popup,width=540px,height=540px")

if (window.origin === targetOrigin) {
  ;
} else {

子ウィンドウは開かれると同時に Window.opener を経由して親ウィンドウの userInfo を参照して、フィールドの初期値を埋めます。

// 親ウィンドウのuserInfoを直接参照してフィールドを埋める
// valueの設定
iptId.value = window.opener.userInfo.id
iptName.value = window.opener.userInfo.name
iptPassword.value = window.opener.userInfo.password
iptEmail.value = window.opener.userInfo.email
// disabledの設定
const isAdmin = window.opener.userInfo.auth === "admin"
iptName.disabled = !isAdmin
iptPassword.disabled = !isAdmin

また、更新ボタンが押されたときは Window.opener を経由して親ウィンドウの userInfo を直接書き換えます。

document.querySelector("#btn").onclick = () => {
  // 親ウィンドウのuserInfoを直接編集して更新する
  if (isAdmin) {
    window.opener.userInfo.name = iptName.value
    window.opener.userInfo.password = iptPassword.value
  }
  window.opener.userInfo.email = iptEmail.value
}

同一オリジンでない場合

子ウィンドウを開いたあと、子ウィンドウに userInfo を送っています。

// 権限情報の送信(子ウィンドウのload完了を待つ必要があるためとりあえず2秒くらい待つ)
await new Promise((resolve) => setTimeout(resolve, 2 * 1000))
child.postMessage(window.userInfo, targetOrigin)

ちなみにコメントにも書いているように、子ウィンドウのloadが完了する前にpostMessageを実行してしまうと子ウィンドウが userInfo を受け取れないので、今回は適当な待ち時間を入れてます。
※厳密に実装するなら、例えば子ウィンドウのloadイベントをトリガーに親ウィンドウにload完了メッセージを送り、それの受信をトリガーに userInfo を送るとかでしょうか。

次に、子ウィンドウは親ウィンドウから受信した userInfo を使ってフィールドの初期値を埋めます。

window.addEventListener('message', (e) => {
  // 送信元の確認
  if (e.origin !== PARENT_ORIGIN) return
  // valueの設定
  iptId.value = e.data.id
  iptName.value = e.data.name
  iptPassword.value = e.data.password
  iptEmail.value = e.data.email
  // disabledの設定
  isAdmin = e.data.auth === "admin"
  iptName.disabled = !isAdmin
  iptPassword.disabled = !isAdmin
})

ちなみに今回は省略していますが e.datauserInfo の型と一致しているかの確認も行った方がよいですね。
リファレンスの ここ にも以下のような記載があります。

常に受け取ったメッセージの構文を確かめるべきです。

話を戻して、更新ボタンが押されたときは子ウィンドウは親ウィンドウに新しい userInfo を送り、親ウィンドウは受け取ったメッセージで userInfo を更新します。

子ウィンドウからの送信
document.querySelector("#btn").onclick = () => {
  // userInfoの更新情報を親ウィンドウに送信する
  window.opener.postMessage({
    name: isAdmin ? iptName.value : null,
    password: isAdmin ? iptPassword.value : null,
    email: iptEmail.value
  }, PARENT_ORIGIN)
}
親ウィンドウでの受信
window.addEventListener('message', (e) => {
  // 送信元の確認
  if (e.origin !== targetOrigin) return

  // 受信データで上書き
  if (e.data.name) { window.userInfo.name = e.data.name }
  if (e.data.password) { window.userInfo.password = e.data.password }
  if (e.data.email) { window.userInfo.email = e.data.email }
})

まとめ

JavaScriptの Window.open で開いた子ウィンドウと通信する方法として

  1. Window.opener プロパティを使う方法
  2. Window.postMessage メソッドを使う方法

の2種類をサンプルも交えてまとめてみました。

ちなみに最近(2023/11月末)、ウィンドウ間で3Dの描画を同期するみたいな話が盛り上がったと思うのですが、それを追っていたら以下の記事が見つかりました。

こちらの記事をみると localStorage や Socket.IO なるものを使ってウィンドウ間通信を行っているようですね。

さらに この方の別記事 によると Broadcast Channel API と Service Worker を使った方法もあるようなので、こちらも参考になると思います。

記事は以上になります。最後まで読んでくださりありがとうございます。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?