Angularで非同期処理などで画面の更新が正しく行われないときの対処方法(Angular2,Angular4, Angular5, Angular6)


AngularでPusherを利用していて、非同期処理で画面がちゃんと更新されない、という事象にぶつかったので、回避方法を記載しておきます。

Pusherというのは、JSやスマホなどでサーバからのPush(リアルタイム処理)を行いたい時に、簡単に使えるサービスです。

今回は、 pusher-js を使いました。

結論から言うと、 NgZone を使うのが簡単だろうと思っています。

この記事の流れ

  1. 非同期処理などで画面の更新がおこなわれない事象の発生方法とそのコード
  2. 発生理由
  3. 解決方法1 (NgZone)
  4. 解決方法2 (DetectChangeRef)
  5. まとめ

1. 非同期処理などで画面の更新がおこなわれない事象の発生方法とそのコード

今回は、外部ライブラリを使っているということもあり、Angularが上手く動かない可能性を懸念していました(以前 Vue で実装したときは問題なく動いたんですよね)。
今回使ったのは Pusher ですが、下記のようなコードです

models/pusher.ts
const Pusher = require('pusher-js');

// TODO: 環境ごとに値が変わるようにする
export default new Pusher('xxxxxxxx', {
  cluster: 'ap1',
  encryped: true,
});
components/RealTimeComponent.ts
import { default as testPusher } from 'models/pusher.ts'

@Component({
  selector: 'app-real-time',
  template: `
    <div *ngIf="error"> {{ error }} </div>
    <div>Pusherテスト</div>
  `
})
export class RealTimeComponent implements OnInit {
  error: string;
  ngOnInit(): void {
    this.error = null;
    const channel = testPusher.subscribe('xxxxxxxx');
    channel.bind('error', (data) => {
      this.error = 'エラーが発生しました';
    })
  }
}

一見問題なさそうに感じますが、 これでは、画面に error の項目が反映されません。 

発生理由

Angular には、 Zone という考え方があります。
参考↓
https://angular.io/guide/glossary#zone
https://www.youtube.com/watch?v=3IqtmUscE_U

Pusherchannel.bind で非同期処理になっていまして、 そこが Zone の外側なので変更をAngularが検知できないのだと思います。

解決方法1 (NgZone)

Zone の外側なら、中にいれちゃえば良いじゃない、という解決方法。
単純だけど、強力。

components/RealTimeComponent.ts
import { default as testPusher } from 'models/pusher.ts'

@Component({
  selector: 'app-real-time',
  template: `
    <div *ngIf="error"> {{ error }} </div>
    <div>Pusherテスト</div>
  `
})
export class RealTimeComponent implements OnInit {
  error: string;
  constructor(private zone: NgZone) {}
  ngOnInit(): void {
    this.error = null;
    const channel = testPusher.subscribe('xxxxxxxx');
    channel.bind('error', (data) => {
      this.zone.run(() => {
        this.error = 'エラーが発生しました';
      });
    })
  }
}

簡単に、これで解決します。

this.zone.run(() => {
  // コード
});

の中に入れることで、 Zone の中に入れた、というものです。

解決方法2 (DetectChangeRef)

こちらは、変更検知しないなら、手動で検知を発生させれば良いじゃない。
という考え方です。

components/RealTimeComponent.ts
import { default as testPusher } from 'models/pusher.ts'

@Component({
  selector: 'app-real-time',
  template: `
    <div *ngIf="error"> {{ error }} </div>
    <div>Pusherテスト</div>
  `
})
export class RealTimeComponent implements OnInit {
  error: string;
  constructor(private detectChange: DetectChangeRef) {}
  ngOnInit(): void {
    this.error = null;
    const channel = testPusher.subscribe('xxxxxxxx');
    channel.bind('error', (data) => {
      this.error = 'エラーが発生しました';
      this.detectChange.detectChanges();
    })
  }
}

コードの実行後に、明示的に変更検知を呼び出しています。

// コード
this.detectChange.detectChanges(); // <- 変更検知の発動

こちらも結構シンプルに解決できます。

まとめ

どちらの解決方法もシンプルに解決できますが、私は前者が良いと思います。
というのも、後者の場合、例えば this.router.navigate() を呼び出したい場合に、うまく動作しません。すでに別の画面に言ってしまっているので当たり前ですね。
挙動としては、別画面には遷移するんだけど、 ngOnInit などが呼ばれない、となります。

NgZone を使った方法なら、こういうケースも回避できます。Zone に入っているからです。

最後に

そもそも、フレームワーク側でこんなこと気にしないで良いようにしてもらえないだろうか…。