Angular OnPush Component注意すべきなどこる: 続き


前日の記事でAngular OnPush Componentでいろいろ注意すべきなどころを書きました、この記事がさらにいくつ普段あまり使われていないケースを説明したいと思います。

  1. ケース1:componentRef.changeDetectorRef.detectChanges() is confusing.
@component({
  selector: 'dynamic',
  template: `{{name}}`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class Dynamic  {
  name = 'initial name';
}

@component({
  selector: 'my-app',
  template: `
    <b>Expected</b>: "initial name" changes to "changed name" after 2s<br>
    <b>Actual</b>:<br>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent  {
  constructor(private _vcRef: ViewContainerRef,
     private _cfr: ComponentFactoryResolver, private _injector: Injector) {}

  ngOnInit() {
    const cmptRef = this._cfr.resolveComponentFactory(Dynamic).create(this._injector);
    this._vcRef.insert(cmptRef.hostView);

    setTimeout(() => {
        cmptRef.instance.name = 'changed name';
        cmptRef.changeDetectorRef.detectChanges(); // this will not update the DOM.
        // cmptRef.injector.get(ChangeDetectorRef).detectChanges(); // this will update the DOM.
    }, 2000);
  }
}

このケースがDynamic のOnPush Componentを追加して、このcomponentのcmptRef.changeDetectorRef.detectChanges() をコールしても、画面が更新されないという現象になります。
そして、もしcmptRef.injector.get(ChangeDetectorRef).detectChanges();で実行されたら、画面が更新されました。

その原因がcmptRef.changeDetectorRefがこのOnPush ComponentのchangeDetectorRefではなく、そのComponentを格納するPlaceholderのHostViewのchangeDetectorRef になります。

なので、このHostViewのdetectChanges()をコールして、OnPush ComponentがまだDirtyではなくため、画面が更新されません。

  1. ケース2:ComponentFixture.detectChanges() is confusing
const myComp = TestBed.createComponent(OnPushComponent);
myComp.componentInstance.abc = 123;
myComp.detectChanges() // Does not work

myComp.componentRef.injector.get(ChangeDetectorRef).detectChanges(); // This will work.

ケース1と似てるですが、テストにもこの現象が有ります。

  1. ケース3:ngDoCheck()が実行されましたが, 実際ChangeDetectionが実行されていません.
@Component({
  selector: "onpush",
  template: `
    onpush: {{ name }} <br />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPush implements DoCheck {
  name = "initial name";

  ngDoCheck() {
    console.log("docheck onpush");
  }
}

@Component({
  selector: "my-app",
  template: `
    <onpush></onpush>
    <button (click)="onClick()">Update onpush</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
  @ViewChild(OnPush) onPushComp: OnPush;
  constructor() {}

  onClick() {
    this.onPushComp.name = 'new name';
  }
}

このケースの場合、OnPushが更新されないのは正しいですが、ngDoCheckが実行されたのが変です。
その理由がHostViewの存在です、実際はComponentのLifecycle hooksが親のViewに所属していますため。

  1. ケース4:dev modeで、OnPush ComponentがCheckNoChanges()が実行されていません、つまりどのようなパターンでもExpressionChangedAfterItHasBeenCheckedErrorになれません。
import {
  Component,
  NgModule,
  ViewChild,
  ChangeDetectionStrategy,
  DoCheck,
  Input, AfterViewInit
} from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";

@Component({
  selector: "onpush",
  template: `
    onpush: {{ name }} <br />
    {{ input }}
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class OnPush implements AfterViewInit {
  name = 'initial name';

  ngAfterViewInit() {
    this.name = 'updated name';
  }

}

@Component({
  selector: "my-app",
  template: `
    <b>Expected</b>: "initial name" changes to "new name" after button click<br />
    <b>Actual</b>:<br />
    <onpush></onpush>
  `,
  changeDetection: ChangeDetectionStrategy.Default
})
export class AppComponent {
  @ViewChild(OnPush) onPushComp: OnPush;

  constructor() {}
}

その原因が今AngularがExpressionChangedAfterItHasBeenCheckedErrorを探知するため、開発モードで2回Change Detectionを実行しました。OnPushの場合、1回目でDirtyフラグをリセットして、2回目がDirtyではない状態になって、CheckNoChangesの処理が実行されなくなりました。

では、なぜHostViewという概念が存在しますか?ちょっといろいろなLegacyの原因があります、これを説明すると、すごく長文になりしょうですので、纏めると、

  1. Directiveを設計するとき、DirectiveがHostElementがないので、DirectiveのHooksとかを一個PlaceholderのHostViewに置く設計になりました。
  2. ComponentもDirectiveなので(ComponentがViewがあるDirective)、設計を統一するため、ComponentにもHostViewを持ちました。
  3. 普通のComponentの場合、わざわざ新しいHostViewを作る必要がなく、Parent Component ViewをHostViewとして利用する
  4. BootStrap/Dynamic Componentの場合、作成したとき、親が分からないため、新しく一個HostViewを作成されました。

ということになりました。

このHostViewが開発者意識させたくないものなので、開発者が直接触るケースがないはずですが、上記のケースでちょっとわかりにくいことが発生しました。

今これらの問題を解決するため、大幅にChange Detectionのデータ構造・ロジックを更新することが難しくて、これから、

  1. Documentを強化して、これらのケースとWork Around案を明確する
  2. 将来てきには、HostViewをなくす設計を検討する

ということになります。
今わたしのほうで1番目を対応中ですが、皆さんがこれらの問題をあうとき、この記事が参考になればいいと思います。

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