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

  • 18
    Like
  • 0
    Comment

概要

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

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

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

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

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

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

例えばhelpみたいな、クリックしたら表示、範囲外をクリックしたら非表示みたいな動作をしようとしたらdocumentをlistenしないといけないですよね。愚直に実装すると以下のようになると思います(DEMO)。

@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が走るのが理想ですよね。上のコードを書き換えてみましょう。

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はパフォーマンス的には愚策ですね。

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)。

@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#runOutsideAngularNgZone#runを組み合わせます。

@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));
        });
    });
  }
}
@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)。

@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で詳しく説明されています。

zoneの詳細については@QuramyさんのAngular 2とZone.jsとテストの話 - Qiitaを参照してください。