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 の [パフォーマンス] タブを開きます。

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