🎃

Ionic AngularでOnPush変更検知にマイグレーションする

2024/10/15に公開

Ionic Angularでは、以前は「OnPush変更検知は予期せぬ不具合が起きる可能性があるから使わないでね」とされていました。しかし、ドキュメントを読み直したところ、現在ではOnPush変更検知が使えない箇所が明記されており、それ以外では問題なく使えそうです。

ion-nav または ion-router-outlet を使用するコンポーネントは、 OnPush 変更検出方式を使用しないでください。 ngOnInit などのライフサイクル・フックが実行されなくなります。また、非同期状態の変更は正しくレンダリングされない場合があります。
https://ionicframework.com/docs/angular/lifecycle#angular-life-cycle-events

そこで、弊社プロダクトであるwinecode( https://site.winecode.app/ )で、ion-nav または ion-router-outlet を使用するコンポーネントを除外したすべてのコンポーネントでOnPush変更検知にマイグレーションしました。問題なく動いており、またチェック量が減ったことでより軽快に動作するようになったので、実際にマイグレーションした手順をご紹介します。自動的なものはないので、基本努力です。笑

基本路線

すでにソースコードのボリュームのあるアプリですので、今から ChangeDetectorRef.markForCheck を利用して書き直し、動作確認をするのは現実的ではありません。そこで、v16以降に導入されたSignalsを利用して変更検知を行います。OnPush変更検知を採用しているテンプレート内にSignalsがあると、Angularは自動的に変更検知を行ってくれます。また、 OnPush 変更検知で起こり得る不具合は、「変更したのにテンプレートが更新されないこと」ですので、すべてのプロパティは readonly とすることにします。定数は問題ないので。

手順

0. 事前準備

いくつかのAPIに関しては、ngxtensionが自動アップデート機構を用意しているので、まだの場合はアップデートしておきます。

npm i ngxtension --save-dev

New output() Migration

% ng g ngxtension:convert-outputs --project=app

https://ngxtension.netlify.app/utilities/migrations/new-outputs-migration/

Queries Migration

% ng g ngxtension:convert-queries --project=app

https://ngxtension.netlify.app/utilities/migrations/queries-migration/

Signal Inputs Migration

% ng g ngxtension:convert-signal-inputs --project=app

https://ngxtension.netlify.app/utilities/migrations/signal-inputs-migration/

1. マイグレーション対象のコンポーネントを選定する

一度にすべてのコンポーネントのマイグレーションは不可能ですので、マイグレーションをコンポーネント毎に行うことになります。上層(呼び出し元)からマイグレーションを行うと、マイグレーションできていない呼び出し先が正しく動作しない可能性があるので、下層(呼び出し先)からマイグレーションを行うことにします。ちょっと懐かしいたとえですが、Atomic Designに沿ってコンポーネント設計を行っているなら、

原子 → 分子 → 有機体 → テンプレート → ページ

の順でマイグレーションを行うとよいでしょう。

2. マイグレーション対象のコンポーネントをOnPush変更検知に変更する

強い意志を持って、OnPushに変更します。

  @Component({
    ...
+   changeDetection: ChangeDetectionStrategy.OnPush,
  })

3. マイグレーション対象のコンポーネントのプロパティをreadonlyに変更する

まずエラーはでますが、強い意志を持ってすべてに readonly をつけます。

  export class HomePage implements OnInit, OnDestroy, ViewDidEnter {
-   SlipType = SlipType;
-   version = packageInfo.version;
-   emailUser = '';
-   initSubscription$: Subscription[] = [];
+   readonly SlipType = SlipType;
+   readonly version = packageInfo.version;
+   readonly emailUser = '';
+   readonly initSubscription$: Subscription[] = [];

余談ですが、ServiceのInjectも readonly をつけることができるので(※上書きする人はそういないでしょうが)、すべてのプロパティは原則 readonly をつける運用はありだと思っています。チームでのコーディングルール上、なぜここは変数で、こっちはSignalでということがなくなります。

4. エラーがでるものはすべて Signals に置き換える

  export class HomePage implements OnInit, OnDestroy, ViewDidEnter {
    readonly SlipType = SlipType; // 定数だからそのまま
-   readonly version = packageInfo.version;
-   readonly emailUser = '';
    readonly initSubscription$: Subscription[] = []; // pushしかしないのでそのまま
+   readonly version = signal<string>(packageInfo.version); // 書き換えるのでSignalに
+   readonly emailUser = model<string>(''); // 双方向バインディングするのでSignal modelに

全部が全部Signalsに置き換えても不都合はありませんが(Signalに asReadonly() もあることですし)、ここでは必要最低限の部分だけを書き換えていきます。

5. 呼び出し先を修正する

地道な作業です。HTMLテンプレートで、バインディングをしている部分を書き換えていきます。HTMLテンプレートでよくあるパターンだと、if文やfor文、バインディングでカッコをつけたす必要があります。

-   @if (emailUser) {
-     <ion-text>{{ emailUser }}</ion-text>
-   }
  
+   @if (emailUser()) {
+     <ion-text>{{ emailUser() }}</ion-text>
+   }

ちなみに、 ngModel は書き換える必要がないため [(ngModel)]="emailUser" を間違って [(ngModel)]="emailUser()" と書き換えないように注意が必要です。
ただ、HTMLテンプレートについては、AngularにSignalとして使うのを忘れていないか確認するExtendがあるので、 tsconfig.jsonstrictTemplates を有効にしている場合は漏れは気にしなくていいとは思います。なお、コンポーネントクラスだけが対象です。

TypeScriptの書き換えも当然必要です。まぁ、Signalのドキュメント通りですよね。

- if (this.emailUser) {
+ if (this.emailUser()) {
    console.log(this.emailUser);
  }

あえて引っかかるポイントでいうと、Signalでのオブジェクトのアップデートは、オブジェクト自身が同じだとSignalは変更を検知しません。なので、更新する時は、オブジェクトを新しく生成して返す必要があります(以下では { ...user } は新しいオブジェクトを生成しています)。

- this.user.email = email;
+ this.user.update((user) => {
+   user.email = email;
+   return { ...user }
+ });

作業漏れの確認

1. TypeScriptでの書き換えミス

私自身の作業でよく漏れがあったこはこれです。

class HomePage {
  readonly isReady = signal<boolean>(false);
  ...
  hoge() {
    /**
     * ここが間違い。Signalなので常にtrueが返る。正しくは `if (this.isReady())` 。
     */
    if (this.isReady) {   
      console.log('ready');
    }
  }
}

さすがに目視確認してられないので、 とても簡単なeslintルールを用意しました。 signal model で宣言された場合、 ().** がついているかをチェックするだけです。

https://github.com/rdlabo-team/eslint-plugin-rules/blob/main/docs/rules/signal-use-as-signal.md

マイグレーションの時につけておくと、トラブル防止になるかも・・・?

2. どのファイルって作業終わってないっけ

@angular-eslint/prefer-on-push-component-change-detection を使うと、eslintでOnPush変更検知がされていないコンポーネントを検知できます。あとどれぐらいのファイルが残っているか確認する時に重宝しました。

まとめ

アプリによっては作業ボリュームがそこそこあるので、簡単にはじめることはできませんが、パフォーマンス改善のひとつの打ち手として意識しておいていただければと思います。なお、くれぐれもion-nav または ion-router-outlet を使用するコンポーネントでは変更しないようにしてください。

OnPushにした時の動作等は、この記事が詳しいですね。

https://qiita.com/masaks/items/61150907ce95b509fcaa

それではまた。

Discussion