本文へ移動
rdlabo.dev

require-viewmodel

Componentの new ViewModel(this)ViewModelStore<ComponentType, Keys> 継承を強制し、View APIをViewModelから排除する。

  • ⭐️ このルールは plugin:@rdlabo/rules/recommended プリセットに含まれます。

Ionic AngularのComponent / ViewModel分割を1つのルールで強制します。同居するクラス名のデフォルトは ViewModel です。

検査 要件
Component所有 すべての @Componentnew ViewModel(this) で初期化されたフィールドを持つ
構築 第1引数は this でなければならない
ホスト境界 ViewModelは ViewModelStore<ComponentType, Keys> を継承する。Keys は任意で、明示的な非SignalのComponentプロパティを公開する
Constructor / host ViewModelはデフォルトで両者を継承する。任意のconstructorは super(host)host を転送しなければならない
View API viewChild / viewChildren / contentChild / contentChildren / effect / computed / 描画lifecycle APIは ViewModel 内に現れてはならない

所有検査では @Component 以外のクラスは無視します。@rdlabo/rules/no-component-method-except-lifecycle と併用します。

ルール詳細

✅ Signalとoutputホストのみ

@Component({ selector: 'app-example', template: '' })
export class ExamplePage {
  readonly vm = new ViewModel(this);
}

class ViewModel extends ViewModelStore<ExamplePage> {
  save(): void {
    this.host.saved.emit();
  }
}

ViewModelStore がconstructorを所有し、Componentオブジェクトを保持します。公開の host@rdlabo/ionic-angular-kitViewModelHost<T, K> 型を使うため、クラスフィールド初期化時にコピーされるのではなく、ViewModel method実行時に値が読まれます。基底constructorはホストの絞り込みと一度きりの描画フックを、kitの mountViewModel() ヘルパーへ委譲します。

✅ 明示的な非Signal依存

class ViewModel extends ViewModelStore<EntryPage, 'entryForm' | 'inventoryModel'> {
  save(): void {
    this.host.entryForm.save();
  }
}

第2型引数は任意です。TypeScriptはそのキーがComponentに属することを検査します。ハードプライベートなComponentフィールドは Pick で公開できないため、ViewModelが必要な場合は公開の readonly 境界プロパティを使います。

✅ 共有ViewModel基底

class ViewModel extends MainViewModel<FoodsPage> {}
class ViewModel extends ListViewModel<WineListPage> {}
class ViewModel extends ModelSearch<SearchPage, SearchCondition> {}

名前が ViewModel で終わる中間基底と、確立された ModelSearch 基底は、第1型引数が所有Componentと一致するときに受け入れられます。中間基底自体は ViewModelStore を継承している必要があります。

ジェネリックなViewModelはデフォルトのComponentホストを使えます。所有検査にはそのデフォルトが使われます。

class ViewModel<THost = MainPage> extends ViewModelStore<THost> {}

ハードプライベートなViewModel所有も問題ありません。

readonly #vm = new ViewModel(this);

❌ ViewModelなし、または this なしのComponent

@Component({ selector: 'app-example', template: '' })
export class ExamplePage {
  readonly title = 'x'; // error: missingViewModel
}

readonly vm = new ViewModel();
readonly vm = new ViewModel(other);

❌ 旧来のViewModelごとのhostパターン

class ViewModel extends StoreModel {
  readonly host: ReactiveHost<ExamplePage>; // error

  constructor(host: ExamplePage) {
    super();
    this.host = host;
  }
}

extends ViewModelStore<ExamplePage> を使い、hostメンバーを削除します。ViewModelのconstructorで host.someProperty をキャッシュしないでください。vm より後に宣言されたComponentクラスフィールドは、まだ初期化されていません。

通常、constructorは不要です。Componentの初期化後に待つ必要があるセットアップには onMount() を優先します。既存の即時constructor副作用を残す必要がある場合は、同じ型のhostを転送する必要があります。

class ViewModel extends ViewModelStore<ExamplePage, 'inventoryModel'> {
  protected override onMount(): void {
    this.host.inventoryModel.initialize();
  }
}

互換のために即時constructorも有効です。

class ViewModel extends ViewModelStore<ExamplePage> {
  constructor(host: ExamplePage) {
    super(host);
    registerCleanup();
  }
}

❌ ViewModel上のView API

class ViewModel extends ViewModelStore<ExamplePage> {
  readonly el = viewChild('host'); // error
  readonly label = computed(() => 'x'); // error
}

viewChild.required(...) も禁止されます。afterNextRender / afterEveryRender / afterRenderEffect は個別のViewModelではなく、kitの mountViewModel() ヘルパーまたはComponentに属します。

オプション

{
  // Class name treated as the ViewModel. default: 'ViewModel'
  viewModelClassName?: string;

  // Required base-class name. default: 'ViewModelStore'
  viewModelStoreClassName?: string;

  // Call expressions banned inside ViewModel.
  // default also denies afterNextRender / afterEveryRender / afterRenderEffect
  bannedApis?: string[];
}
'@rdlabo/rules/require-viewmodel': 'error';

カスタム名:

[
  'error',
  {
    viewModelClassName: 'PageState',
    viewModelStoreClassName: 'HostedStore',
  },
];

実装