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