JavaScript
vue.js
VeeValidate

[Vue.js] VeeValidateで入れ子のフォームを作る

概要

VeeValidateで、入れ子のフォームを作る方法を解説します。

せっかちな方は以下のVeeValidate公式ドキュメントを読むのが早いです。
http://vee-validate.logaretm.com/examples.html#event-bus-example

:warning: 注意 :warning:

※2017/11/13追記

下記サンプルコードは、子コンポーネントのフィールドに正常な値を入力しても、即時にバリデーションエラーが解消されないという問題があります。

EventBusパターンによる解決を試みましたが、なかなか難しいです。

ややdirty hackなきらいはありますが、 inject を使って親の $validator を子コンポーネントに注入すれば、本記事で提示している問題は解消されます。

参考: https://github.com/baianat/vee-validate/issues/631

この場合、MemberFormとOptionFormにそれぞれ inject: ['$validator'], を追加するだけです。

サンプルコード

あなたが通販サイトを作っていて、そのECサイトの購入フォームでは、以下の仕様に基づいて入力を要求するとします。

  • 必須項目(会員情報)
    • 氏名
    • メールアドレス
    • 電話番号
    • 住所
  • 選択項目
    • 配送先住所(会員の住所と、配送先が異なる場合のみ使用)

Screen Shot 2017-11-12 at 0.18.24.png

「Ship to different address」にチェックを入れると、別の住所の入力欄が表示されるようになっています。

未入力の状態で送信ボタンを押すと、バリデーションエラーが表示されます。

Screen Shot 2017-11-12 at 0.39.45.png

このサンプルコードの基本的な構成は、vue-clivue init webpackしたものです。また、本記事で紹介したコードはGitHubでも公開しています。

また、バリデーションのためにvee-validateを導入しています。

EntryFormコンポーネントのコードは以下のようになっています。

<template>
  <form action="">
    <div>
      <label for="name">Name</label>
      <input
        type="text"
        name="name"
        id="name"
        v-model="name"
        v-validate="'required'"
      >
    </div>
    <div>
      <label for="email">Email</label>
      <input
        type="email"
        name="email"
        id="email"
        v-model="email"
        v-validate="'required|email'"
      >
    </div>
    <div>
      <label for="tel">Tel</label>
      <input
        type="tel"
        name="tel"
        id="tel"
        v-model="tel"
        v-validate="'required'"
      >
    </div>
    <div>
      <label for="address">Address</label>
      <input
        type="text"
        name="address"
        id="address"
        v-model="address"
        v-validate="'required'"
      >
    </div>
    <div>
      <label for="shipToDifferentAddress">Ship to different address</label>
      <input
        type="checkbox"
        name="shipToDifferentAddress"
        v-model="shipToDifferentAddress"
        id="shipToDifferentAddress"
      >
    </div>
    <div v-show="shipToDifferentAddress">
      <label for="shippingAddress">Shipping Address</label>
      <input type="text"
             name="shippingAddress"
             id="shippingAddress"
             v-model="shippingAddress"
             v-validate="'required'"
      >
    </div>
    <div v-show="errors.any()">
      <p v-for="error in errors.collect()">{{ error }}</p>
    </div>
    <input type="submit" value="submit" @click.prevent="doValidation">
  </form>
</template>
<script>
  /* eslint-disable no-console */

  export default {
    name: 'entry-form',
    data() {
      return {
        name: '',
        email: '',
        tel: '',
        address: '',
        shipToDifferentAddress: false,
        shippingAddress: '',
      };
    },
    methods: {
      doValidation() {
        this.$validator.validateAll();
        if (this.errors.any()) {
          console.log('cancel submission');
        } else {
          console.log('do submission');
        }
      },
    },
  };
</script>

ここでは、簡略化のためにスタイルの制御などを省いていますが、実際のアプリケーションではさらに複雑な表現が必要とされることもあるでしょう。その場合、コードは肥大化していき、保守性は低下していきます。

リファクタリングと、それによって生じる問題

保守性向上のため、入力フォームを会員情報と配送オプションに分割してみましょう。

<template>
  <form action="">
    <member-form :member="member"></member-form>
    <option-form :option="option"></option-form>
    <div v-show="errors.any()">
      <p v-for="error in errors.collect()">{{ error }}</p>
    </div>
    <input type="submit" value="submit" @click.prevent="submit">
  </form>
</template>
<script>
  /* eslint-disable no-console */
  import MemberForm from './MemberForm';
  import OptionForm from './OptionForm';

  export default {
    name: 'entry-form',
    components: {
      MemberForm,
      OptionForm,
    },
    data() {
      return {
        member: {
          name: '',
          email: '',
          tel: '',
          address: '',
        },
        option: {
          shipToDifferentAddress: false,
          shippingAddress: '',
        },
      };
    },
    methods: {
      submit() {
        this.$validator.validateAll();
        if (this.errors.any()) {
          console.log('cancel submission');
        } else {
          console.log('do submission');
        }
      },
    },
  };
</script>
<template>
  <div>
    <div>
      <label for="name">Name</label>
      <input
        type="text"
        name="name"
        id="name"
        v-model="member.name"
        v-validate="'required'"
      >
    </div>
    <div>
      <label for="email">Email</label>
      <input
        type="email"
        name="email"
        id="email"
        v-model="member.email"
        v-validate="'required|email'"
      >
    </div>
    <div>
      <label for="tel">Tel</label>
      <input
        type="tel"
        name="tel"
        id="tel"
        v-model="member.tel"
        v-validate="'required'"
      >
    </div>
    <div>
      <label for="address">Address</label>
      <input
        type="text"
        name="address"
        id="address"
        v-model="member.address"
        v-validate="'required'"
      >
    </div>
  </div>
</template>
<script>
  export default {
    name: 'member-form',
    props: [
      'member',
    ],
  };
</script>
<template>
  <div>
    <div>
      <label for="shipToDifferentAddress">Ship to different address</label>
      <input
        type="checkbox"
        name="shipToDifferentAddress"
        v-model="option.shipToDifferentAddress"
        id="shipToDifferentAddress"
      >
    </div>
    <div v-show="option.shipToDifferentAddress">
      <label for="shippingAddress">Shipping Address</label>
      <input
        type="text"
        name="shippingAddress"
        id="shippingAddress"
        v-model="option.shippingAddress"
        v-validate="'required'"
      >
    </div>
  </div>
</template>
<script>
  export default {
    name: 'option-form',
    props: [
      'option',
    ],
  };
</script>

コード量は増えましたが、コンポーネントが細かく分かれて見通しは良くなったと思います。しかし、このフォームには、バリデーションが動作しないという重大な問題があります!

VeeValidateのバリデーションはコンポーネント単位で実行されます。そのため、送信前の一括バリデーションのような処理を実装しようとすると、子コンポーネントのバリデータを何らかの手段で実行する必要があります。

EventBusパターン

Vue.jsがコンポーネント間通信の方法として用意しているのが、イベントシステムです。これを活用することで、コンポーネントをまたがったバリデーションを実装することができます。この手法は、冒頭で記述したVeeValidateのドキュメントにも紹介されています。
http://vee-validate.logaretm.com/examples.html#event-bus-example

まず、イベントを伝達するバス(EventBus)として、Vueインスタンスを用意します。

// EventBus.js
import Vue from 'vue';

export default new Vue();

次に、このイベントバスにvalidateイベントを発生させるコードを、バリデーションの呼び出し側に追加します。

diff --git a/src/components/EntryForm.vue b/src/components/EntryForm.vue
index db33dda..0dec669 100644
--- a/src/components/EntryForm.vue
+++ b/src/components/EntryForm.vue
@@ -10,6 +10,7 @@
 </template>
 <script>
   /* eslint-disable no-console */
+  import EventBus from '../EventBus';
   import MemberForm from './MemberForm';
   import OptionForm from './OptionForm';

@@ -35,7 +36,10 @@
     },
     methods: {
       submit() {
+        this.errors.clear();
         this.$validator.validateAll();
+        EventBus.$emit('validate', this.errors);
+
         if (this.errors.any()) {
           console.log('cancel submission');
         } else {

最後に、validateイベントに反応するコードを実装します。こちらは、コードの重複を避けるためmixinで実装すると良いでしょう。

// mixins/NestedValidator.js
import EventBus from '../EventBus';

export default {
  mounted() {
    EventBus.$on('validate', (errorBag) => {
      this.$validator.validateAll();
      this.errors.items.forEach((error) => {
        errorBag.add(error);
      });
    });
  },
};
diff --git a/src/components/MemberForm.vue b/src/components/MemberForm.vue
index 392f9e2..fed96a5 100644
--- a/src/components/MemberForm.vue
+++ b/src/components/MemberForm.vue
@@ -43,8 +43,11 @@
   </div>
 </template>
 <script>
+  import NestedValidator from '../mixins/NestedValidator';
+
   export default {
     name: 'member-form',
+    mixins: [NestedValidator],
     props: [
       'member',
     ],
diff --git a/src/components/OptionForm.vue b/src/components/OptionForm.vue
index da440a7..9f530d5 100644
--- a/src/components/OptionForm.vue
+++ b/src/components/OptionForm.vue
@@ -9,7 +9,7 @@
         id="shipToDifferentAddress"
       >
     </div>
-    <div v-show="option.shipToDifferentAddress">
+    <div v-if="option.shipToDifferentAddress">
       <label for="shippingAddress">Shipping Address</label>
       <input
         type="text"
@@ -22,8 +22,11 @@
   </div>
 </template>
 <script>
+  import NestedValidator from '../mixins/NestedValidator';
+
   export default {
     name: 'option-form',
+    mixins: [NestedValidator],
     props: [
       'option',
     ],

これで、従来と同様、バリデーションエラーが表示されるようになります!

別の設計方針

ここまで、親コンポーネントのバリデーション実行に合わせて、子コンポーネントのバリデーションを実行する、という設計で実装しました。

より柔軟な設計としては、以下のような方針も考えられます。

  • 親コンポーネントは、1つ以上のバリデータを持つ
  • 子コンポーネントは、任意のタイミングで自身のバリデータを親コンポーネントのバリデータに追加・削除できる

この方針の場合、以下のような実装になります。

// EntryForm.vue
    // バリデータのコレクションを用意し、バリデータの追加・削除イベントに対応するイベントハンドラを定義
    created() {
      this.validators = [this.$validator];
      EventBus.$on('add-validator', (validator) => {
        const index = this.validators.findIndex(v => v === validator);
        if (index === -1) {
          this.validators.push(validator);
        }
      });
      EventBus.$on('remove-validator', (validator) => {
        const index = this.validators.findIndex(v => v === validator);
        if (index !== -1) {
          this.validators.splice(index, 1);
        }
      });
    },

    methods: {
      // 全てのバリデーションを実行するメソッド
      validateAll() {
        this.errors.clear();
        this.validators.forEach((validator) => {
          validator.validateAll();
          validator.errors.items.forEach((error) => {
            this.errors.add(error);
          });
        });
      },
// mixins/NestedValidator
  created() {
    // EntryForm.created() より後に実行するために、$nextTick()で1テンポ遅らせる
    this.$nextTick(() => {
      EventBus.$emit('add-validator', this.$validator);
    });
  },
  // v-if でコンポーネントが消去されたときにバリデータを取り除く
  beforeDestroy() {
    EventBus.$emit('remove-validator', this.$validator);
  },

VeeValidate自身は入れ子のフォームに対応する機能を持ちませんが、Vueのイベントを活用することで、入れ子のフォームでもバリデーションを機能させることができます。