52
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2017-04-21

概要

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を参照してください。

52
46
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
46

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?