Vue.jsで入力フォームにバリデーションをつける


# はじめに
この記事はVue#2アドベントカレンダーの23日目です🎅

アドベントカレンダー初参加の@yakumomutsukiです。
当初は「vue-cliを使わなくてもできるvueを使った開発」を考えていたのですが、書いているうちにただのWebpackの説明になってしまったので、実務であるあるなお悩みを書いてみようと思いました。

# バリデーション + 入力フォーム
フロントエンドの開発において、フォーム周りのバリデーションの設計は比較的悩みどころかなあと思います。zipというプロパティにたいして、watchを使ってバリデーションを張るか、computedを使うか、このあたりは実装者によってさまざまあると思います。

さまざまあるなかでの「私はこう実装した」という一例をご覧ください。

```App.vue
<template>
  <div id="app">
    <div class="container">
      <form>
        <div class="form-row">
          <div class="form-group col-md-6">
            <label for="email">メールアドレス（必須）</label>
            <input
              id="email"
              required
              type="email"
              class="form-control"
              :class="formError.email.className"
              name="email"
              placeholder="メールアドレスを入力してください"
              @change="e => update(e)"
              :value="formState.email"
            />
            <span>{{ formError.email.errorMessage }}</span>
          </div>
          <div class="form-group col-md-6">
            <label for="password">パスワード（必須）</label>
            <input
              id="password"
              required
              type="password"
              class="form-control"
              :class="formError.password.className"
              name="password"
              placeholder="パスワード"
              @change="e => update(e)"
              :value="formState.password"
            />
            <span>{{ formError.password.errorMessage }}</span>
          </div>
        </div>
        <div class="form-row">
          <div class="form-group col-md-4">
            <label for="zip">郵便番号（必須）</label>
            <input
              id="zip"
              required
              type="text"
              class="form-control"
              :class="formError.zip.className"
              name="zip"
              placeholder="1000000"
              maxlen="7"
              @change="e => update(e)"
              :value="formState.zip"
            />
            <p>{{ formError.zip.errorMessage }}</p>
          </div>
          <div class="form-group col-md-4">
            <label for="state">都道府県（必須）</label>
            <select
              id="state"
              class="form-control"
              :class="formError.state.className"
              :value="formState.state"
              @change="e => update(e)"
              required
            >
              <option value="" selected>選択してください</option>
              <option value="tokyo">東京</option>
              <option value="osaka">大阪</option>
            </select>
            <p>{{ formError.state.errorMessage }}</p>
          </div>
          <div class="form-group col-md-4">
            <label for="city">市区町村（必須）</label>
            <input
              id="city"
              required
              type="text"
              class="form-control"
              :class="formError.city.className"
              name="city"
              placeholder="千代田区"
              @change="e => update(e)"
              :value="formState.city"
            />
            <p>{{ formError.city.errorMessage }}</p>
          </div>
        </div>
        <div class="form-group">
          <label for="address1">住所１（必須）</label>
          <input
            id="address1"
            required
            type="text"
            class="form-control"
            :class="formError.address1.className"
            name="address1"
            placeholder="秋葉原１－１－１"
            @change="e => update(e)"
            :value="formState.address1"
          />
          <p>{{ formError.address1.errorMessage }}</p>
        </div>
        <div class="form-group">
          <label for="address2">住所２</label>
          <input
            id="address2"
            type="text"
            class="form-control"
            name="address2"
            placeholder="秋葉原ビル１F"
            @change="e => update(e)"
            :value="formState.address2"
          />
        </div>
        <button class="btn btn-primary" @click="registration">登録する</button>
      </form>
    </div>
  </div>
</template>

<script>
const errorBase = {
  errorMessage: "",
  className: ""
};
const patterns = {
  password: /^(?=.*?[a-zA-Z])(?=.*?\d)[a-zA-Z\d]{8,16}$/,
  zip: /^[0-9]{7}$/,
  phoneNumber: /^[0-9]{10,11}$/
};

import isEmail from "validator/lib/isEmail";
import isMatch from "validator/lib/matches";

export default {
  name: "App",
  data() {
    return {
      formState: {
        email: "",
        password: "",
        zip: "",
        state: "",
        city: "",
        address1: "",
        address2: ""
      },
      formError: {
        email: { ...errorBase },
        password: { ...errorBase },
        zip: { ...errorBase },
        state: { ...errorBase },
        city: { ...errorBase },
        address1: { ...errorBase },
        address2: { ...errorBase }
      }
    };
  },
  methods: {
    /**
     * 値が更新されたらupdateメソッドが呼ばれる
     * formStateの値を更新したら、バリデーションを実行
     * @param e HTMLElement
     */
    update(e) {
      const { name, value } = e.target;
      this.formState[name] = value;
      this.formError[name] = { ...errorBase };

      this.validate(name);
    },

    /**
     * バリデーションを行います
     * 未入力チェックが必要な場合は、第2引数にfalseを指定する
     * @param name
     * @param allowBlank
     */
    validate(name, allowBlank = true) {
      const formVal = this.formState[name];

      // 未入力チェックをする場合
      if (!allowBlank && !formVal) {
        this.formError[name] = {
          errorMessage: "未入力です",
          className: "error"
        };
        return;
      }

      // メールアドレス
      if (name === "email" && formVal && !isEmail(formVal)) {
        this.formError[name] = {
          errorMessage: "メールアドレスに誤りがあります",
          className: "error"
        };
        return;
      }

      // パスワード
      if (
        name === "password" &&
        formVal &&
        !isMatch(formVal, patterns.password)
      ) {
        this.formError[name] = {
          errorMessage: "パスワードに誤りがあります",
          className: "error"
        };
        return;
      }

      // 郵便番号
      if (name === "zip" && formVal && !isMatch(formVal, patterns.zip)) {
        this.formError[name] = {
          errorMessage: "郵便番号に誤りがあります",
          className: "error"
        };
      }
    },

    /**
     * 登録処理を行います
     * バリデーションで引っかかった場合は、登録処理を行えません
     */
    registration() {
      for (let [key] of Object.entries(this.formState)) {
        // バリデーションを行わないのはcontinueで抜けておく
        if (key === "address2") {
          continue;
        }
        // バリデーションを実行
        this.validate(key, false);
      }

      let isValid = true;
      for (let [key] of Object.entries(this.formError)) {
        // エラーメッセージがない場合にはtrueになる
        isValid = !this.formError[key].errorMessage ? isValid : false;
      }

      if (isValid) {
        window.alert("登録に成功しました");
      } else {
        window.alert("入力不備があります");
      }
    }
  }
};
</script>

<style>
.error {
  background-color: #ffecec !important;
}
</style>

```

お気づきになりましたでしょうか。
このコードはwatchもcomputedも使っておりません。しかし、formStateとfromErrorの各プロパティのキー名を同じにしておくことで、updateメソッドからvalidateメソッドに同じキー名を渡して、バリデーションをかけることができています。


```javascript
    update(e) {
      const { name, value } = e.target;
      this.formState[name] = value;
      this.formError[name] = { ...errorBase };

      this.validate(name);
    },
```

引数として受け取ったHTMLElementから、`const { name, value } = e.target;`のように分割代入して、nameとvalueを受け取り、formStateに値を設定、fromErrorの初期化して、もう一度バリデーションという流れになっています。

# おわりに
書いていて思ったのですが、別にVueじゃなくてJavaScriptのテクニック感集ですね...。v-modelやwatch、computedは使わなくてもバリデーションはできますというお話でした。
明日のアドベントカレンダーは@kacky917さんです！！🎄