Edited at

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

More than 1 year has passed since last update.


概要

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

せっかちな方は以下のVeeValidate公式ドキュメントを読むのが早いです。

http://vee-validate.logaretm.com/examples.html#event-bus-example


注意

※2017/11/13追記

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

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

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

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

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


サンプルコード

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


  • 必須項目(会員情報)


    • 氏名

    • メールアドレス

    • 電話番号

    • 住所



  • 選択項目


    • 配送先住所(会員の住所と、配送先が異なる場合のみ使用)



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

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

このサンプルコードの基本的な構成は、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のイベントを活用することで、入れ子のフォームでもバリデーションを機能させることができます。