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

2017年9月からGoogleのプレミアムパートナーでもある[株式会社ゴーガ](https://www.goga.co.jp/)でエンジニアをしており、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`を呼び出せば、引数に渡したマーカーの情報を元に地図上にマーカーを表示します。

```ts: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));
    });
  }

  ...

}
```

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

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

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

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

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

## どのように変更したか（After: NgZoneで解決）

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

```ts: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のモジュールを使用し、マーカーをクリックした時の処理を`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で扱いやすくするためのモジュールと言えばわかりやすいでしょうか。

- https://angular.io/api/core/NgZone

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

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

`Zone`や`NgZone`については、以下の記事が参考になります。

- [AngularとZone.jsとテストの話 - Qiita](https://qiita.com/Quramy/items/83f4fbc6755309f78ad2)
- [日本語訳：Angular 2 Change Detection Explained - Qiita](https://qiita.com/lacolaco/items/523d96ddbfe55c4e6949)
- [Angularでイベントから無駄にChange Detectionを走らせないためにすべきこと](https://qiita.com/ukyo/items/1775f5f09bbd9b5c2978)
- [Zones in Angular by thoughtram](https://blog.thoughtram.io/angular/2016/02/01/zones-in-angular-2.html)


## detectChanges()ではダメなの？

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

- https://angular.io/api/core/ChangeDetectorRef

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

```ts:コンポーネント
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を初期化するメソッドを呼び出すことで回避しました。

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

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

## まとめ

Angularを使い始めてまだ３ヶ月ほどですが、やはり変更検知周りが難しいなという印象です。まだまだ試行錯誤しながら実装している段階での内容となります。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