Angular の変更検出を最適化する

変更検出の高速化を実装して、ユーザー エクスペリエンスを向上させます。

Angular では、変更検出メカニズムを定期的に実行して、データモデルへの変更がアプリのビューに反映されます。変更の検出は、手動で、または非同期イベント(ユーザー操作や XHR の完了など)によってトリガーできます。

変更の検出は強力なツールですが、頻繁に実行されると、大量の計算がトリガーされ、メインのブラウザ スレッドがブロックされる可能性があります。

この投稿では、アプリケーションの一部をスキップし、必要な場合にのみ変更検出を実行することで、変更検出メカニズムを制御および最適化する方法について説明します。

Angular の変更検出機能

Angular の変更検出の仕組みを理解するために、サンプルアプリを見てみましょう。

アプリのコードは、こちらの GitHub リポジトリで確認できます。

このアプリは、ある会社の 2 つの部門(営業と研究開発)の従業員をリストアップし、次の 2 つのコンポーネントで構成されています。

  • AppComponent(アプリのルート コンポーネント)
  • EmployeeListComponent の 2 つのインスタンス(1 つは販売用、もう 1 つは研究開発用)。

サンプル アプリケーション

AppComponent のテンプレートには、EmployeeListComponent の 2 つのインスタンスがあります。

<app-employee-list
  [data]="salesList"
  department="Sales"
  (add)="add(salesList, $event)"
  (remove)="remove(salesList, $event)"
></app-employee-list>

<app-employee-list
  [data]="rndList"
  department="R&D"
  (add)="add(rndList, $event)"
  (remove)="remove(rndList, $event)"
></app-employee-list>

各従業員には名前と数値があります。このアプリは、従業員の数値をビジネス計算に渡し、結果を画面に表示します。

EmployeeListComponent を見てみましょう。

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Component(...)
export class EmployeeListComponent {
  @Input() data: EmployeeData[];
  @Input() department: string;
  @Output() remove = new EventEmitter<EmployeeData>();
  @Output() add = new EventEmitter<string>();

  label: string;

  handleKey(event: any) {
    if (event.keyCode === 13) {
      this.add.emit(this.label);
      this.label = '';
    }
  }

  calculate(num: number) {
    return fibonacci(num);
  }
}

EmployeeListComponent は、従業員のリストと部門名を入力として受け取ります。ユーザーが従業員を削除または追加しようとすると、コンポーネントは対応する出力をトリガーします。このコンポーネントは、ビジネス計算を実装する calculate メソッドも定義します。

以下は、EmployeeListComponent のテンプレートです。

<h1 title="Department">{{ department }}</h1>
<mat-form-field>
  <input placeholder="Enter name here" matInput type="text" [(ngModel)]="label" (keydown)="handleKey($event)">
</mat-form-field>
<mat-list>
  <mat-list-item *ngFor="let item of data">
    <h3 matLine title="Name">
      {{ item.label }}
    </h3>
    <md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
      {{ calculate(item.num) }}
    </md-chip>
  </mat-list-item>
</mat-list>

このコードは、リスト内のすべての従業員に対して反復処理を行い、従業員ごとにリストアイテムをレンダリングします。また、入力と、EmployeeListComponent で宣言された label プロパティとの間の双方向データ バインディング用の ngModel ディレクティブが含まれています。

EmployeeListComponent の 2 つのインスタンスを使用して、アプリは次のコンポーネント ツリーを形成します。

コンポーネント ツリー

AppComponent は、アプリケーションのルート コンポーネントです。その子コンポーネントは、EmployeeListComponent の 2 つのインスタンスです。各インスタンスには、部門の個々の従業員を表す項目(E1、E2 など)のリストがあります。

ユーザーが EmployeeListComponent の入力ボックスに新しい従業員の名前の入力を開始すると、Angular は AppComponent からコンポーネント ツリー全体の変更検出をトリガーします。つまり、ユーザーがテキストを入力している間、Angular は各従業員に関連付けられた数値を繰り返し再計算して、前回のチェック以降に変更されていないことを確認します。

どれくらい遅くなるかを確認するには、StackBlitz で最適化されていないバージョンのプロジェクトを開き、従業員の名前を入力してください。

サンプル プロジェクトを設定し、Chrome DevTools の [Performance] タブを開くと、速度低下の原因が fibonacci 関数にあることを確認できます。

  1. Ctrl+Shift+J キー(Mac の場合は Command+Option+J キー)を押して DevTools を開きます。
  2. [パフォーマンス] タブをクリックします。

次に、[Performance] パネルの左上隅にある [Record] をクリックし、アプリのテキスト ボックスのいずれかで入力を開始します。数秒後に、もう一度録画アイコン をクリックして録画を停止します。Chrome DevTools で収集されたすべてのプロファイリング データが処理されると、次のように表示されます。

パフォーマンス プロファイリング

リスト内に多数の従業員がいる場合、このプロセスによってブラウザの UI スレッドがブロックされてフレーム落ちが発生し、ユーザー エクスペリエンスが低下する可能性があります。

コンポーネント サブツリーのスキップ

ユーザーが営業 EmployeeListComponent のテキスト入力では、R&D 部門のデータは変更されていないことがわかっているため、そのコンポーネントで変更検出を実行する必要はありません。R&D インスタンスによって変更の検出がトリガーされないようにするには、EmployeeListComponentchangeDetectionStrategyOnPush に設定します。

import { ChangeDetectionStrategy, ... } from '@angular/core';

@Component({
  selector: 'app-employee-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['employee-list.component.css']
})
export class EmployeeListComponent {...}

これで、ユーザーがテキストを入力すると、対応する部門に対してのみ変更検出がトリガーされます。

コンポーネント サブツリーでの変更検出

この最適化が元のアプリケーションに適用されたかどうかは、こちらで確認できます。

OnPush の変更検出戦略について詳しくは、Angular の公式ドキュメントをご覧ください。

この最適化の効果を確認するには、StackBlitz 上のアプリケーションに新しい従業員を入力します。

純粋なパイプの使用

EmployeeListComponent の変更検出戦略は OnPush に設定されていますが、ユーザーが対応するテキストを入力すると、Angular は部門のすべての従業員の数値を再計算します。

この動作を改善するには、純粋なパイプを利用できます。純パイプと不純パイプはどちらも入力を受け取り、テンプレートで使用できる結果を返します。この 2 つの違いは、純粋なパイプが前回の呼び出しとは異なる入力を受け取った場合にのみ結果を再計算する点です。

アプリは、EmployeeListComponent で定義された calculate メソッドを呼び出して、表示する値を従業員の数値に基づいて計算します。計算を純粋なパイプに移動した場合、Angular は引数が変更された場合にのみパイプ式を再計算します。フレームワークは参照チェックを実行して、パイプの引数が変更されたかどうかを判断します。つまり、従業員の数値が更新されない限り、Angular は再計算を行いません。

ビジネス計算を CalculatePipe というパイプに移動する方法は次のとおりです。

import { Pipe, PipeTransform } from '@angular/core';

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Pipe({
  name: 'calculate'
})
export class CalculatePipe implements PipeTransform {
  transform(val: number) {
    return fibonacci(val);
  }
}

パイプの transform メソッドは、fibonacci 関数を呼び出します。パイプが純粋であることに注目してください。特に指定しない限り、Angular ではすべてのパイプが純粋とみなされます。

最後に、EmployeeListComponent のテンプレート内の式を更新します。

<mat-chip-list>
  <md-chip>
    {{ item.num | calculate }}
  </md-chip>
</mat-chip-list>

これで、これで、ユーザーがいずれかの部門に関連付けられたテキストを入力しても、個々の従業員に関する数値が再計算されなくなります。

下のアプリでは、入力がどのくらいスムーズになったかを確認できます。

最後に行った最適化の効果を確認するには、StackBlitz でこの例を試してみてください

元のアプリケーションを純粋にパイプで最適化したコードは、こちらから入手できます。

まとめ

Angular アプリで実行時の遅延が発生した場合:

  1. Chrome DevTools でアプリケーションをプロファイリングし、速度が低下している原因を確認します。
  2. OnPush 変更検出戦略を導入し、コンポーネントのサブツリーをプルーニングしました。
  3. 負荷の高い計算を純粋なパイプ処理に移して、フレームワークが計算値のキャッシュを実行できるようにします。