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 で最適化されていないバージョンのプロジェクトを開き、従業員名を入力してみてください。

fibonacci 関数が原因で速度低下の原因となっているかどうかを確認するには、サンプル プロジェクトをセットアップして Chrome DevTools の [Performance] タブを開きます。

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

をクリックして記録を停止します。収集したすべてのプロファイリング データが Chrome DevTools で処理されると、次のように表示されます。

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

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

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

ユーザーが sales 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. 負荷の大きい計算を純粋なパイプに移行して、フレームワークが計算値のキャッシュを実行できるようにします。