GoogleMapsAPI
angular
AngularDay 18

Angularの外部でイベントが発生した時の変更検知の方法

2017年9月からGoogleのプレミアムパートナーでもある株式会社ゴーガでエンジニアをしており、Google Maps APIとAngularを使って地図関連のWebサービスを開発しています。

特にテーマが思いつかないので、直近でハマったことについて書きます。Google Mapのようなサードパーティーのライブラリを使用した際に注意しておきたいことです。

はじめに

Google Mapのマーカー(地図に表示される指標)をクリックした時に、マーカーの情報を元にAngularのコンポーネントを更新する処理を書いていたところ、Angular Materialのダイアログがうまく開かないという問題が発生しました。なぜだろうと思っていろいろ調べていたら、マーカーをクリックした時にコンポーネントのngAfterViewCheckedが全く反応していない(ビューが更新されていない)ことに気付きました。

結論としては、Google MapのクリックイベントがAngularの管轄外であり、いくらマーカーをクリックしてもAngularのChange Detectorが反応しなかったということでした。

どのように実装していたか(Before)

実際のコードは説明に必要のないものが含まれているので、簡単な例で説明します。

例えば、以下のようなGoogle Mapを表示するためのService(google-map.service.ts)があったとします。initMapを呼び出せば、地図を初期化します。addMarkerを呼び出せば、引数に渡したマーカーの情報を元に地図上にマーカーを表示します。

google-map.service.ts
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
...

@Injectable()
export class GoogleMapService {
  map: any;
  markerClick: Subject<Marker> = new Subject<Marker>();

  /**
   * 地図を初期化
   */
  initMap() {
    this.map = new google.maps.Map(document.getElementById('google-maps'), {
      zoom: 5,
      mapTypeId: google.maps.MapTypeId.ROADMAP,
      center: { ... },
    });
  }

  /**
   * マーカーを地図に表示
   */
  addMarker(markerData: MarkerData) {
    markerData.map(marker => {
      const gMarker = new google.maps.Marker({
        position: { ... },
        icon: { ... },
        map: this.map,
      });

      // マーカーをクリックした時の処理(今回のテーマのメインの部分)
      gMarker.addListener('click', () => this.markerClick.next(marker));
    });
  }

  ...

}

地図に表示したマーカーをクリックした時の処理は以下のようになっています。

gMarker.addListener('click', () => this.markerClick.next(marker));

markerClickSubjectとなっており、マーカーがクリックされたタイミングでマーカーの情報がストリームに流れるようになっているので、コンポーネント側ではこのServiceをDIすれば、マーカーの情報を受け取り、その情報を使ってビューの更新などができるはずです。

しかし、すでにお気付きの方もいるかと思いますが、これだとコンポーネントの変更検知(Change Detector)が機能しません。コンポーネントに以下を追加したとします。地図のマーカーを何度クリックしても一切反応してくれません。

コンポーネント
ngAfterViewChecked() {
  console.log('ngAfterViewChecked'); // <= 全く反応なし...
}

どのように変更したか(After: NgZoneで解決)

いろいろ調べた結果以下で解決しました。

google-map.service.ts
import { Injectable, NgZone } from '@angular/core';
import { Subject } from 'rxjs/Subject';
...

@Injectable()
export class GoogleMapService {
  map: any;
  markerClick: Subject<Marker> = new Subject<Marker>();

  constructor(
    private ngZone: NgZone,
  ) { }

  /**
   * 地図を初期化
   */
  initMap() {
    this.map = new google.maps.Map(document.getElementById('google-maps'), {
      zoom: 5,
      mapTypeId: google.maps.MapTypeId.ROADMAP,
      center: { ... },
    });
  }

  /**
   * マーカーを地図に表示
   */
  addMarker(markerData: MarkerData) {
    markerData.map(marker => {
      const gMarker = new google.maps.Marker({
        position: { ... },
        icon: { ... },
        map: this.map,
      });

      // マーカーをクリックした時の処理(今回のテーマのメインの部分)
      gMarker.addListener('click', () => {
        this.ngZone.run(() => {
          this.markerClick.next(marker);
        });
      });
    }
  }

  ...

}

何をしたかというと、NgZoneというAngularのモジュールを使用し、マーカーをクリックした時の処理をNgZonerun()メソッドの中で呼び出すようにしました。こうすることでマーカークリック時の処理をAngularの管轄内に置くことができ、Angularの変更検知(Change Detection)の処理を有効にすることができます。

NgZoneとは?

Angularは内部にZoneと呼ばれるものを備えており、ZoneにはaddEventListenersetTimeoutなどの非同期APIがパッチされるようになっています。そしてAngularはこのZoneの中でコンポーネントのコードを実行しています。こうすることで、Angularはいつ非同期処理が発生したかを知ることができ、変更検知(Change Detection)を実行することがでいるようになっています。

逆に、今回の例のように、Zoneの外で非同期処理が発生してしまうと、Angularはその非同期処理が起きたことを知る術がなく、変更検知(Change Detection)も実行されません。

NgZoneは、このZoneをAngularで扱いやすくするためのモジュールと言えばわかりやすいでしょうか。

上記の通り、NgZonerun()メソッドを使用することで、明示的にZoneの内部でコードを実行することができるようになり、Angularに変更検知(Change Detection)を実行させることが可能となります。

gMarker.addListener('click', () => {
  this.ngZone.run(() => {
    this.markerClick.next(marker);
  });
});

ZoneNgZoneについては、以下の記事が参考になります。

detectChanges()ではダメなの?

Anuglarには、同様に明示的にAngularに変更検知(Change Detection)を行わせるためのChangeDetectorRefdetectChanges()メソッドというものがあります。

コンポーネント
markerClick(event: any) {
  this.position = event.position;
  this.cd.detectChanges();
}

コンポーネントのプロパティを更新後に、detectChanges()を呼び出すようにすれば、これでも問題ないですが、以下のように、例えばAPIの呼び出しなどの処理を挟む場合、非同期処理が完了する前にdetectChanges()が実行されてしまい、思ったように変更検知(Change Detection)が行われないので注意が必要です。

コンポーネント
markerClick(event: any) {
  this.store.dispatch(new CallApi(event)); // <= 非同期処理を伴う
  this.cd.detectChanges();
}

逆にAngularに変更検知をさせたくない場合

run()メソッドを使用して、Zoneの内部で実行するようにしていたコードをもう一度Zoneの外部で実行したくなったりや、そもそも最初からZoneの内部では実行したくなかったりするケースがあるかと思います。私の場合は、ブラウザの「戻る」や「進む」で遷移した時に、AngularがGoogle Mapのmousemoveやマーカーのアニメーションに反応し出して、無限ループのようにngAfterViewCheckedが呼び出される謎の現象が起こりました。

原因は特定できていませんが、NgZonerunOutsideAngular()メソッドの中でGoogle Mapを初期化するメソッドを呼び出すことで回避しました。

コンポーネント
this.ngZone.runOutsideAngular(() => this.googleMapService.initMap());

runOutsideAngular()メソッドはrun()メソッドの逆バージョンだと考えてもらえれば良いかと思います。Zoneの外側でコードを実行することができ、Angularの変更検知(Change Detection)を引き起こさないようにすることができます。

まとめ

Angularを使い始めてまだ3ヶ月ほどですが、やはり変更検知周りが難しいなという印象です。まだまだ試行錯誤しながら実装している段階での内容となります。Google Mapsはもちろんですが、認証系だったり、グラフだったり、外部のライブラリを使うことがあるかと思います。そうした外部のライブラリを使う際にぜひ参考にしてもらえればと思います。

参考