💨

2年以上放置していたIonic Angularプロジェクトを最新にキャッチアップした話

2023/05/24に公開

2021年に最後の更新をして以降、ずっと放置してたアプリ「食品表示印刷」を、ようやく最新の環境にキャッチアップした話です。

https://foodlabel.rdlabo.jp/

がんばったとかそういう話ではなく、どういうステップで変更していったということをまとめています。なお、リプレイス以前のpackage.jsonの一部です。見る人がみたら、「おおう」ってなるやつ。

{
  "dependencies": {
    "@angular/core": "~11.1.0",
    "@angular/fire": "^6.1.3",
    "@capacitor/core": "^2.4.6",
    "@ionic-native/core": "^5.29.0",
    "@ionic/angular": "^5.5.4",
    "@ionic/storage": "^2.3.1",
    ...
  },
  "devDependencies": {
    "@angular/cli": "~11.1.0",
    "@capacitor/cli": "^2.4.6",
    "@ionic/angular-toolkit": "^3.1.0",
    "typescript": "~4.0.5",
    ...
  }
}

アップデート作業

1. Angularをアップデート

まず、脳死でAngularをアップデートしていきました。Angularには、めっちゃありがたいことに ng update コマンドがあり、これを使えばコアの破壊的変更は自動的にコードをアップデートしてくれます。これがなかったら、この作業自体断念していたかも。 ng update コマンドは、メジャーバージョンをひとつずつあげていく必要があるため、淡々と以下を実行してきました。

% ng update @angular/core@12 @angular/cli@12 --force
% ng update @angular/core@13 @angular/cli@13 --force
% ng update @angular/core@14 @angular/cli@14 --force
% ng update @angular/core@15 @angular/cli@15 --force
% ng update @angular/core @angular/cli @ngrx/store @ionic/angular-toolkit firebase-tools @ionic/angular @angular/fire --force

--force オプションは、依存関係の解決を無視してくれるオプションです。これがないと、依存関係の解決でエラーが出てしまいます。 最後の行でパッケージ増えてるのは、ついてにAngular16に依存するパッケージをすべて最新版にするためです。これで、Angularのバージョンは最新になりました。やったー!

2. 古くなったライブラリのアップデート

時代を感じますよね。まず、 @ionic-native が、 @awesome-cordova-plugins にリネームされたので、すべて置き換えます。

% npm remove @ionic-native/core
% npm install @awesome-cordova-plugins/core

こんな感じで全部を入れ替え。あと、 @ionic/storage@ionic/storage-angular になったのでこれも入れ替えます。これは使い方も大きく変わりましたね。

https://github.com/ionic-team/ionic-storage

Angularは昔はtslintを採用してたのですが、eslintになったので、eslintを導入します。

% ng add @angular-eslint/schematics
% npm remove tslint

このコマンドだけですべて自動設定してくれました。

3. Capacitorのアップデート

Capacitor3以降はマイグレーションの自動化コマンド( npx cap migrate )が用意されてるのですが、Capacitor2はないので、一度すべてを入れ直しました。まず、Capacitor用のiOS/Androidフォルダを削除して、そのあと関連パッケージをすべてアップデートします。

% rm -rf ios android
% npm install @capacitor/cli@4 @capacitor/core@4 @capacitor/android@4 @capacitor/ios@4
// あと、プラグインまわりもアップデート

4. とりあえず動くようにする

特にCapacitor2から3になる時に、プラグインを Plugins オブジェクトから取得する形から、直接インポートする形に変わったり、コアプラグインがすべて個別パッケージになったりと大きな変更があったので、そこをよしなにします。 ionic serve してる状態だと、CLIがよしなにエラー箇所を教えてくれるのでそこを片っ端から直していきます。あと、もしかすると angular.json が古い可能性もあるので、 https://github.com/ionic-team/starters/blob/main/angular-standalone/base/angular.json と見比べておくといいかと思います。

といっても、Gitのコミットログをみてると、2時間あれば終わってますね。私のアプリの範囲では、 rxjs@angular/fire がバージョンアップに伴って破壊的変更があったりで( compat を消すために)時間がかかってたようです。FireStore使ってなかったら多分もっとはやく終わったんじゃないかな。

5. Ionic 7の記法に変更

https://zenn.dev/rdlabo/articles/1eb9e13c8a5945

で書きましたが、フォームシンタックスが簡素化されたので、最新版の書き方に変更します。あと、Ionic 6で ion-datetime が大きく変わったので、新しい ion-datetime に対応しました。

https://zenn.dev/rdlabo/articles/62f404d448df01

マイグレーション作業

1. Standalone Componentsに移行

以下のコマンドで自動的にStandalone Componentsに移行できます。

% ng generate @angular/core:standalone

ただ、マイグレーションが3段階になってるので、 Convert all components, directives and pipes to standaloneRemove unnecessary NgModule classesBootstrap the application using standalone APIs を順番に実行します。なので上記コマンドは3回叩きます。
ただ、これですべてのModuleが削除されるわけではなく、Router Moduleだけ残るので、そこは手動で移行する必要があります。といっても、記法がシンプルになるだけです。今までのRouter Moduleから、Route部分を以下のようにひっぺがして

https://github.com/ionic-team/starters/blob/main/angular-standalone/official/tabs/src/app/tabs/tabs.routes.ts

それを main.ts で読み込むだけです。

https://github.com/ionic-team/starters/blob/main/angular-standalone/base/src/main.ts#L18

これは本当先送りせずにやっておいた方がいいです。30分もかからない上に、今後の開発コストが落ちます。落ちました。
ここまですませると、プロジェクトから ngModule を一掃できたと思います(私の場合、テスト用の ngModule だけ残りましたが)。

2. strict: true にする

昔の Ionic Angular って strict: true じゃなかったんですよね。なので、 このタイミング tsconfig.jsonstrict: true にしてしまいましょう。なんだったら、スターターテンプレートの tsconfig.json をコピペしてしまって、最新版にする方がはやいです。

https://github.com/ionic-team/starters/blob/main/angular-standalone/base/tsconfig.json

3. constructor injectから、property injectに変更

https://zenn.dev/rdlabo/articles/7aa0b566f97c80 を導入して、Lintを走らせただけです。簡単。

Overlay ControllerをComponentから分離

constructor injectからproperty injectにする一番のメリットって、Componentの外でもAngularのServiceを使うことができることだと思っています。そして、私は日頃から感じていたんですが、Overlay Controllerって、Componentにあると冗長じゃないですか? Modal Controller とか Alert Controller たちのことです。これをComponentにおいておくと行数がとられるんですが(Alert Controllerだと1つ使うのに20行ぐらい)、そのコンポーネントに依存してるのをいちいちServiceに持っていくのは億劫です。 なので、Overlay Controllerを持つComponentsはすべて同じファイルに OverlayFunc という関数を持って、それをComponentsのプロパティに置くようにしました。こんな感じ。

Before:

@Component({
  selector: 'app-main',
  templateUrl: 'main.page.html',
  styleUrls: ['main.page.scss'],
  standalone: true,
  imports: [IonicModule, FormsModule, RouterLink, CommonModule],
})
export class MainPage {
  private readonly overlayFunc = overlayFunc();
  public readonly platform = inject(Platform);

  constructor() {}

  public async navigatePrint (event: Event) {
    event.stopPropagation();
    const modal = await this.modalCtrl.create({
      component: SimplePrintPage,
    });
    await modal.present();
  };

  async alertDeleteItem(productName: string): Promise<boolean> {
    return new Promise<boolean>(async (resolve) => {
      const alert = await this.alertCtrl.create({
        header: `${productName}を削除しますか?`,
        message: '削除した場合、復元を行うことはできませんので再登録が必要です。',
        buttons: [
          {
            text: 'キャンセル',
            handler: () => resolve(false),
          },
          {
            text: '削除',
            handler: () => resolve(true),
          },
        ],
      });
      await alert.present();
    });
  }

  public async shareApp () {
    await Share.share({
      title: '食品表示印刷',
      text: '食品表示ラベルをシンプルに印刷するモバイルアプリ',
      url: 'https://foodlabel.rdlabo.jp/',
      dialogTitle: '食品表示ラベルをシンプルに印刷するモバイルアプリ「食品表示印刷」',
    });
  };
}

After:

@Component({
  selector: 'app-main',
  templateUrl: 'main.page.html',
  styleUrls: ['main.page.scss'],
  standalone: true,
  imports: [IonicModule, FormsModule, RouterLink, CommonModule],
})
export class MainPage {
  public readonly platform = inject(Platform);
  private readonly overlayFunc = overlayFunc();

  constructor() {}

  public navigatePrint = (event: Event) => {
    event.stopPropagation();
    return this.overlayFunc.navigatePrint();
  };
  public shareApp = () => this.overlayFunc.shareApp();
}

function overlayFunc() {
  const [alertCtrl, modalCtrl] = [inject(AlertController), inject(ModalController)];

  return {
    async shareApp(): Promise<void> {
      await Share.share({
        title: '食品表示印刷',
        text: '食品表示ラベルをシンプルに印刷するモバイルアプリ',
        url: 'https://foodlabel.rdlabo.jp/',
        dialogTitle: '食品表示ラベルをシンプルに印刷するモバイルアプリ「食品表示印刷」',
      });
    },
    async navigatePrint(): Promise<void> {
      const modal = await modalCtrl.create({
        component: SimplePrintPage,
      });
      await modal.present();
    },
    async alertDeleteItem(productName: string): Promise<boolean> {
      return new Promise<boolean>(async (resolve) => {
        const alert = await alertCtrl.create({
          header: `${productName}を削除しますか?`,
          message: '削除した場合、復元を行うことはできませんので再登録が必要です。',
          buttons: [
            {
              text: 'キャンセル',
              handler: () => resolve(false),
            },
            {
              text: '削除',
              handler: () => resolve(true),
            },
          ],
        });
        await alert.present();
      });
    },
  };
}

行数自体は変わらないのですが、Overlay ControllerってPromiseで結果を返すだけのUI操作ライブラリを使ってるだけなので、Componentに置くのは冗長だと思っています。 なので、Overlay ControllerをComponentから分離して、Overlay Controllerを使うComponentはOverlay Controllerを持つ関数をプロパティに持つようにしました。もしかすると将来的に別ファイルにしてより目のつかないところに追い出すかもしれません(笑)

まとめ

ざっと急ぎ足ですが、Angular11/Ionic5のアプリをAngular16/Ionic7にアップデートした方法を紹介しました。これ系のアップデートは後回しにするほど大変になるので、こまめにやっていきたいですね。

それではまた。

Discussion