Angular18からはZone外であっても明示的にコンポーネントをdirtyにすれば変更検知がトリガーされるという話

2024/12/24に公開

この記事はjig.jp Advent Calender 2024、 24日目の記事です。

こんにちは。jig.jp でウェブ開発を担当しているフジイです。

普段の開発では Angular を使用しています。

今回は、Angular v18で変更されたzone外でコンポーネントをdirtyにした時の変更検知の挙動について書きます。

サンプルコード

今回は以下のサンプルコードを用いて、表示された「Hello from」の文言の後に「Angular」の文字が表示されるかされないかで変更検知が走ったかどうかを見ていきます。

サンプルコードのコンポーネントは以下のようになっています。

  • OnPush型の変更検知戦略を採用
  • zone外で、zone内であれば変更検知をトリガーさせるAPI (zone.js - STANDARD apiを参照) の1つであるwindow.setTimeoutを実行する
  • 上記のAPIのコールバック関数内でテンプレートにバインドされている変数をコンポーネントを直接dirtyにしない方法で上書きする
  • ChangeDetectorRef.markForCheck()関数を実行して、コンポーネントをdirtyにする
@Component({
  selector: 'app-root',
  standalone: true,
  template: `<h1>Hello from {{name}}</h1>`,
  // OnPush型
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App implements OnInit {
  private zone = inject(NgZone)
  private cdr = inject(ChangeDetectorRef)
  protected name = '';
  ngOnInit (): void {
    // zone外で実行
    this.zone.runOutsideAngular(() => {
      // 変更検知をトリガーさせるAPI
      window.setTimeout(() => {
        // コンポーネントをdirtyにせずに変数の上書きをする方法を採用
        this.name = 'Angular'
        // コンポーネントをdirtyにする関数
        this.cdr.markForCheck()
      }, 1000)
    })
  }
}

Angular v17までの挙動

Angular v17までは上記のサンプルコードでは、「Hello from Angular」には更新されませんでした。

理由はChangeDetectorRef.markForChack()関数でコンポーネントをdirtyの状態にしても、 zone外でwindow.setTimeoutを実行したことで変更検知がトリガーされず、またwindow.setTimeoutによってngOnInit後の描画の更新にも引っかからなかったためです。

変数を上書きした後に、ChangeDetectorRef.markForChack()関数ではなくChangeDetectorRef.detectChanges()関数を実行したり、name変数をAngular Signalsにしたり、別の箇所で変更検知がトリガーされれば問題なく表示されている文言は変更されます。

ChangeDetectorRef.markForChack()関数でコンポーネントをdirtyの状態にしても、 zone外でwindow.setTimeoutを実行した時の変更検知がトリガーされず表示されている文言が更新されない」という挙動が Angular v18 では変わりました。

Angular v18 からの挙動

結論から言うと、上記のサンプルコードの場合でも表示されている文言が更新されるようになります。

表題の通り、zone外であっても明示的にコンポーネントをdirtyにすれば画面更新されるようになります。(markForCheckは変更検知をトリガーする側ではなかったので少し違和感がありますが...)

この挙動の変化は以下のリンクサイトで説明されています。

This change updates the default change detection scheduling
approach of Zone-based applications to ensure a change detection will
run when these events happen outside the Angular zone
https://github.com/angular/angular/pull/55102

Angular will ensure change detection runs, even when the state update originates from outside the zone, tests may observe additional rounds of change detection compared to the previous behavior.
https://github.com/angular/angular/blob/main/CHANGELOG.md#core-28

ここで注意したいのは、zone外でwindow.setTimeoutwindow.setIntervalを実行しただけでは変更検知がトリガーされず、ChangeDetectorRef.markForChack()関数を用いて明示的にコンポーネントをdirtyにした時に変更検知がトリガーされ、描画が更新されると言うことです。

zone.runOutsideAngularを使用するケースとして、window.setIntervalrequestAnimationFrameを使用した処理で不要な変更検知を抑えたいことが考えられますが、その場合であってもwindow.setIntervalrequestAnimationFrameによって変更検知が大量にトリガーされるようになるということはありません。

あくまでそのコールバック関数内でChangeDetectorRef.markForChack()関数を実行している場合にだけパフォーマンスなどに影響すると言うことです。

また、ChangeDetectorRef.markForChack()関数ではなくChangeDetectorRef.detectChanges()関数を実行したり、name変数をAngular Signalにして更新する時は、変更検知がトリガーされるのでAngular v17 同様に描画が更新されます。

コンポーネントをdirtyにする他のパターン

コンポーネントをdirtyにする別の方法である async パイプを使用した場合でも、Angular v18では表示が更新されるようになります。

@Component({
  selector: 'app-root',
  standalone: true,
  template: `<h1>Hello from {{name$ | async}}</h1>`,
  imports: [AsyncPipe],
  // OnPush型
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App implements OnInit {
  private zone = inject(NgZone)
  private cdr = inject(ChangeDetectorRef)
  protected name$ = new BehaviorSubject<string>('');
  ngOnInit (): void {
    // zone外で実行
    this.zone.runOutsideAngular(() => {
      // 変更検知をトリガーさせるAPI
      window.setTimeout(() => {
        // コンポーネントをdirtyにする上書きをする方法を採用
        this.name$.next('Angular')
      }, 5000)
    })
  }
}

この挙動をオフにしたいとき

この挙動はAngularの変更検知のデフォルトの挙動になってしまっているので、以前の挙動に戻すことは基本的にはできませんが、アプリケーションをprovideZoneChangeDetectionを用いてZonelessで動かす時に、オプションのignoreChangesOutsideZoneをtrueにすることで表題の挙動をなくすことができます。

bootstrapApplication(App, {
  providers: [
    provideZoneChangeDetection({ ignoreChangesOutsideZone: true })
  ],
});
jig.jp Engineers' Blog

Discussion