BETA

ブラウザのウィンドウを跨いでリアルタイムにデータを連携する

投稿日:2018-12-23
最終更新:2018-12-31

こんにちは。 たーせるです。 この前、Angular でアプリケーションを作っているチームから、こんなご相談を受けました。

  1. あるページ A から、別のページ B を window.open() で起動したい

  2. ページ B で何らかの操作を行うと、その結果が即座に ページ A に反映されるようにしたい(ウィンドウ同士でイベントを送り合いたい)

そんなわけで今回は、「ウィンドウ間のデータ・イベント連携(クロスドキュメントメッセージング)」にフォーカスを絞って考えます。

なお、モーダレスなウィンドウを複数起動したり、それらを相互連携させたりといった仕掛けは、そもそも設計の難易度が高くなりがちですのでくれぐれも注意しましょう。

手法の検討

ウィンドウを跨いでデータの連携を行うには、localStorage を利用する方式と、postMessage を利用する方式が考えられます。

案1: localStorage を用いる方式

  • 概要:
    • localStorage 介して、なんらかの状態変数をウィンドウ間で共有する
    • それぞれのウィンドウは、localStorage を定期的にポーリングして、状態変数の変化を検知する
  • 長所:
    • ページを再読み込みしても、ウィンドウ間の連携を維持できる
  • 短所:
    • 相手のウィンドウとオリジンが異なる場合、そもそもこの手法は通用しない
    • 各ウィンドウが定期的に localStorage をつつき合うので、ウィンドウが増えるとだんだん重くなる
    • localStorage のキー設計が少々面倒(特に多対多連携の場合)
    • localStorage のお掃除処理の設計が煩雑

案2: postMessage を用いる方式

  • 概要:
    • 相手のウィンドウに対して、直接メッセージを送り合う
    • メッセージ受信をトリガとするイベント処理を実装する
  • 長所:
    • 相手のウィンドウとオリジンが異なる場合でも連携が可能である
  • 短所:
    • 相手に確実にメッセージが届いたことを送信元は検知できない
    • ページを再読み込みすると、連携が解除されてしまう(別ページに遷移して戻ってきた場合も同様)
    • XSS 対策が必須である

いずれも一長一短ありますが、今日は postMessage を用いた手法を詳解します。

postMessage によるウィンドウ間の双方向通信

以降は、説明の都合上、新規ウィンドウを生成する側を「親」、生成されたウィンドウを「子」と呼ぶことにします。

処理のイメージ

初期処理

まず前提として、メッセージを送り合うためには、お互いが相手先のウィンドウへの参照を保持していなくてはなりません。 そのためには、以下のような前処理が必要です。

  1. 親ウィンドウは、子ウィンドウを生成する。
  2. 親から子に対してダミーの「Hello」メッセージを一定間隔で送信する(子の初期化処理が完了する前に送信されたメッセージは捨てられてしまうため、子側の準備が整うまでメッセージを送り続ける必要がある)。
     
  3. 親から「Hello」メッセージ受信に成功した子は、メッセージに含まれる source 情報から親ウィンドウの参照を取得する(この時点で初めて、子が親を認識できるようになる)。
     
  4. 子は、親に対して応答メッセージを返す。
  5. 子からの応答メッセージを受信した親は、「Hello」メッセージの送信を止める(この時点で初めて、親は子と双方向通信が可能になったことを検知できる)。

イベント処理

メッセージを受信したら、それがしかるべき相手からのメッセージかどうかを確認する必要があります。 悪意の第三者がなりすましてメッセージを送ってくる恐れがあるためです。

  1. メッセージに含まれる origin 情報が、想定している送信元のオリジンと一致するかどうかをチェックする(一致しない場合はイベントを捨てる)
  2. メッセージに含まれる data 情報(=メッセージ本体)に基づいて、後続処理を行う。

終了処理

お互いのウィンドウ参照を持ち合ったままだと、メモリリークの危険性がある(らしい)ので、ビビりなぼくはウィンドウの死に際の処理で明示的に解放処理を書くようにしています。

  1. ウィンドウを閉じる場合は、必要に応じて相手に「Goodbye」メッセージを送る。
  2. 相手のウィンドウオブジェクトに null を代入する。

では、いよいよ実装していきましょう。

実装サンプル

概要

簡単なメッセージ相互通信のサンプルです。 動きのイメージは以下の通りです。

  1. 親ウィンドウ。ボタンを押すと、子が新規のタブで開く。
    その際、新たなウィンドウID(UUID)を発行し、Hello メッセージとともに子に通知する。

  2. 子の起動直後の様子。親から受け取った自身のウィンドウIDを表示する。
    また、親に対して応答メッセージを返す。

  3. 親側に表示を切り替えると、子から届いたメッセージが画面に反映されている。

  4. 子を閉じると、子から親に Goodbye メッセージが送信され、親側の画面に即座に反映される。

環境

  • Node 10.14.1
  • Angular CLI 7.1.0

以下の要領でサクッとプロジェクトを作ります。

$ng new PostMessageSample --routing  
$cd PostMessageSample  
$ng g component Page1  
$ng g component Page2  

フォルダ構成(抜粋)

src  
  +- app  
  |    +- page1  
  |    |    +- page1.component.css  
  |    |    +- page1.component.html  
  |    |    +- page1.component.ts  
  |    |  
  |    +- page2  
  |    |    +- page2.component.css  
  |    |    +- page2.component.html  
  |    |    +- page2.component.ts  
  |    |  
  |    +- app-routing.module.ts  
  |    +- app.component.css  
  |    +- app.component.html  
  |    +- app.component.ts  
  |    +- app.module.ts  
  |    +- message-info.ts  
  |  
  +- environments  
  |    +- environment.ts  
  |  
  +- favicon.ico  
  +- index.html  
  +- main.ts  
  +- styles.css  

環境の設定

まずは、メッセージのやり取りを許容するオリジンを、 environment.ts に定義しておきます。

environments/environment.ts

export const environment = {  
  production: false,  
  origin: 'http://localhost:4200'  
};  

開発用・テスト用・本番用と、環境によって切り替える必要がある定数は、environments を利用しましょう。

メッセージ構造体の定義

次に、やり取りするメッセージのデータ構造体を考えます。

今回は、親から子を複数起動する可能性があるため、子を識別するための windowId と、メッセージ本文 content を持つ構造体にしました。

src/message-info.ts

export class MessageInfo {  
  windowId: string;  
  content: string;  
}  

ルーティングの設定

今回は、親(page1)と子(page2)のルーティングを以下のように定義しました。

src/app-routing.module.ts

import { NgModule } from '@angular/core';  
import { Routes, RouterModule } from '@angular/router';  
import { Page1Component } from './page1/page1.component';  
import { Page2Component } from './page2/page2.component';  

const routes: Routes = [  
  { path: '', component: Page1Component, pathMatch: 'full' },  
  { path: 'page1', redirectTo: '' },  
  { path: 'page2', component: Page2Component }  
];  

@NgModule({  
  imports: [RouterModule.forRoot(routes)],  
  exports: [RouterModule]  
})  
export class AppRoutingModule { }  

親ウィンドウ実装

ここから本題となります。 とはいっても、特に目新しい処理はありません。

少しだけ癖があるとすれば、イベントの購読には @HostListener ではなく rxjs を利用している点でしょう。

また、複数の子ウィンドウを立ち上げたとき、どのウィンドウから応答が返ってきたかを管理するために、Map<String, Subscription> を利用しています。

子からの応答があるまでは Helloメッセージを送り続け、応答がきたら止める(以降はその子に対して Hello は送らない)という制御に関する部分は、特に注意深く読み解いていただければと思います。

src/page1/page1.component.html

<p>  
  <button (click)="open()">別タブで開く</button>  
</p>  

<h3>イベント一覧</h3>  
<ul>  
  <li *ngFor="let log of logMessages">{{log}}</li>  
</ul>  

src/page1/page1.component.ts

import { Component, OnDestroy } from '@angular/core';  
import { interval, Subscription, fromEvent } from 'rxjs';  
import { map, filter } from 'rxjs/operators';  
import { MessageInfo } from '../message-info';  
import { environment } from 'src/environments/environment';  
import * as uuid from 'uuid';  

@Component({  
  selector: 'app-page1',  
  templateUrl: './page1.component.html',  
  styleUrls: ['./page1.component.css']  
})  
export class Page1Component implements OnDestroy {  

  // ログメッセージ  
  private logMessages: string[] = [];  

  // ウィンドウIDと接続状況の紐づけ Map  
  private connectedMap = new Map<String, Subscription>();  

  // ウィンドウが閉じられるときも、OnDestroy が呼ばれるようにする  
  private readonly unloadEvent$ = fromEvent(window, 'beforeunload')  
    .subscribe(() => this.ngOnDestroy());  

  private readonly messages$ = fromEvent(window, 'message')  
    .pipe(  
      map(e => e as MessageEvent),  
      filter(e =>  
        e.origin === environment.origin &&  
        !!(e.data as MessageInfo).windowId),  
      map(e => e.data as MessageInfo),  
      filter(e => this.connectedMap.has(e.windowId)));  

  // 接続応答が返ってきたときのイベント  
  private readonly connectionResponseReceived$ = this.messages$  
    .pipe(  
      filter(e => !this.connectedMap.get(e.windowId).closed))  
    .subscribe(e => {  
      this.connectedMap.get(e.windowId).unsubscribe();  
      this.logMessages.push(`[${e.windowId}] Connected!`);  
    });  

  // メッセージ受信時のイベント  
  private readonly messageReceived$ = this.messages$  
    .pipe(  
      filter(e => this.connectedMap.get(e.windowId).closed))  
    .subscribe(msgInfo =>  
      this.logMessages.push(`[${msgInfo.windowId}] ${msgInfo.content}`));  

  open() {  
    // ウィンドウID払い出し  
    const childWindowId = uuid.v4();  

    // 新しいウィンドウでページを開く  
    const childWindow = window.open(`/page2`);  

    const msg: MessageInfo = {  
      windowId: childWindowId,  
      content: ''  
    };  

    // 新しいウィンドウから応答があるまで一定間隔で接続要求を送り続ける。  
    this.connectedMap.set(childWindowId,  
      interval(10)  
        .subscribe(() => {  
          childWindow.postMessage(msg, environment.origin);  
        }));  
  }  

  // 各種解放処理  
  ngOnDestroy() {  
    this.unloadEvent$.unsubscribe();  
    this.connectionResponseReceived$.unsubscribe();  
    this.messageReceived$.unsubscribe();  
    this.connectedMap.forEach(val => val.unsubscribe());  
  }  
}  

子ウィンドウ実装

src/page1/page2.component.html

<p *ngIf="!!windowId">  
  新しいウィンドウが別タブで開かれました。  
  このウィンドウのIDは「{{windowId}}」です。  
</p>  

src/page1/page2.component.ts

import { Component, OnDestroy } from '@angular/core';  
import { fromEvent } from 'rxjs';  
import { map, filter, first } from 'rxjs/operators';  
import { MessageInfo } from '../message-info';  
import { environment } from 'src/environments/environment';  

@Component({  
  selector: 'app-page2',  
  templateUrl: './page2.component.html',  
  styleUrls: ['./page2.component.css']  
})  
export class Page2Component implements OnDestroy {  
  private srcWindow: Window;  
  private srcOrigin: string;  
  private windowId: string;  

  // ウィンドウが閉じられるときも、OnDestroy が呼ばれるようにする  
  private readonly beforeUnload$ = fromEvent(window, 'beforeunload')  
    .subscribe(() => this.ngOnDestroy());  

  // 呼び出し元からの接続メッセージ受信イベント  
  private readonly messageReceived$ = fromEvent(window, 'message')  
    .pipe(  
      map(e => e as MessageEvent),  
      filter(e =>  
        e.origin === environment.origin &&  
        !!(e.data as MessageInfo).windowId),  
      first())  
    .subscribe(e => {  
      const messageInfo = e.data as MessageInfo;  
      [this.srcWindow, this.srcOrigin, this.windowId] =  
        [e.source as Window, e.origin, messageInfo.windowId];  

      this.onConnectionRequestReceived();  
      this.windowId = messageInfo.windowId;  
    });  

  // 接続リクエストメッセージが届いたときに1度だけ実行される  
  onConnectionRequestReceived() {  
    // 接続元に応答を返す  
    this.srcWindow.postMessage({  
      windowId: this.windowId,  
      content: ''  
    }, this.srcOrigin);  

    // 「こんにちは」メッセージを送る  
    this.srcWindow.postMessage({  
      windowId: this.windowId,  
      content: 'Hello'  
    }, this.srcOrigin);  
  }  

  // コンストラクタ  
  constructor() {  
    this.windowId = sessionStorage.windowId;  
    this.srcOrigin = sessionStorage.srcOrigin;  
    this.srcWindow = sessionStorage.srcWindow;  
  }  

  // コンポーネントが破棄される前の処理  
  ngOnDestroy() {  
    try {  
      if (!this.srcWindow) { return; }  

      sessionStorage.srcWindow = this.srcWindow;  
      sessionStorage.srcOrigin = this.srcOrigin;  
      sessionStorage.windowId = this.windowId;  

      // ダイイングメッセージを送る  
      this.srcWindow.postMessage({  
        windowId: this.windowId,  
        content: 'GoodBye'  
      }, this.srcOrigin);  

    } finally {  
      // ウィンドウオブジェクトの明示的解放  
      this.srcWindow = null;  

      // 各種イベントの購読を解除  
      this.messageReceived$.unsubscribe();  
      this.beforeUnload$.unsubscribe();  
    }  
  }  
}  

注意すべきこと

このサンプルでは、2つのウィンドウのライフサイクルを厳密に管理していません。

親と子のどちらが先に閉じられるか、メッセージが届かなかった場合のリトライをどのように行うか、同じページの多重起動を許容するかといった方針についても、別途検討が必要です。

また、冒頭にも書いた通り、どちらかのウィンドウを再読み込みした瞬間に、メモリ上から相手先の情報が消えてしまうため、連携が壊れてしまいます。 この際の回復処理についても、今回のサンプルでは考慮されていません。

まぁ、複数ウィンドウ間の連携という仕様を導入してしまうと、せっかくの Angular + SPA という統制が破綻してしまうので、たとえ技術的に可能だとしてもぼくはあまり積極的に実践しようとは思わないかなー、という、身もふたもない結論でした。

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@tercelの技術ブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう