OnPush 注意するべきなどころ


皆さん、こんにちは、まだ年に一度のAngularアドベントカレンダーになりました、今回が OnPush について、いろいろ話したいと思います。

私が Jia Li と申します、Angular の Zone.js というライブラリを 4 年間開発して、今が Zone.js の Code Owner と Angular Collaborator として Angular の Contribution をやっています。

OnPush Component

Angular を開発するとき、性能向上するため、Component の ChangeDetectionStrategy を OnPush をすることが一つの有力な対策です。まず OnPush の仕組みを説明します。

例えば、下記のサンプルをみてください。

@Component({
  selector: 'app-onpush',
  template: `<button (click)="click()"></button>{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  @Input() input;
  @Output() output = new EventEmitter();
  click() {}
}

@Component({
  selector: 'app-root',
  template: `<app-onpush [input]="inputData" (output)="fromPush($event)"></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  inputData = 'pushInput';
  fromPush() {}
}

Angular 内部で下記のような View Tree を持っています。

Angular の Change Detection が実行されるとき、RootView から子供の TreeNode に全部 Visit して、変更があるかどうかを探します。

  • ChangeDetection.Strategy が Default のとき、条件なく該当 Component の変更探知を実行します。
  • ChangeDetection.Strategy が OnPush のとき、ある条件に満足しないと変更探知をしません。

OnPushのとき、変更探知をするかどうかの条件が下記になります。

OnPush ComponentがDirtyするとき

そして、いつDirtyになりますかというと:

  • 自動てきにDirtyになるパターン Templateで宣言された要素が動きがあるかどうかということです
  • 手動てきにDirtyになるパターン markDirty()/markForCheck()などを手動でコールする

このArticleが主に自動のパターンを説明したいと思います。

Templateに宣言された要素が:

  • Input
  • Output
  • EventListener
  • AsyncPipe

ここで一番重要なポイントがこれらの要素がTemplateに書かないと自動的にOnPush ComponentをDirtyになれません。

  • Input
@Component({
  selector: 'app-onpush',
  template: `{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  @Input() input;
}

@Component({
  selector: 'app-root',
  template: `<app-onpush [input]="inputData"></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  inputData = 'pushInput';
}

Inputの場合、Templateに宣言する意味がOnPush ComponentのElementがいるTemplate(<app-onpush [input]="inputData"></app-onpush>)でproperty binding/interpolation を宣言する意味です。このように宣言したら、AppComponentでinputDataを変更したら、OnPush Componentが自動的にDirtyになって、Change Detectionの変更探知の対象になります。

もし宣言しない場合、

@Component({
  selector: 'app-onpush',
  template: `{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  @Input() input;

  ngOnInit() {
    // use setTimeout since ngOnInit is in the change detection because
    // this is a part of the component create phase
    setTimeout(() => {
      this.input = 'updated value';
    });
  }
}

@Component({
  selector: 'app-root',
  template: `<app-onpush></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
}

このとき、inputがOnPush Componentで@Inputとして定義しても、Templateで宣言していません。そしたら、たとえば、setTimeoutなどの処理でこのinputの値を更新しても、画面が更新されません、つまりOnPush ComponentがDirtyになっていません。

その原因が宣言されたとき、AngularがTemplateをCompileするとき、<app-onpush [input]="inputData" をみて、このinput propertyがOnPush Componentの@Inputということが分かって、そしたら、inputを変更するInstructionコードでOnPush ComponentをDirty化にする処理も入れました。なので、宣言しないと、AngularがTemplateをCompileするときこの関係がわからないので、自動てきにDirtyすることができません。

  • Output
@Component({
  selector: 'app-onpush',
  template: `<button (click)="click()"></button>{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  @Input() input;
  @Output() output = new EventEmitter();
  click() {
    this.output.emit('updated');
  }
}

@Component({
  selector: 'app-root',
  template: `<app-onpush [input]="inputData" (output)="fromPush($event)"></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  inputData = 'pushInput';
  fromPush(value) {}
}

Outputの場合、Templateに宣言する意味がOnPush ComponentのElementがいるTemplate(<app-onpush (output)="fromPush($event)"></app-onpush>)でevent binding を宣言する意味です。このように宣言したら、OnPush ComponentでEventがEmitされたら、OnPush Componentが自動的にDirtyになって、Change Detectionの変更探知の対象になります。

もし宣言しない場合、

@Component({
  selector: 'app-onpush',
  template: `{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  @Input() input;
  @Output() output = new EventEmitter();

  ngOnInit() {
    // use setTimeout since ngOnInit is in the change detection because
    // this is a part of the component create phase
    setTimeout(() => {
     this.output.emit('updated');
    });
  }
}

@Component({
  selector: 'app-root',
  template: `<app-onpush></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
}

このとき、outputがOnPush Componentで@Outputとして定義しても、Templateで宣言していません。そしたら、たとえば、setTimeoutなどの処理でこのoutputでなにか値をEmitしても、画面が更新されません、つまりOnPush ComponentがDirtyになっていません。

その原因が宣言されたとき、AngularがTemplateをCompileするとき、<app-onpush (output)="fromPush($event)" をみて、fromPush というEvent Bindingの関数をWrapして、この関数が所属するComponentがもしOnPushの場合、Dirty化にする処理も入れました。なので、宣言しないと、AngularがTemplateをCompileするときこの関係がわからないので、自動てきにDirtyすることができません。

  • Event Listener
@Component({
  selector: 'app-onpush',
  template: `<button (click)="click()"></button>{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  click() {
    this.input = 'clicked';
  }
}

@Component({
  selector: 'app-root',
  template: `<app-onpush></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  inputData = 'pushInput';
}

EventListenerの場合、Outputと似ています、OutputがOnPush ComponentのElementがいるTemplateで宣言しますが、EventListenerがOnPush Component自分のTemplateで宣言する意味です。このように宣言したら、EventがTriggerされたら、OnPush Componentが自動的にDirtyになって、Change Detectionの変更探知の対象になります。

もし宣言しない場合、

@Component({
  selector: 'app-onpush',
  template: `<button #btn>Click</button>{{input}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  @ViewChild('btn') btn;
  ngOnInit() {
    this.btn.nativeElement.addEventListener('click', click);
  }
}

@Component({
  selector: 'app-root',
  template: `<app-onpush></app-onpush>`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
}

このとき、clickがOnPush Componentでclick event listenerとして登録しても、Templateで宣言していません。Buttonをクリックしても、画面が更新されません、つまりOnPush ComponentがDirtyになっていません。

その原因が宣言されたとき、AngularがTemplateをCompileするとき、<button (click)="click()">Click</button>" をみて、click というEvent Bindingの関数をWrapして、この関数が所属するComponentがもしOnPushの場合、Dirty化にする処理も入れました。なので、宣言しないと、AngularがTemplateをCompileするときこの関係がわからないので、自動てきにDirtyすることができません。

  • Async Pipe
@Component({
  selector: 'app-onpush',
  template: `{{data$ | async}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPushComponent {
  data$: Observable<any>;
}

Async PipeがとくにOnPush Componentと関係がありません、Async Pipeの仕組みが新しい値がくるとき、自動的にmarkForCheck()を呼び出して、該当のComponentとAncestorComponentを全部Dirty化することになります。

@Pipe({name: 'async', pure: false})
export class AsyncPipe implements OnDestroy, PipeTransform {
obs$ = this.observablesToSubscribeSubject
  .pipe(
    distinctUntilChanged(ɵlooseIdentical),
    switchAll(),
    distinctUntilChanged(),
    tap(v => { this.value = v; this.ref.markForCheck(); })
  );

纏め

つまり、OnPush Componentを利用するとき、必ずAngular Compilerをわかれるように、Templateに宣言して、これらの要素が変更可能ということをAngularに教えることが必要になります。

以上です、どうもありがとうございました。