狛ログ

2022年8月5日金曜日

【Angular】コンポーネントの設計(画面ごとの設計)について。

オフィス狛 技術部のKoma(Twitterアカウントの中の人&CEO)です。

前回、「機能内の画面構成(コンポーネント構成)設計」について記載したので、今回は、「画面ごとのコンポーネント設計」を記載しようと思います。

それでは、前回の続きとして、『アカウント登録処理』から「登録情報入力画面」について、コンポーネントの設計を説明しようと思います。

(1)前回のおさらい

「アカウント登録処理」として、以下のような画面が必要と想定されます。

①登録情報入力画面
②入力情報確認画面
③登録完了画面

画面遷移としては以下になります。

そして、ディレクトリ構成は以下となります。(一部省略)
src/
└ app/
    └ account/
        └ containers/
            ├ account/
            ├ account-register
            │  └ account-register.component.html
            │  └ account-register.component.spec.ts
            │  └ account-register.component.ts
            ├ account-register-confirm
            │  └ ・・・
            └ account-register-complete
                 └ ・・・

図で表すと、以下のようになります。

(2)画面単位でのディレクトリ構成を考える

画面単位で考えた時、「登録情報入力画面」にはどんな機能が含まれるでしょうか?

  • 画面の表示
  • 入力項目のチェック(バリデーション、業務的な関連チェックなど)
  • 入力項目を次の画面へ持ち越す為の準備
  • 確認画面から戻って来た場合は、入力した値を再現

などなど、色々ありますね。
もっと複雑な機能であれば、「遷移先を入力値によって分岐させる」や、「APIを呼び出し、入力用の補足情報を取得する」・・・・なんて事もあるかもしれません。

このような多種多様な処理を以下の「containers」配下のコンポーネントだけでやるとしたら、コンポーネントが肥大化してしまうと思います。
src/
└ app/
    └ account/
        └ containers/
            └ account-register
                  └ account-register.component.ts ← 肥大化する

そこで、「画面の表示」「入力項目のチェック」など、View(ビュー)側に属する処理については、「presentations」ディレクトリに分けることにします。

と言うことで「presentations」ディレクトリとファイルを追加します。
src/
└ app/
    └ account/
        └ containers/
        └ presentations/
              └ register/
                   └ register.component.html
                   └ register.component.spec.ts
                   └ register.component.ts
                   └ register.validator.ts

※「 register.validator.ts」について
弊社のプロジェクトでは、Validationは出来る限りViewとコンポーネントからは切り離して別ファイルで管理するようにしています。(Validationについては、以前の記事「【Angular】FormのValidationの書き方を簡略化させる。」を参照ください。)

(3)「Container」と「Presentation」の切り分け方

「Container」と「Presentation」の切り分けですが、弊社では以下のように定義付けしています。

「Container」として分類するもの
  • 画画面の状態保持に関すること(NgRxのStore操作など)
  • 画面に入力した値の業務チェック(API呼び出しが必要なもの、など)
  • API(バックエンド処理)の呼び出し
  • 画面遷移

「Presentation」として分類するもの
  • 画面表示、及び表示内容の制御(エラー時など)
  • 画面に入力した値のバリデーションチェック
  • 画面に入力した値の関連チェック、及び業務チェック

「関連チェック」というのは、例えば「AとBが選択されているときは、Cは必須となる」とか、です。
両方に含まれている「業務チェック」が、判断難しいところかな、と思います。
クライアント内で完結出来るチェックであれば「Presentation」。完結出来ないものは「Container」、という考えで良いと思います。

アカウント登録で言うと、「入力されたメールアドレスが既に使用されているかどうか」のチェックは、クライアント内では完結出来ず、バックエンドの処理(API等)を呼び出す必要があると思いますので、「Container」側に記載する必要があります。

さて、今の状態を図で表すと、以下のようになります。(登録情報入力画面のみ)

(4)「Container」と「Presentation」間のデータ・処理受け渡し方法を考える

ここで、分かりやすいように、「Container」を、「Presentation」をとして、話を進めようと思います。
親と子の間では、データ・処理のやり取りが必要になります。

コンポネート間のデータ・処理のやり取りについては、いくつか方法がありますが、
今回は、「@Input()、@Output()」を使います。

参考サイト(本家):ディレクティブとコンポーネントの親子間でのデータ共有

(5)親子間でやり取りが必要なデータ・処理を考える

方式が決まったところで、親子間で、どんなデータ・処理のやり取りが必要か考えます。
登録情報入力画面の内部処理は、以下の流れになると思います。

1. 画面初期表示
 ※確認画面から戻ってきた場合は、画面の入力値を復元
  →親から子へ、復元用のデータを送る(@Input())
2. 画面の各フィールドへの値入力(バリデーション)
3. Submitによって、入力値を引き継ぎつつ、確認画面へ遷移
 →入力値を引数に、子から親へ処理を引き継ぐ(@Output())

上記を実現したいのですが、ここで、
  • 親子間でのデータ
  • 次画面(確認画面)に送るデータ
両方を兼ね備えたデータモデルを作成しようと思います。
src/
└ app/
    └ account/
        └ containers/
        └ presentations/
        └ models/
              └ account.ts

中身は下記のようにします。
// アカウント登録用画面間保持データモデル
export class AccountViewSaveModel {
  mobilePhoneNumber: string;
  name: string;
}
とりあえず、電話番号と名前を項目として用意しました。この辺はあくまで例なので適当です。

続いて、親と子のコンポーネントの中身を実装します。

(6)親子間のデータ・処理のやり取りを実装する

まずは親コンポーネントの実装です。
[account-register.component.ts]
import { Component, OnInit } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Router } from '@angular/router';
import { AccountViewSaveModel } from '../../models/account';
import * as fromAccount from '../../store/reducers';
import * as AccountActions from '../../store/actions/account.actions';

@Component({
  selector: 'koma-account-register',
  templateUrl: './account-register.component.html',
})
export class AccountRegisterComponent implements OnInit {

  // Storeから入力情報を取得する(確認画面から戻ってきた場合には値が入っている)
  // 取得値の型はObservableになるので、変数の末尾に「$」を付ける(お約束みたいなもの)
  accountViewSave$ = this.store.pipe(select(fromAccount.getDataRegisterPost));

  constructor(public store: Store<fromAccount.State>, private router: Router) {}

  ngOnInit(): void {}

  onSubmit(formModel: AccountViewSaveModel): void {
    // 入力情報をStoreに格納して(=引き継ぎ情報として)、遷移後の画面でも使えるようにする。
    this.store.dispatch(
      AccountActions.setRegisterPostData({ data: formModel }),
    );
    // 確認画面へ遷移する
    this.router.navigateByUrl('/account/register-confirm');
  }
}

唐突に「NgRxのStore」が出てきましたが、ここは一旦スルーしてください。(いずれ、NgRxの実装方法も記事にしようと思います。)
ここでは、画面で入力された値はStore(と言う場所)に格納されている、ぐらいの理解でOKです。
(今回重要なのは、「データ保存方式」ではなく、あくまで「データ受け渡し部分」なので)

細かい説明も実装内のコメントとして記載しましたので、参考にお願いします。

続いて、子コンポーネントの実装です。
[register.component.ts]
import { Component, OnInit, Input, Output, OnDestroy, EventEmitter } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable, Subscription } from 'rxjs';
import { RegisterValidator } from './register.validator';
import { AccountViewSaveModel } from '../../models/account';
import { getMessage } from '@app/shared/message/error-messages';

@Component({
  selector: 'koma-register',
  templateUrl: './register.component.html',
})
export class RegisterComponent implements OnInit, OnDestroy {
  // Input・Outputの定義。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formSubmit = new EventEmitter<AccountViewSaveModel>();

  mobilePhoneNumberMaxLength = 11;
  nameMaxLength = 20;

  registerSubscription: Subscription = new Subscription();

  formRegister: FormGroup = this.formBuilder.group({
    mobilePhoneNumber: ['',[Validators.required,Validators.maxLength(this.mobilePhoneNumberMaxLength)]],
    name: ['', [Validators.maxLength(this.nameMaxLength)]],
  });

  constructor(private formBuilder: FormBuilder, public v: RegisterValidator) {}

  ngOnInit(): void {
    // バリデーション設定
    this.v.formGroup = this.formRegister;

    this.registerSubscription.add(
      // 画面初期値設定
      this.accountViewSave$.subscribe(value => {
        if (value) {
          this.formRegister.controls.mobilePhoneNumber.setValue(
            value.mobilePhoneNumber,
          );
          this.formRegister.controls.name.setValue(value.name);
        }
      }),
    );
  }

  ngOnDestroy(): void {
    // サブスクリプション解除
    this.registerSubscription.unsubscribe();
  }

  // 画面でSubmitが発生した時の処理
  onSubmit(): void {
    if (this.formRegister.valid) {
      // バリデーションエラーが発生していない場合
      const formModel = {
        mobilePhoneNumber: this.formRegister.controls.mobilePhoneNumber.value,
        name: this.formRegister.controls.name.value,
      } as AccountViewSaveModel;

      this.formSubmit.emit(formModel);
    }
  }

  message(messageId: string, ...args: any[]): string {
    return getMessage(messageId, ...args);
  }
}
ちょっと記載量が多いのですが、あくまで「親子間のデータやり取り」部分についてのみ説明します。
その他、FormBuilder, FormGroup, Validatorsあたりは、以前の記事などを参照ください。

参照:FormのValidationの書き方を簡略化させる

まずはInput・Outputの定義です。
  @Input() accountViewSave$: Observable<AccountViewSaveModel>;
  @Output() formSubmit = new EventEmitter<AccountViewSaveModel>();
Inputは、親からデータが来る為の受け口になるので、分かりやすく同じ変数名にします。
また、型も同じ「Observable」にします。
Outputについては、「処理を引き継ぐ」為のお決まりの「EventEmitter」を使います。

続いて、ngOnInitで、親からのデータをFormに反映する処理をSubscribeします。
      // 画面初期値設定
      this.accountViewSave$.subscribe(value => {
        if (value) {
          this.formRegister.controls.mobilePhoneNumber.setValue(
            value.mobilePhoneNumber,
          );
          this.formRegister.controls.name.setValue(value.name);
        }
      }),

Subscribeする事で、親からのデータが来れば、常にアクティブに画面表示が更新されることになります。

最後が、画面でSubmitが発生した場合です。
  // 画面でSubmitが発生した時の処理
  onSubmit(): void {
    if (this.formRegister.valid) {
      // バリデーションエラーが発生していない場合
      const formModel = {
        mobilePhoneNumber: this.formRegister.controls.mobilePhoneNumber.value,
        name: this.formRegister.controls.name.value,
      } as AccountViewSaveModel;

      this.formSubmit.emit(formModel);
    }
  }

バリデーションエラーが発生していない場合、画面で入力された値を Model にセットし、その Model を引数に Emit します。 Emitする事で、親側に処理を引数ごと引き渡すことになります。

続いて、親のテンプレート(View)を実装します。
[account-register.component.html]
<koma-register
  [accountViewSave$]="accountViewSave$"
  (formSubmit)="onSubmit($event)"
></koma-register>
左辺が子の変数(処理)、右辺が親の変数(処理)となります。
ここで、親子を結び付けています。

最後に、子のテンプレート(View)を実装します。
[register.component.html](form部分のみ抜粋)
<form [formGroup]="formRegister" (ngSubmit)="onSubmit()" (keydown.enter)="$event.preventDefault()">
  <div class="form-input">
    <div>
      <label>携帯電話番号<span>必須</span></label>
      <input formControlName="mobilePhoneNumber" type="tel" inputmode="numeric" class="form-control" placeholder="携帯電話番号を入力"
        [ngClass]="{'alert-danger' : v.mobilePhoneNumberInvalid}" autofocus>
      <ng-container *ngIf="v.mobilePhoneNumberInvalid">
        <p *ngIf="v.mobilePhoneNumberHasErrorRequired" class="error-message">{{ message('msg_error_field_required', '携帯電話番号') }}</p>
        <p *ngIf="v.mobilePhoneNumberHasErrorMaxLength" class="error-message">{{ message('msg_error_field_max', '携帯電話番号', mobilePhoneNumberMaxLength) }}</p>
      </ng-container>
    </div>
    <div>
      <label>お名前</label>
      <input formControlName="name" type="text" class="form-control" placeholder="お名前を入力(任意)"
        [ngClass]="{'alert-danger' : v.nameInvalid}">
      <ng-container *ngIf="v.nameInvalid">
        <p class="error-message">{{ message('msg_error_field_max', 'お名前', nameMaxLength)}}</p>
      </ng-container>
    </div>
  </div>
  <footer>
    <button [disabled]="formRegister.invalid" type="submit">確認</button>
  </footer>
</form>
こちらに関しては、特に「親子間のデータやり取り」について意識している部分は無いですね。

これで、

1. 画面初期表示
 ※確認画面から戻ってきた場合は、画面の入力値を復元
  →親から子へ、復元用のデータを送る(@Input())
2. 画面の各フィールドへの値入力(バリデーション)
3. Submitによって、入力値を引き継ぎつつ、確認画面へ遷移
 →入力値を引数に、子から親へ処理を引き継ぐ(@Output())

が実現出来たことになります。(長かった・・・・・)
最終的に図で表すと下記のようになります。

今回は、コンポーネント設計(と実装)を紹介しました。
もっと複雑な画面になると、設計方法や実装も違ってきますので、それはまた別途記事にしようと思います。


0 件のコメント:

コメントを投稿