はじめに
Angularのパフォーマンス改善で「ChangeDetectionStrategy.OnPushを指定する」というのをよく見るが、これを設定するとどんな良いことがあるのか、を実際に見える形で確認したかったので試した。
今回はその結果を共有していく。
ChangeDetectionとは
これに関しては@lacolacoさんの記事がとても参考になる(なった)ので、そちらを確認いただきたい。
Angular2のChange Detectionについて
Angular2はいかにしてオブジェクトの変更を監視しているのか
日本語訳:Angular 2 Change Detection Explained
・Change Detectionとはモデルの変更を検知し、UIに反映することである
・Angularはあらゆる非同期処理の後にChange Detectionを行う(Zoneを使って)
・変更を検知する際にはオブジェクトの参照が変わったかどうかが重要である
・Immutableを使えばAngularが変更を検知しやすくなる
・OnPushでChange Detectionをコントロールできる
ChangeDetectionStrategy.OnPushとはChangeDetectionを走らせる下層のコンポーネントを絞る機能
AngularはデフォルトではどのコンポーネントもChangeDetectionを走らせる設定になっている。
これでは変更する必要のないコンポーネントまでもチェックする必要があり、その数が多いとパフォーマンス低下に繋がる。そこでチェックする必要のないコンポーネントを減らせればその分パフォーマンスの向上に繋がる。その**チェックするかどうかを決められる設定がOnPush**というわけだ。(正確にはそのコンポーネントからではなく、そこから下のコンポーネントへのチェックを無くせる。)
ChangeDetectionの確認はngDoCheck()で出来る
A callback method that performs change-detection, invoked after the default change-detector runs. See KeyValueDiffers and IterableDiffers for implementing custom change checking for collections.
実際に確認するアプリの構成
AppComponentがそれぞれ@Input()経由でStoreListComponent, FruitsListComponentには配列、UserComponentにはStringの値を渡す。
2層目では配列なら3層目のコンポーネントに1つ1つ要素を渡し、UserComponentであればAppComponentから受け取った値に文字列を連結させて3層目のコンポーネントに渡す。
AppComponent, StoreListComponentでのみ値を変更するイベントを用意し、それぞれどの層のどのコンポーネントにChangeDetectionが走るのかをngDoCheck()で確認する狙い。

ソースコード
※StoreListとFruitsListおよびそれらのItemコンポーネントは実装がほぼ同じなのでStoreListのみ。
全体のソースコードはこちらから。
app.component
import { ChangeDetectionStrategy, Component, DoCheck } from '@angular/core';
import { Store } from './modules/store-list/store-list.component';
import { Fruits } from './modules/fruits-list/fruits-list.component';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss'],
// changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.Default,
})
export class AppComponent implements DoCheck {
public storeList: Store[] = [
{ name: '渋谷店', isFavorite: true },
{ name: '新宿店', isFavorite: false },
{ name: '豊洲店', isFavorite: false },
];
public fruitsList: Fruits[] = [
{ name: 'オレンジ' },
{ name: 'ブドウ' },
{ name: 'バナナ' },
{ name: 'メロン' },
];
public userName = 'John';
public title = 'angular-change-detect';
ngDoCheck(): void {
console.log('AppComponent-DoCheck');
}
/**
* EventだけでChangeDetectionが走るのかを確認する
*/
public consoleLog() {
console.log('AppComponentのconsoleLog()');
console.log('event');
}
/**
* AppComponentの値だけを変更してChangeDetectionが走るのかを確認する
*/
public changeTitle() {
console.log('AppComponentのtitleをchange');
this.title = 'Title updated';
}
/**
* UserComponentの値を変更する
*/
public changeUserName() {
console.log('AppComponentからuserNameをchange');
this.userName = 'Lucy';
}
/**
* StoreListに要素を追加する
*/
public addStore() {
console.log('AppComponentからStoreListに追加');
this.storeList.push({ name: '飯田橋店', isFavorite: false });
}
/**
* StoreListのコピーを作成、それに要素を追加して新しい配列を格納する
*/
public addStoreNewArray() {
console.log('AppComponentから新しいStoreList配列を格納');
const currentStoreList = [...this.storeList];
this.storeList = [...currentStoreList, { name: '飯田橋店', isFavorite: false }];
}
}
<div>{{title}}</div>
<div>
<button (click)="consoleLog()">ConsoleLog</button>
</div>
<div>
<button (click)="changeTitle()">Change Title</button>
</div>
<div>
<button (click)="addStore()">Add Store</button>
</div>
<div>
<button (click)="addStoreNewArray()">Add Store New Array</button>
</div>
<div>
<button (click)="changeUserName()">Change User Name</button>
</div>
<div>
<app-store-list [storeList]="storeList"></app-store-list>
</div>
<div>
<app-fruits-list [fruitsList]="fruitsList"></app-fruits-list>
</div>
<div>
<app-user [userName]="userName"></app-user>
</div>
store-list.component
import { ChangeDetectionStrategy, Component, DoCheck, Input } from '@angular/core';
export interface Store {
name: string;
isFavorite: boolean;
}
@Component({
selector: 'app-store-list',
templateUrl: './store-list.component.html',
styleUrls: ['./store-list.component.scss'],
// changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.Default,
})
export class StoreListComponent implements DoCheck {
@Input() storeList: Store[];
constructor() {}
ngDoCheck(): void {
console.log('StoreListComponent-DoCheck');
}
public consoleLog() {
console.log('StoreListからconsoleLog()');
console.log('Event in StoreListComponent');
}
}
<p>Store List</p>
<button (click)="consoleLog()">Add Store List In StoreListComponent</button>
<ul>
<li *ngFor="let store of storeList">
<app-store-list-item [store]="store"></app-store-list-item>
</li>
</ul>
user.component
import { ChangeDetectionStrategy, Component, DoCheck, Input, OnChanges } from '@angular/core';
@Component({
selector: 'app-user',
templateUrl: './user.component.html',
styleUrls: ['./user.component.scss'],
// changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.Default,
})
export class UserComponent implements OnChanges, DoCheck {
@Input() userName: string;
public hello = '';
constructor() {}
ngOnChanges(): void {
this.hello = `Hello ${this.userName}`;
}
ngDoCheck(): void {
console.log('UserComponent-DoCheck');
}
}
<p>Hello, {{userName}}</p>
<app-user-child [hello]="hello"></app-user-child>
user-child.component
import { Component, DoCheck, Input } from '@angular/core';
@Component({
selector: 'app-user-child',
templateUrl: './user-child.component.html',
styleUrls: ['./user-child.component.scss'],
})
export class UserChildComponent implements DoCheck {
@Input() hello: string;
constructor() {}
ngDoCheck(): void {
console.log('UserChildComponent-DoCheck');
}
}
<p>user-child: {{hello}}</p>
全コンポーネントがChangeDetectionStrategy.Defaultの場合
赤枠がAppComponentのClickイベント、青枠がStoreListComponentのClickイベント。
どちらも全てのコンポーネントのChangeDetectionが走ってしまっていることがわかる。

さっきの図で表すとこんな感じ。
全ての階層のコンポーネントでChangeDetectionが走っている。
ちなみにAppComponentのどのClickイベントでもこうなる。

2層目のコンポーネントでOnPushを設定した場合
赤枠がAppComponentのtitleを変更、青枠がStoreListに要素を追加、緑枠がStoreListを新しい配列(新しい参照)にしたとき。
AppComponentのtitle変更はStoreListに影響はないので、StoreListItemのChangeDetectionが走っていないことが確認できる。
またStoreListへの要素追加ではStoreListItemのChangeDetectionが走らず、StoreListの参照を変更した場合はStoreListItemのChangeDetectionが走っていることが確認できる。
このことから**OnPushを設定した場合、オブジェクトの値であれば参照が変更されるとChangeDetectionが走ることがわかる。

StoreListのデータに関係のない、または参照が変更されないとOnPushを設定したコンポーネントよりも下層のコンポーネントのChangeDetectionが走らない。

一方で参照を変更すると、OnPushを設定したコンポーネントの下層のみにChangeDetectionを絞ることができる。FruitsListItemにはChangeDetectionが走ってない**。複数のリストを表示する、例えばダッシュボード画面のような構成でかなり効果的なのかも…。

(余談)プリミティブな値を変更した場合
AppComponentからuserNameの変更イベントを2度走らせてみた。(1度目が赤枠、2度目が青枠。)
1度目は値が変更されたので、UserChildComponentのChangeDetectionが走っている。
2度目は1度目と同じ値で変更したので値自体は変わらない。するとUserChildComponentのログが表示されなかったので、UserChildComponentのChangeDetectionが走ってないことが確認できる。
このことからプリミティブな値は変更が無ければChangeDetectionが走らないことがわかる。

さいごに
今回はChangeDetectionの効果を可視化してみた。
ChangeDetectionStrategy.OnPushを設定することでChangeDetectionの無駄を省くだけでなく、オブジェクトをImmutableを半強制的に扱わせられることもわかった。(半強制的なのはImmutableでなくても自分でChangeDetectionするメソッドを呼べるため。)
ダッシュボード画面のように複数のオブジェクトを表示するようなときにこのChangeDetectionStrategy.OnPushを設定すると幸せになるかもしれない。