🌟

Ionic AngularのStandalone構成でのベストプラクティスを考える

2023/12/20に公開

Ionic AngularがStandalone構成で使えるようになりました。このことで、実装が今後大きく変わるようになります。ここでは example.component.ts としますが、以下のような差分が生まれます。

 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
-import { IonicModule, ModalController } from '@ionic/angular';
+import { ModalController } from '@ionic/angular/standalone';
+import { addIcons } from 'ionicons';
+import {
+  wineOutline,
+  receiptOutline,
+  timeOutline,
+  walletOutline,
+  fileTrayStackedOutline,
+  calendarOutline,
+  documentTextOutline,
+} from 'ionicons/icons';

+import {
+  IonHeader,
+  IonToolbar,
+  IonButtons,
+  IonButton,
+  IonContent,
+  IonText,
+  IonList,
+  IonItem,
+  IonLabel,
+  IonIcon,
+  IonInput,
+  IonListHeader,
+  IonRadioGroup,
+  IonRadio,
+  IonNote,
+} from '@ionic/angular/standalone';
 
 @Component({
   selector: 'app-move-bottle',
   templateUrl: './move-bottle.page.html',
   styleUrls: ['./move-bottle.page.scss'],
   standalone: true,
-  imports: [IonicModule, FormsModule, CommonModule, KeyTrackByPipe],
+  imports: [
+    FormsModule,
+    CommonModule,
+    KeyTrackByPipe,
+    IonHeader,
+    IonToolbar,
+    IonButtons,
+    IonButton,
+    IonContent,
+    IonText,
+    IonList,
+    IonItem,
+    IonLabel,
+    IonIcon,
+    IonInput,
+    IonListHeader,
+    IonRadioGroup,
+    IonRadio,
+    IonNote,
+  ],
 })
 export class ExampleComponent implements OnInit {
-  constructor() {}
+  constructor() {
    addIcons({ wineOutline, receiptOutline, timeOutline, walletOutline, fileTrayStackedOutline, calendarOutline, documentTextOutline });
+  }

な、長い。これだけ行数があると、Ionicのコンポーネントを使うのが面倒になってしまいます。それをフィードバックしたIssueがあったので読んでいきましょう。

feat: Ionic Angular standalone component feedback #28445

まずベストプラクティスを考えるにあたって有用なのは、Ionic Frameworkのレポジトリに立てられた本Issueです。とても簡単にいうと、開発者体験が悪化してしまったというフィードバック。

https://github.com/ionic-team/ionic-framework/issues/28445

※ 本Issueでは、7.5.2ではStandalone機能を使うとバンドルサイズが増える不具合があったことに触れられていますが、解決済みですのでここでは割愛します。

Ionic does not provide export groups for components
Ionicは、コンポーネントグループを提供することはありません。

複数のIonicコンポーネントをインポートするのが開発者体験が落ちるなら、以下のようにコンポーネントグループを作ることがまず思い浮かびます。

import { IonAccordion, IonAccordionGroup, IonInput, IonTextarea } from '@ionic/angular/standalone';

export const ACCORDION_GROUP = [IonAccordion, IonAccordionGroup];
export const INPUT_GROUP = [IonInput, IonTextarea];

任意のグループでつくるngModuleのようなものですね。ただ、これには2つの問題があります。ひとつめは、上記の ACCORDION_GROUP のように中身が明確なものはいいのですが、 TOOLBAR_GROUP をつくるとしたらそこに、IonTitle は含まれる? IonButtons は??というように、かなり分類が主観的なものとなり、機能で分類することができないことです。これだと開発者体験は向上しませんよね。もうひとつは、IDEの自動補完で、コンポーネントグループは表示されないことです。

そうすると、特に複数メンバーでの開発において、コンポーネントグループからインポートする人と、パッケージから直接インポートする人に分かれてしまいます。そのため、この解決方法が現実的ではありません。

You don't have to call addIcons in every component.
すべてのコンポーネントでaddIconsを呼び出す必要はありません。

You're more than welcome to register them in main.ts or app.component.ts.
main.tsやapp.component.tsで登録することもできます。

IonIconを使う "正しい" 方法は、コンポーネント内で利用するアイコンを addIcons することですが、厳密にいうと addIcons はそのアイコンをWindow関数に登録します。続いて、表示時には、IonIconはWindow関数を参照して該当するアイコンを取得し、表示します。ですので、いうなればIonIconが表示するよりも前に addIcons されていれば、アイコンを表示することは可能です。

例を示しましょう。 CloseOutline (name=close-outline) を、AページとBページの両方で使うとしましょう。Aページは必ず、Bページよりも先に表示される関係です。

Aページ ー 遷移 → Bページ

このような場合、 addIcons(CloseOutline) をBページのみで行っていた場合、Aページではアイコンは表示されず、Bページでは表示されます。逆に、Aページのみで行っていた場合、両方のページで表示されます。

※ 厳密にはBページがLazyLoadingされるかや、先読みで constructor が読み込まれないか等が関わってきますが割愛します。

このことから、コンポーネントが表示される前に処理が走る main.tsapp.component.tsaddIcons をすることもできるよと提案しています。ただ、その代わりにmain.tsapp.component.tsで登録しているIonIconsが増えるほど、初期バンドルサイズが増えることには注意が必要です。

では、どのように立ち向かっていくか

Ionic Components import

これについては、早期からコンポーネントをインポートする仕様をつくってきたReactが参考になります。以下のような書き方ですね。

import React from 'react';
import { IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';
import ExploreContainer from '../components/ExploreContainer';
import './Home.css';

const Home: React.FC = () => {
  return (
    <IonPage>
      <IonHeader>
        <IonToolbar>
          <IonTitle>Blank</IonTitle>
        </IonToolbar>
...

React開発に慣れてる人に聞いたりもしたのですが、やはりコンポーネントグループをつくることは(技術的には可能だけど)悪手だろうとのことでした。上記であげたようにIDEの補完の問題から、書く人によってまちまちになり、コードの統一性はとれなくなる。実際、Reactでも試行錯誤したところ、コンポーネントグループはつくらず、直接インポートするようにしたとのことです。

また、Reactと違い、Angularはコンポーネント自体にCustomElementのローダーがついているので、全部まとめてインポートすると、バンドルサイズ以上に通信が発生して負荷が大きくなってしまう問題があるので、必要分だけインポートするのが現実的でしょう。

ただ、Componentの行数が増えることに対しては、ViewModelを作ってロジックを別クラスに移すことでシンプルにすることができます。シンプルな例を示します。

example.component.ts
@Component({
  selector: 'app-settings',
  templateUrl: './settings.page.html',
  styleUrls: ['./settings.page.scss'],
  standalone: true,
  imports: [
    RouterLink,
    FormsModule,
    CommonModule,
    IonRouterLink,
    IonHeader,
    IonToolbar,
    IonTitle,
    IonContent,
    IonRefresher,
    IonRefresherContent,
    IonList,
    IonListHeader,
    IonLabel,
    IonItem,
    IonIcon,
    IonToggle,
    IonButton,
    IonNote,
    IonText,
  ],
})
export class SettingsPage implements OnInit, OnDestroy {
  // ロジックを書くViewModelを呼び出し
  public vm = new ViewModel();

  // あとライフサイクルだけ書く
  async ngOnInit() {
    await this.vm.initialize();
  }
  public ngOnDestroy() {}
}
example.component.view-model.ts
class ViewModel extends StoreModel {
  public useShopMenu: boolean;
  public readonly helper = inject(HelperService);
  private readonly storage = inject(StorageService);

  async initialize() {
    this.helper.setDefaultThemeMode();
    this.storage.get(StorageKeyEnum.useShopMenu).then((useShopMenu) => {
      if (useShopMenu !== null) {
        this.useShopMenu = useShopMenu;
      }
    });
  }

  async doRefresh(event: RefresherCustomEvent) {
    event.target.complete();
  }

  public changeTheme(isDark: boolean) {
    this.helper.changeTheme(isDark);
  }
}

このようにして、Componentは、コンポーネントのインポートとライフサイクルのためだけの機構と割り切り、ロジックを分離することでシンプルな構成を保つことが可能になります。もちろん同一ファイル内においても、いやいや別にClass内の行数が増えるわけではないので分離する必要がないというのもひとつです。

IonIcon

ただ、IonIconについては状況が少し異なります。繰り返しになりますが、もちろん必要なだけComponent毎に addIcons するのが理想的です。ただ、 addIcons はビルドプロセスと関係ないため、追加し忘れていてもビルドエラーは発生しません。そのため、開発者が addIcons を忘れてしまうと、ユーザはただアイコンが表示されないアプリをさわることになる可能性があります。 上記Issueにもこの件は触れられていたのですが、どうしてもその特性上、ブラウザ上で console.error でエラーが表示されるだけとなってしまいます。これは現実的ではありません。

また、見せかけでアイコンが表示されていたとしても、

Aページ ー 遷移 → Bページ

で紹介したように、ただのコンポーネントの表示順によるものかもしれません。別の遷移をたどると、Bページが先に表示されるような場合、アイコンが表示されないということになります。そのため、以下の自動アイコン収集CLIを作成しました。

https://github.com/rdlabo-team/ionic-angular-collect-icons

これは、Ionic Angularのコンポーネントを重複なく自動で収集し、main.tsaddIcons するための CLIです。コンポーネントごとには addIcons しないので、"正解" のプラクティスでないことに留意が必要です。あくまで私が考える現実的な解決策です。

このライブラリは、以下のようなファイルを自動生成・更新します。

use-icons.ts
export { keypadOutline, closeOutline, removeCircleOutline, addCircleOutline, arrowUpOutline, copyOutline, clipboardOutline, filterOutline, swapVerticalOutline, chevronDownOutline, imageOutline, documentOutline, add, wineOutline, receiptOutline, timeOutline, walletOutline, fileTrayStackedOutline, calendarOutline, documentTextOutline, logoApple, languageOutline, closeCircleOutline, pricetagOutline, arrowForwardOutline, cloudDownloadOutline, cloudUploadOutline, checkboxOutline, linkOutline, personCircleOutline, toggleOutline, printOutline, moon, fileTrayFullOutline, bagHandleOutline, codeWorkingOutline, analyticsOutline, trendingUpOutline, codeDownloadOutline, todayOutline, logoTwitter, readerOutline, checkmarkCircle, personOutline, ellipseOutline, locationOutline, listCircleOutline, barcodeOutline, albumsOutline, ellipsisHorizontalCircleOutline, addOutline, ellipsisHorizontalOutline, image, informationCircleOutline, earthOutline, bagCheckOutline, radioButtonOnOutline, exitOutline, trashOutline, swapHorizontal, alertCircle, home, paw, bagOutline, pinOutline, homeOutline, timerOutline, checkmarkCircleOutline, alertCircleOutline, settingsOutline, playOutline, arrowRedoOutline, briefcaseOutline, carOutline, gitCompareOutline } from "ionicons/icons";

これは、プロジェクト内のテンプレートを参照して収集したアイコンであり、それ以外のアイコンは追加されません。そして、これを main.tsaddIcons します。

+ import { addIcons } from 'ionicons';
+ import * as allIcons from 'ionicons/icons';
+ import * as useIcons from '../use-icons';

  if (environment.production) {
    enableProdMode();
  }

+  addIcons(environment.production ? useIcons : allIcons);

environment.production で条件分岐させているのは、CLIを実行しないとexport文が更新されないため、開発者体験を下げないために開発環境では すべての アイコンを addIcons するためです。ただ、それだとバンドルサイズが大きく肥大化するため、本番環境では use-icons.ts で収集したアイコンのみを addIcons するようにしています。コマンドひとつで実行できるライブラリなので、まずはお試しください。

https://github.com/rdlabo-team/ionic-angular-collect-icons

今のところ、これが現実的な解決策かなと思っています。

まとめ

Ionic AngularのStandalone構成でのベストプラクティスを考えてみました。Ionic AngularのStandalone構成は、今後の開発において大きな変化をもたらすと思います。そのため、早期にベストプラクティスを考えておくことは、開発者体験を向上させるためにも重要です。先送りせずに、今のうちに考えておきましょう。

それではまた。

Discussion