経験2年のフロントエンジニアがVue.jsで本気でフォームバリデーションを作ってみた

###前置き
※備忘録です

フォームのバリデーションって要件とかいろいろあったり、
何かとそれぞれのアプリ固有の動きがあったりして
プラグインとか使っても結局実現できない部分を自分で実装しなきゃいけないことがあったりしてめんどくさいと思っています。
個人的には何でも自分で作っちゃえ派なので、バリデーションなんかは特に作りたくなります。

自作機能の何がいいって、外部のプラグインでよくある、
**「なんだこのエラー、知らん」**
**「この関数使えるはずでは？なぜ？」**
ってならないところ。
**「あ、これバグっとる、直さな」**
っていう責任感が生まれてくるので、
コストはかかるかもしれないですが、**確実に実現させられるものを作れるところ**と、**コードの理解を深められる**
ことですね。

今回は3時間ぐらいで作った、**汎用性重視**のVue.jsで実装したバリデーションを紹介したいと思います。
一例なので、参考までに見ていただければ幸いです。

##作った人のステータス
ITの経験は投稿時点で2年4カ月、
お仕事でWebアプリ作る事がメインで、
Vue、React、jQueryをかじった程度。
プライベートでiOSの開発してみたいがために
ReactNativeをちょろちょろ触る程度。休日も家でガシガシやってるタイプではないです。
HTMLとCSSだったら延々といじってられる、~~というか本当はそれだけいじっていたい。~~

![FireShot Capture 008 - validation - localhost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469924/1900082c-6fbd-468e-8933-1fb17e201e01.png)

今回のブツです。

ありそうな項目をテキトーにもってきました。
とりあえず、inputのtext、radioとselectがあればとりあえずよかったのです。

今回の機能要件：
**1.登録のボタン押したときにバリデーションが走る**
**2.バリデーションは以下のような機能：**
**・必須**
**・文字数**
**・形式**
**上記3項目のチェックを行って、ひっかかった条件のメッセージを出します。**

こんな感じ。
![FireShot Capture 009 - validation - localhost.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/469924/ea920e55-7b1c-5d82-ae09-f65362e0860d.png)

###やってみる
ひとまず画面側のソースはこちら。

```vuejs:SubmitUser.vue
<template>
  <div class="page">
    <h3>ユーザー登録画面ダヨ</h3>
    <SubmitUserForm
      v-bind:formValues=formValues
      :validationStatus=validationStatus
      :validationMessage=validationMessage
    >
    </SubmitUserForm>
    <div class="footer">
      <button v-on:click="submit">登録</button>
    </div>
  </div>
</template>

<script>
import SubmitUserForm from '../components/SubmitUserForm.vue';

import validationMethod from '../utilities/validation/validationMethod';

export default {
  components: {
    SubmitUserForm,
    validation
  },
  data () {
    return {
      formValues: { //扱う値
        name: null,
        age: null,
        gender: null, // 0:男, 1:女
        prefecture: null
      },
      validation: { //バリデーションの条件を詰め込んだもの
        name: {
          name: '名前', //メッセージ出力時に使う文字列
          required: true, //必須かどうか
          length:[1, 32], //何文字以上何文字以下の指定
        },
        age: {
          name: '年齢',
          required: true,
          length:[1, 4],
          format: ['Number'],
        },
        gender: {
          name: '性別',
          required: false,
          format: ['Number'],
        },
        prefecture: {
          name: '都道府県',
          required: false,
          format: ['Number'],
        },
      },
      validationStatus: true, //バリデーションが正常かどうか
      validationMessage: { //引っかかってる場合のみメッセージが入ります
        name: '',
        age: '',
        gender: '', // 0:男, 1:女
        prefecture: ''
      }
    }
  },
  methods:{
    validate() { //バリデーションメソッドの呼び出し
      return validationMethod.validate(this.formValues, this.validation, this.validationMessage);
    },
    submit() { //登録ボタン押したときの挙動
      let validationResult = this.validate();
      this.validationStatus = validationResult.status;
      this.validationMessage = validationResult.message;
    }
  }
}
</script>
```

```vuejs:SubmitUserForm.vue
<template>
  <div class="form">
    <div class="form_component">
      <div class="form_title required">
        名前
      </div>
      <div class="form_content">
        <Input v-model=formValues.name></Input>
        <div class="validation-message" v-show="!validationStatus">
          {{ validationMessage.name }}
        </div>
      </div>
    </div>
    <div class="form_component">
      <div class="form_title required">
        年齢
      </div>
      <div class="form_content">
        <Input v-model=formValues.age></Input>
        <div class="validation-message" v-show="!validationStatus">
          {{ validationMessage.age }}
        </div>
      </div>
    </div>
    <div class="form_component">
      <div class="form_title">
        性別
      </div>
      <div class="form_content">
        <Radio
          v-model=formValues.gender
          v-bind:data=GENDER_LIST
        >
        </Radio>
      </div>
    </div>
    <div class="form_component">
      <div class="form_title">
        都道府県
      </div>
      <div class="form_content">
        <Select
          v-model=formValues.prefecture
          v-bind:data=PREFECTURE_LIST
        >
        </Select>
      </div>
    </div>
  </div>
</template>

<script>
import Input from './form/Input';
import Radio from './form/Radio';
import Select from './form/Select';

import GENDER_LIST from '../utilities/constants/gender';
import PREFECTURE_LIST from '../utilities/constants/prefecture';

export default {
  components: {
    Input,
    Radio,
    Select,
  },
  mixins: [GENDER_LIST, PREFECTURE_LIST],
  props: ['formValues', 'validationStatus', 'validationMessage']
}
</script>
```

```vuejs:Input.vue
<template>
  <input type="text" v-model="innerValue">
</template>

<script>
  export default {
    props: ['value'],
    computed: {
      innerValue: {
        get() {
          return this.value
        },
        set(val) {
          this.$emit('input', val)
        }
      }
    }
  }
</script>

```

```vuejs:Radio.vue
<template>
  <ul>
    <li v-for="option in data">
      <label>
        <input type="radio" v-bind:value="option.id" v-model="innerValue">
        {{ option.name }}
      </label>
    </li>
  </ul>
</template>

<script>
export default {
  props: ['value', 'data'],
  computed: {
    innerValue: {
      get() {
        return this.value
      },
      set(val) {
        this.$emit('input', val)
      }
    }
  }
}
</script>
```

```vuejs:Select.vue
<template>
  <select v-model="innerValue">
    <option v-for="option in data" v-bind:value="option.id">
      {{ option.name }}
    </option>
  </select>
</template>

<script>
export default {
  props: ['value', 'data'],
  computed: {
    innerValue: {
      get() {
        return this.value
      },
      set(val) {
        this.$emit('input', val)
      }
    }
  }
}
</script>
```

radioボタンで使ってる定数ファイルはこちら

```js:gender.js
const GENDER_LIST = [
  {
    id: 0,
    name: "男"
  },
  {
    id: 1,
    name: "女"
  }
]

export default {
  data() {
    return {
      GENDER_LIST: GENDER_LIST,
    }
  }
}
```
都道府県も同じように用意しました。それは[こちら](https://qiita.com/ebineK/items/e41027371f0f85447644)をどうぞ。


スタイルまで書くと長すぎるので割愛しました。
コンポーネントに切り分けたので、記事にすると読みにくいかもしれないですね...(プロジェクトまるごと共有したいですわ)
とりあえず、画面は手ごろな感じで汎用性高めました！っていうだけです。

そしてここから。

```js
validationMethod.validate(this.formValues, this.validation, this.validationMessage);
```

画面側からはこんな感じで呼び出します。（~~命名が雑なのは許してください~~）

```js:validationMethod.js
import _ from 'lodash';
import validationSchema from './validationSchema';

function validate(values, settings, messages) {
  let errors = 0; //最終的に0じゃなければエラーメッセージを出します
  let validationMessage = messages;

  _.map(settings, function(setting, settingKey){ //条件にループ
    _.map(values, function(value, valueKey){ //値のループ
      if(settingKey === valueKey) { //条件と値のキーが一致した場合
        //必須チェック
        if(!requiredCheck(setting.required, value)) {
          validationMessage[settingKey] = setting.name + 'は必須です';
          errors++;
          return;
        } else {
          validationMessage[settingKey] = '';
        }
        //文字数チェック
        if(!wordcountCheck(setting.length, value)) {
          validationMessage[settingKey] =
          setting.name + 'は' +
          setting.length[0] + "文字以上" +
          setting.length[1] + "文字以下で入力してください";
          errors++;
          return;
        } else {
          validationMessage[settingKey] = '';
        }

        //形式チェック
        let formatResult = formatCheck(setting.format, value);
        if(!formatResult.status) {
          validationMessage[settingKey] = setting.name + formatResult.message;
          errors++;
          return;
        } else {
          validationMessage[settingKey] = '';
        }

      }
    });
  })
  let status = errors === 0 ? true : false;
  let validationResult = {
    status: status,
    message: validationMessage
  }
  return validationResult
}

//必須チェック
function requiredCheck(required, value) {
  if(required && (value === "" || value === null)) {
    return false;
  }
  return true;
}

//文字数チェック
function wordcountCheck(length, value) {
  if(length) {
    if(value.length < length[0] || length[1] < value.length) {
      return false
    }
  }
  return true;
}

//形式チェック
function formatCheck(format, value) {
  let schema = validationSchema.validationSchema;
  let formatResult = {
    status: true,
    message: '',
  }
  if(format) {
    _.map(format, function(schemaKey, index) {
      if(schema[schemaKey]) {
        if(!schema[schemaKey].schema.test(value)) {
          formatResult.status = false;
          formatResult.message = schema[schemaKey].errorMessage
        }
      }
    })
  }
  return formatResult;
}

export default { validate };
```

もうめっちゃ長い。
泥臭くなってしまったのが非常に悲しいですが、
~~とりあえず画面側がスッキリしたからいいかな~~
~~バリデーションなんて裏で泥臭いことしてるんや~~

上のメソッドで使ってるschemaはこんな感じ。

```js:validationSchema.js
const validationSchema = {
  Number: {
    schema: /^([1-9]\d*|0)$/,
    errorMessage: "は数字で入力してください"
  },
  Email: {
    schema: /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/,
    errorMessage: "の形式が正しくありません"
  }
}

export default { validationSchema }
````

schemaと言いながらメッセージも書いちゃってるので、ここは切り分けてもよさそうですね。


###まとめと振り返り
ひとまず機能としては実装できたと言ったところです。
実装は3時間ぐらいでしたが、
そこから記事に起こす前に3時間ぐらいもうちょっとスッキリ書けないか
「う～～ん」と悩む時間があったのですが、結局ほとんど手を加えず投稿してしまいました。

けど、map→map→ifはちょっとしんどい。
lodashもあまり使い慣れてないので、もう少し綺麗に書くことできそう。

ここに更に複数のフォームで構成されてる電話番号/郵便番号だったり、
相関的なバリデーションとかが入ってくるのがだいたいなので、
part2として、そういうちょっと複雑になるバリデーションも投稿するかもしれません。

メモとはいえ、ご意見などがありましたらありがたいです。