【Angular】`ChangeDetectionStrategy.OnPush`の効果を確認してみる

## はじめに
Angularのパフォーマンス改善で「`ChangeDetectionStrategy.OnPush`を指定する」というのをよく見るが、これを設定するとどんな良いことがあるのか、を実際に見える形で確認したかったので試した。
今回はその結果を共有していく。

## ChangeDetectionとは
これに関しては@lacolacoさんの記事がとても参考になる（なった）ので、そちらを確認いただきたい。
[Angular2のChange Detectionについて](https://qiita.com/lacolaco/items/78edd53f5da8ead02e75)
[Angular2はいかにしてオブジェクトの変更を監視しているのか](https://qiita.com/lacolaco/items/8901219737f606d68d54)
[日本語訳：Angular 2 Change Detection Explained](https://qiita.com/lacolaco/items/523d96ddbfe55c4e6949)

> ・Change Detectionとはモデルの変更を検知し、UIに反映することである
・Angularはあらゆる非同期処理の後にChange Detectionを行う(Zoneを使って)
・変更を検知する際にはオブジェクトの参照が変わったかどうかが重要である
・Immutableを使えばAngularが変更を検知しやすくなる
・OnPushでChange Detectionをコントロールできる

## `ChangeDetectionStrategy.OnPush`とはChangeDetectionを走らせる下層のコンポーネントを絞る機能
AngularはデフォルトではどのコンポーネントもChangeDetectionを走らせる設定になっている。
これでは変更する必要のないコンポーネントまでもチェックする必要があり、その数が多いとパフォーマンス低下に繋がる。そこでチェックする必要のないコンポーネントを減らせればその分パフォーマンスの向上に繋がる。その**チェックするかどうかを決められる設定が`OnPush`**というわけだ。（正確にはそのコンポーネントからではなく、そこから下のコンポーネントへのチェックを無くせる。）

## ChangeDetectionの確認は`ngDoCheck()`で出来る
[Angular - DoCheck](https://angular.jp/api/core/DoCheck#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層目では配列なら３層目のコンポーネントに１つ１つ要素を渡し、UserComponentであれば`AppComponent`から受け取った値に文字列を連結させて３層目のコンポーネントに渡す。
`AppComponent`, `StoreListComponent`でのみ値を変更するイベントを用意し、それぞれどの層のどのコンポーネントにChangeDetectionが走るのかを`ngDoCheck()`で確認する狙い。
![detect-change (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/dbe92338-23ea-a326-132e-c1efb5dbc271.png)

## ソースコード
※StoreListとFruitsListおよびそれらのItemコンポーネントは実装がほぼ同じなのでStoreListのみ。
全体のソースコードは[こちら](https://github.com/m-Suda/angular-change-detection-sample)から。

<details>
<summary>app.component</summary>
<div>
このコンポーネントで各層のコンポーネントの値を書き換える。
consoleから確認できるよう各Clickイベントでログを表示するようにした。

```typescript:app.component.ts
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 }];
    }
}
```

```html:app.component.html
<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>
```
</div></details>

<details>
<summary>store-list.component</summary>
<div>
子コンポーネントでEventが発生してもルートコンポーネントからChangeDetectionが走るのかどうかを確認するため、ここだけClickイベントを実装している。

```typescript:store-list.component.ts
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');
    }
}
```

```html:store-list.component.html
<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>
```
</div></details>

<details>
<summary>user.component</summary>
<div>
UserComponentでは`@Input()`の値が変更されたとき挨拶の文言を結合して子コンポーネントに送るようにしている。

```typescript:user.component.ts
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');
    }
}
```

```html:user.component.html
<p>Hello, {{userName}}</p>
<app-user-child [hello]="hello"></app-user-child>
```
</div></details>

<details>
<summary>user-child.component</summary>
<div>

```typescript:user-child.component.ts
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');
    }
}
```

```html:user-child.component.html
<p>user-child: {{hello}}</p>
```
</div></details>

## 全コンポーネントがChangeDetectionStrategy.Defaultの場合
赤枠が`AppComponent`のClickイベント、青枠が`StoreListComponent`のClickイベント。
どちらも全てのコンポーネントのChangeDetectionが走ってしまっていることがわかる。
<img width="847" alt="スクリーンショット 2020-02-06 1.39.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/0c3f67f7-f087-daff-322b-f379426274ba.png">

さっきの図で表すとこんな感じ。
全ての階層のコンポーネントでChangeDetectionが走っている。
ちなみにAppComponentのどのClickイベントでもこうなる。
![detect-change (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/a03a9015-ad67-3fcd-eadf-2d2d0b5c5bf2.png)

## ２層目のコンポーネントで`OnPush`を設定した場合
赤枠が`AppComponent`の`title`を変更、青枠が`StoreList`に要素を追加、緑枠が`StoreList`を新しい配列(新しい参照)にしたとき。
`AppComponent`の`title`変更は`StoreList`に影響はないので、`StoreListItem`のChangeDetectionが走っていないことが確認できる。
また`StoreList`への要素追加では`StoreListItem`のChangeDetectionが走らず、`StoreList`の参照を変更した場合は`StoreListItem`のChangeDetectionが走っていることが確認できる。
このことから**`OnPush`を設定した場合、オブジェクトの値であれば参照が変更されるとChangeDetectionが走る**ことがわかる。
<img width="727" alt="スクリーンショット 2020-02-06 1.56.58.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/0b880329-8af4-b9ef-8bf3-a5f2429672c4.png">
StoreListのデータに関係のない、または参照が変更されないと`OnPush`を設定したコンポーネントよりも下層のコンポーネントのChangeDetectionが走らない。
![detect-change (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/b1882600-4fd6-0596-fb17-7c70cdf3707e.png)
一方で参照を変更すると、**`OnPush`を設定したコンポーネントの下層のみにChangeDetectionを絞る**ことができる。**`FruitsListItem`にはChangeDetectionが走ってない**。複数のリストを表示する、例えばダッシュボード画面のような構成でかなり効果的なのかも…。
![detect-change (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/341d3656-b9bf-d79b-822f-1789028226b0.png)

## (余談)プリミティブな値を変更した場合
`AppComponent`から`userName`の変更イベントを２度走らせてみた。（１度目が赤枠、２度目が青枠。）
１度目は値が変更されたので、`UserChildComponent`のChangeDetectionが走っている。
２度目は１度目と同じ値で変更したので値自体は変わらない。すると`UserChildComponent`のログが表示されなかったので、`UserChildComponent`のChangeDetectionが走ってないことが確認できる。
このことから**プリミティブな値は変更が無ければChangeDetectionが走らないことがわかる。**
<img width="562" alt="スクリーンショット 2020-02-06 2.14.56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/316318/d4044765-a002-0871-fc64-2888429dbbc3.png">

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

## 参考
- [Angular2のChange Detectionについて](https://qiita.com/lacolaco/items/78edd53f5da8ead02e75)
- [Angular2はいかにしてオブジェクトの変更を監視しているのか](https://qiita.com/lacolaco/items/8901219737f606d68d54)
- [日本語訳：Angular 2 Change Detection Explained](https://qiita.com/lacolaco/items/523d96ddbfe55c4e6949)
- [angular-change-detection-example](https://github.com/loiane/angular-change-detection-example)