Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

概要

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のイベントを活用することで、入れ子のフォームでもバリデーションを機能させることができます。

ikyu
「こころに贅沢を」をコンセプトに一休.com、一休レストランなどのサービスを提供しています。
https://www.ikyu.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした