概要
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#runOutsideAngular
とNgZone#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を参照してください。