【Vue】Element-UIでフォーム全体のバリデーションを行う【TypeScript】

これまたすんなり行かなかったので共有しとく

#その1 - Elementフォームのvalidateメソッドを参照したい
フォーム全体のバリデーションを行う方法については、Element-UI公式で[Custom validation rules](https://element.eleme.io/#/en-US/component/form#custom-validation-rules)のサンプルコードを丹念に読めば大体のところが把握できる。
が、TypeScriptでそのまま書こうとするとVSCodeに怒られてしまう。

```typescript:サンプルコード(抜粋)
this.$refs[formName].validate(callback);
```

```text:VSCodeでのエラーメッセージ
Property 'validate' does not exist on type 'Vue | Element | Vue[] | Element[]'.
  Property 'validate' does not exist on type 'Vue'.Vetur(2339)
```
`$refs`の定義を確認すると

```typescripe:node_modules/vue/types/vue.d.ts(抜粋)
export interface Vue {
  readonly $refs: { [key: string]: Vue | Element | Vue[] | Element[] };
}
```
となっているが、実際に`$refs`下に登録されているのは`ElementUIComponent`経由で`Vue`を継承した`ElForm`であり、そこに定義されている`validate`メソッドは`Vue`からは参照できない。

#その1の解決法 - $refsを宣言し直す
以下、Vue.js公式の[TypeScript Support](https://vuejs.org/v2/guide/typescript.html)にある[Class-Style Vue Components](https://vuejs.org/v2/guide/typescript.html#Class-Style-Vue-Components)を前提に進める。

まず`ElForm`の定義を取り込む。

```typescript:任意のVueファイル(抜粋)
<script lang="ts">

import { ElForm } from 'element-ui/types/form';
```
次に、インスタンスプロパティ`$refs`について下記のように宣言。

```typescript:任意のVueファイル(抜粋)
export default class MyComponent extends Vue {
  /* data */
  public $refs!: { [key: string]: ElForm };
```
定義を被せる都合上、`$refs`プロパティは`Vue`に合わせて`public`で宣言する必要がある。TypeScriptでは、`public修飾子`(デフォルト)は省略可だが、筆者の趣味で明示的に記述しておいた。

プロパティ名の後ろに付く`!`は[Definite Assignment Assertions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-7.html#definite-assignment-assertions)というモノ。
本来であれば宣言時もしくはコンストラクタ内で初期化する必要のあるインスタンスプロパティが、コレを付けると「既に代入済みであり初期化不要」という扱いになる。このように記述した場合、当該のプロパティが`$data`オブジェクトに取り込まれることもない。

`$refs`へ登録されるフォーム名は`el-form`タグの`ref`属性で指定するが、名称が固定であれば以下のように宣言しても構わない。

```vue:el-formタグ
<el-form ref="myForm" :rules="myRules" :model="myModel">
```

```typescript:フォーム名が固定の場合
export default class MyComponent extends Vue {
  /* data */
  public $refs!: { myForm: ElForm };
```
以上で、サンプルコードと同じような記述が可能となる。

```typescript:記述例
this.$refs[formName].validate(callback);
/* あるいは */
this.$refs.myForm.validate(callback);
```

#その2 - バリデーション結果を画面に反映させたい
フォーム全体のバリデーション結果を、リアルタイムで画面上（ボタンの使用可否など）に反映させたい、というような欲求はあるかと思う。
フォームの`validate`メソッドが、テンプレート内から直に呼び出すだけで結果(boolean)を取得できるような作りなら話は簡単だが、残念ながらそうなってはおらず上手く行かない。

```typescript:node_modules/element-ui/types/form.d.ts(抜粋)
  /**
   * Validate the whole form
   *
   * @param callback A callback to tell the validation result
   */
  validate (callback: ValidateCallback): void
  validate (): Promise<boolean>
```
コールバックを渡して呼び出せば同期的にバリデーション結果を用いた処理を実行できるが、戻り値はvoidに限定される。
引数無しで呼び出せばバリデーション結果(boolean)を載せたPromiseを返すが、いかんせん非同期。
したがってどちらも使えない。

#その2の解決法 - ライフサイクルフックを使う
テンプレートからメソッドを直接呼び出しても結果が取得できないとなると、残る手段はインスタンスプロパティを介して参照する以外に無い。そのためには[ライフサイクルフック](https://vuejs.org/v2/guide/instance.html#Instance-Lifecycle-Hooks)を利用することになる。

`$data`オブジェクト下のデータがいずれかの入力フィールドに紐づいている場合、そのデータに変更が加わると紐付く入力フィールドで個別のバリデーションが実行される。しかる後に、そのバリデーション結果も踏まえたバーチャルDOMの再描画・書き替えが行われるが、その前後で動作するライフサイクルフックが`beforeUpdate`と`updated`である。今回は`beforeUpdate`を使用するのが適切だろう。

`validate`メソッドについては同期・非同期どちらを用いても良いが、前者の方がテストを書くのも楽だし、もし必要なら入力フィールド毎のエラー詳細も利用できるので、今回はそちらを採用する。

まず`validate`メソッドに渡すコールバック`ValidateCallback`の型定義を取り込む。

```typescript:任意のVueファイル(抜粋)
<script lang="ts">

import { ElForm, ValidateCallback } from 'element-ui/types/form';
```

```typescript:node_modules\element-ui\types\form.d.ts(抜粋)
export interface ValidateCallback {
  /**
   * The callback to tell the validation result
   *
   * @param isValid Whether the form is valid
   * @param invalidFields fields that fail validation
   */
  (isValid: boolean, invalidFields: object): void
}
```
次に、バリデーションの結果を受け取るインスタンスプロパティを用意する。
初期化については入力フィールドのデフォルト値に合わせて決め打ちでも良いが、遷移で諸々入力済みの状態から開始するような場合はバリデーション未実施の状態を区別するために nullable な型(`boolean | null`)で宣言した上`null`で初期化する手もある。

```typescript:初期化決め打ち
export default class MyComponent extends Vue {
  /* data */
  public $refs!: { [key: string]: ElForm };
  private validationResult: boolean = false;
```

```typescript:nullで初期化
export default class MyComponent extends Vue {
  /* data */
  public $refs!: { [key: string]: ElForm };
  private validationResult: boolean | null = null;
```
最後に、クラス直下へ`beforeUpdate`メソッドを追加し、その中でフォームの`validate`メソッドを実行する。
引数としては、バリデーションの結果をインスタンスプロパティへとセットするコールバックを用意する。

```typescript:任意のVueファイル(抜粋)
  private beforeUpdate() {
    this.updateValidationResult();
  }

  private updateValidationResult() {
    const callback: ValidateCallback = (isValid, invalidFields) => {
      this.validationResult = isValid;
    };

    this.$refs.myForm.validate(callback);
  }
```
以上、分かってしまえば超簡単。

ライフサイクルフックを検証するテストの書き方については別記事で書いた。
[【Vue】ライフサイクルフックのテストを書く【TypeScript】](https://qiita.com/Nyagoking/items/e8cd56ab89d9ce136d9c)