1. ryo511

    Posted

    ryo511
Changes in title
+[Vue.js] VeeValidateで入れ子のフォームを作る
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,449 @@
+## 概要
+
+VeeValidateで、入れ子のフォームを作る方法を解説します。
+
+せっかちな方は以下のVeeValidate公式ドキュメントを読むのが早いです。
+http://vee-validate.logaretm.com/examples.html#event-bus-example
+
+## サンプルコード
+
+あなたが通販サイトを作っていて、そのECサイトの購入フォームでは、以下の仕様に基づいて入力を要求するとします。
+
+- 必須項目(会員情報)
+ - 氏名
+ - メールアドレス
+ - 電話番号
+ - 住所
+- 選択項目
+ - 配送先住所(会員の住所と、配送先が異なる場合のみ使用)
+
+このようなフォームを、Vue.jsの単一ファイルコンポーネントを使って作成すると、以下のようになります。
+
+※プロジェクトの基本的な構成は[vue-cli](https://github.com/vuejs/vue-cli)を使って、`vue init webpack`しています。また、本記事で紹介したコードは[GitHub](https://github.com/ryo-utsunomiya/vee-validate-nested-form)でも公開しています。
+
+<img width="252" alt="Screen Shot 2017-11-12 at 0.18.24.png" src="https://qiita-image-store.s3.amazonaws.com/0/33450/4efdd6e2-f568-1399-a13f-9b63b8039c0d.png">
+
+「Ship to different address」にチェックを入れると、別の住所の入力欄が表示されるようになっています。
+
+未入力の状態で送信ボタンを押すと、バリデーションエラーが表示されます。
+
+<img width="369" alt="Screen Shot 2017-11-12 at 0.39.45.png" src="https://qiita-image-store.s3.amazonaws.com/0/33450/f91c1d60-d6d2-dfaf-5d24-addef1e3a984.png">
+
+
+ここでは、バリデーションのために[vee-validate](https://github.com/baianat/vee-validate)を導入しています。
+
+EntryFormコンポーネントのコードは以下のようになっています。
+
+```html
+<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>
+```
+
+ここでは、簡略化のためにスタイルの制御などを省いていますが、実際のアプリケーションではさらに複雑な表現が必要とされることもあるでしょう。その場合、コードは肥大化していきます。
+
+### リファクタリングと、それによって生じる問題
+
+入力フォームを会員情報と配送オプションに分割してみます。
+
+```html
+<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>
+```
+
+```html
+<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>
+```
+
+```html
+<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インスタンスを用意します。
+
+```js
+// EventBus.js
+import Vue from 'vue';
+
+export default new Vue();
+```
+
+次に、このイベントバスに`validate`イベントを発生させるコードを、バリデーションの呼び出し側に追加します。
+
+```diff
+diff --git a/src/components/EntryForm.vue b/src/components/EntryForm.vue
+index db33dda..0dec669 100644
+--- a/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](https://jp.vuejs.org/v2/guide/mixins.html)で実装すると良いでしょう。
+
+```js
+// 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
+diff --git a/src/components/MemberForm.vue b/src/components/MemberForm.vue
+index 392f9e2..fed96a5 100644
+--- a/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
+diff --git a/src/components/OptionForm.vue b/src/components/OptionForm.vue
+index da440a7..9f530d5 100644
+--- a/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つ以上のバリデータを持つ
+- 子コンポーネントは、任意のタイミングで自身のバリデータを親コンポーネントのバリデータに追加・削除できる
+
+この方針の場合、以下のような実装になります。
+
+```js
+// 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);
+ });
+ });
+ },
+```
+
+```js
+// 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のイベントを活用することで、入れ子のフォームでもバリデーションを機能させることができます。