Edited at

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

More than 1 year has passed since last update.


概要

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