Angularでイベントから無駄にChange Detectionを走らせないためにすべきこと

## 概要

Angularの文脈でアプリを高速化するとは、ほぼChange Detectionまわりの最適化を行うことだと思われます。適切に`ChangeDetectionStrategy.OnPush`を設定するのは当たり前ですね。他に、個人的にやってみて特に体感的に速くなったなーってのが、イベントなどから無駄なChange Detectionを走らせないようにすることです。

Change Detectionが走っているかどうかは`NgZone#onMicrotaskEmpty `から確認できます。

```ts
export class App {
  constructor(zone: NgZone) {
    zone.onMicrotaskEmpty.subscribe(() => { console.log('detect change'); });
  }
}
```

明らかにおかしな回数呼ばれている場合は、どこかで無駄打ちしてパフォーマンスが落ちているということです。どんなときに無駄打ちしているのか、よくあるパターンについて見てみましょう。

※ Angular 4系を対象としています。

## `window`, `document`, `body`からのイベントをlistenしている

例えばhelpみたいな、クリックしたら表示、範囲外をクリックしたら非表示みたいな動作をしようとしたら`document`をlistenしないといけないですよね。愚直に実装すると以下のようになると思います([DEMO](http://embed.plnkr.co/55UNk7/))。

```ts
@Component({
  selector: 'help',
  template: `?<div class="popup" *ngIf="active"><ng-content></ng-content></div>`,
  styles: [/* 略 */],
})
export class Help {
  active = false;
  private _removeListener = () => {};
  
  constructor(private _elementRef: ElementRef, private _renderer: Renderer2) { }
  
  ngOnInit() {
    this._removeListener = this._renderer.listen('document', 'click', (event: MouseEvent) => {
      const nextActive = this._elementRef.nativeElement.contains(event.target);
      this.active = nextActive;
    });
  }
  
  ngOnDestroy() {
    this._removeListener();
  }
}
```

helpが1個だけなら問題ないんですが、1画面にn個のhelpが存在すると1クリックあたりn回Change Detectionが走ることになります。クリックならまだましですが、キーボードイベントとかだと、どえらい重さになります。helpの例の場合は`active`が変化したときだけChange Detectionが走るのが理想ですよね。上のコードを書き換えてみましょう。

```ts
export class Help {
  active = false;
  private _removeListener = () => {};
  
  constructor(
    private _elementRef: ElementRef,
    private _renderer: Renderer2,
    private _zone: NgZone) { }
  
  ngOnInit() {
    this._zone.runOutsideAngular(() => {
      this._removeListener = this._renderer.listen('document', 'click', (event: MouseEvent) => {
        const nextActive = this._elementRef.nativeElement.contains(event.target);
        if (this.active !== nextActive) this._zone.run(() => this.active = nextActive);
      });
    });
  }
  
  ngOnDestroy() {
    this._removeListener();
  }
}
```

まず、`NgZone#runOutsideAngular`のコールバック内でイベントをlistenすることでChange Detectionが走らないようにします。つぎに、`active`が変化したときだけ`NgZone#run`のコールバック内で値を変更することでChange Detectionを走らせます。これで画面をクリックしただけで、何もないのにChange Detectionが走るということがなくなりました。


### `window`, `document`, `body`に対する`@HostListener`は常にChange Detectionが走る

`@HostListener`を使って一番上のコードを書き直すと簡単に書くことができますが、常にChange Detectionが走ります。1画面に1コンポーネントしか置かない前提でもない限り、`window`, `document`, `body`に対する`@HostListener`はパフォーマンス的には愚策ですね。

```ts
export class Help {
  active = false;
  
  constructor(private _elementRef: ElementRef) { }
  
  @HostListener('document:click', ['$event'])
  onClick(event: MouseEvent) {
    const nextActive = this._elementRef.nativeElement.contains(event.target);
    this.active = nextActive;
  }
}
```

## inputをdebounceしてもChange Detectionは走る

`<input (input)="hoge($event)">`に対してdebounceすることってありますよね?当たり前ですが、外側でdebounceしても1文字1Change Detectionです([DEMO](http://embed.plnkr.co/Ebmp08/))。

```ts
@Component({
  selector: 'my-app',
  template: `
    <input (input)="subject.next($event.target.value)">
  `,
})
export class App {
  subject = new Subject<string>();
  constructor() {
    this.subject.debounceTime(1000).subscribe((v) => {
      console.log(v);
    })
  }
}
```

Change Detectionもdebounceしたいのなら、例のごとく`NgZone#runOutsideAngular`と`NgZone#run`を組み合わせます。


```ts
@Directive({
  selector: 'input[debounceInput]'
})
export class DebounceInput {
  @Output() onInput = new EventEmitter<string>();
  
  constructor(
    private _elementRef: ElementRef,
    private _zone: NgZone,
  ) {
    this._zone.runOutsideAngular(() => {
      Observable
        .fromEvent(this._elementRef.nativeElement, 'input')
        .debounceTime(1000)
        .subscribe((ev: KeyboardEvent) => {
          this._zone.run(() => this.onInput.emit(ev));
        });
    });
  }
}
```

```ts
@Component({
  selector: 'my-app',
  template: `
    <input debounceInput (onInput)="log($event.target.value)">
  `,
})
export class App {
  constructor() { }
  log(v) { console.log(v); }
}
```

ただし、リアルタイムに表示を反映したいという要件ならば1文字1Change Detectionは許容するしかないです。

## 外部ライブラリで発生するイベントでChange Detectionを走らせない

D3など、タイマー系やDOMイベントが大量に発生しそうな外部ライブラリを使用する場合、完全にAngularとは無関係な部分は忘れずに`NgZone#runOutsideAngular`を使いましょう([DEMO](http://embed.plnkr.co/dm97UH/))。

```ts
@Component({
  selector: 'svg[outsideAngular]',
  template: '',
})
export class OutsideAngular {
  constructor(elementRef: ElementRef, zone: NgZone) {
    zone.runOutsideAngular(() => setupD3(elementRef.nativeElement));
  }
}
```

## まとめ

イベントなどから無駄にChange Detectionを走らせないためにすべきことを何個か紹介しました。突き詰めればもっと減らすことができますが、なんでAngular使ってんだろうってなるのでほどほどにしておきましょう。

`ChangeDetectionStrategy.OnPush`を適切に配置する方法については@laco0426さんの[日本語訳：Angular 2 Change Detection Explained \- Qiita](http://qiita.com/laco0416/items/523d96ddbfe55c4e6949)で詳しく説明されています。

zoneの詳細については@Quramyさんの[Angular 2とZone\.jsとテストの話 \- Qiita](http://qiita.com/Quramy/items/83f4fbc6755309f78ad2)を参照してください。