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
を呼び出せば、引数に渡したマーカーの情報を元に地図上にマーカーを表示します。
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));
markerClick
はSubject
となっており、マーカーがクリックされたタイミングでマーカーの情報がストリームに流れるようになっているので、コンポーネント側ではこのService
をDIすれば、マーカーの情報を受け取り、その情報を使ってビューの更新などができるはずです。
しかし、すでにお気付きの方もいるかと思いますが、これだとコンポーネントの変更検知(Change Detector)が機能しません。コンポーネントに以下を追加したとします。地図のマーカーを何度クリックしても一切反応してくれません。
ngAfterViewChecked() {
console.log('ngAfterViewChecked'); // <= 全く反応なし...
}
どのように変更したか(After: NgZoneで解決)
いろいろ調べた結果以下で解決しました。
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のモジュールを使用し、マーカーをクリックした時の処理をNgZone
のrun()
メソッドの中で呼び出すようにしました。こうすることでマーカークリック時の処理をAngularの管轄内に置くことができ、Angularの変更検知(Change Detection)の処理を有効にすることができます。
NgZoneとは?
Angularは内部にZone
と呼ばれるものを備えており、Zone
にはaddEventListener
やsetTimeout
などの非同期APIがパッチされるようになっています。そしてAngularはこのZone
の中でコンポーネントのコードを実行しています。こうすることで、Angularはいつ非同期処理が発生したかを知ることができ、変更検知(Change Detection)を実行することがでいるようになっています。
逆に、今回の例のように、Zone
の外で非同期処理が発生してしまうと、Angularはその非同期処理が起きたことを知る術がなく、変更検知(Change Detection)も実行されません。
NgZone
は、このZone
をAngularで扱いやすくするためのモジュールと言えばわかりやすいでしょうか。
上記の通り、NgZone
のrun()
メソッドを使用することで、明示的にZone
の内部でコードを実行することができるようになり、Angularに変更検知(Change Detection)を実行させることが可能となります。
gMarker.addListener('click', () => {
this.ngZone.run(() => {
this.markerClick.next(marker);
});
});
Zone
やNgZone
については、以下の記事が参考になります。
- AngularとZone.jsとテストの話 - Qiita
- 日本語訳:Angular 2 Change Detection Explained - Qiita
- Angularでイベントから無駄にChange Detectionを走らせないためにすべきこと
- Zones in Angular by thoughtram
detectChanges()ではダメなの?
Anuglarには、同様に明示的にAngularに変更検知(Change Detection)を行わせるためのChangeDetectorRef
のdetectChanges()
メソッドというものがあります。
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
が呼び出される謎の現象が起こりました。
原因は特定できていませんが、NgZone
のrunOutsideAngular()
メソッドの中でGoogle Mapを初期化するメソッドを呼び出すことで回避しました。
this.ngZone.runOutsideAngular(() => this.googleMapService.initMap());
runOutsideAngular()
メソッドはrun()
メソッドの逆バージョンだと考えてもらえれば良いかと思います。Zone
の外側でコードを実行することができ、Angularの変更検知(Change Detection)を引き起こさないようにすることができます。
まとめ
Angularを使い始めてまだ3ヶ月ほどですが、やはり変更検知周りが難しいなという印象です。まだまだ試行錯誤しながら実装している段階での内容となります。Google Mapsはもちろんですが、認証系だったり、グラフだったり、外部のライブラリを使うことがあるかと思います。そうした外部のライブラリを使う際にぜひ参考にしてもらえればと思います。
参考
- https://blog.angularindepth.com/do-you-still-think-that-ngzone-zone-js-is-required-for-change-detection-in-angular-16f7a575afef
- https://stackoverflow.com/questions/44079424/ionic-2-google-maps-marker-click
- https://stackoverflow.com/questions/31352397/how-to-update-view-after-change-in-angular2-after-google-event-listener-fired
- https://stackoverflow.com/questions/37148813/angular-2-why-do-i-need-zone-run
- https://stackoverflow.com/questions/41364386/whats-the-difference-between-markforcheck-and-detectchanges
- https://stackoverflow.com/questions/37643607/in-angular2-advantage-of-using-zone-run-vs-changedetecotor-markforcheck/37643737#37643737