Bootstrapを利用してフォームを実装しましたため、v-modelやバリデートなどについてまとめます。

イケてるフォーム

イケてるフォーム 初期状態
イケてるフォーム 初期状態
初期状態です。
入力内容に不備がある入力欄はエラーとして赤字、赤枠で表示します。
また全ての不備が取り切れるまで Submit ボタンは disabled とします。

イケてるフォーム エラー状態が即時に反映
イケてるフォーム エラー状態が即時に反映

何か入力し、エラーがなくなるとリアルタイムでフォームに反映されます。

イケてるフォーム Submit可能状態
イケてるフォーム Submit可能状態

全てのエラーがなくなると、Submit ボタンがアクティブになり、ボタンを押下することができます。


ごめんなさい、普通のフォームでした。

環境

  • Node.js 7.7.x
  • npm 4.4.x
  • Vue.js 2.2.x

素のHTML

イケてるフォーム Vue.js 化前の状態
イケてるフォーム Vue.js 化前の状態

上の状態、Vue.js の処理を入れる前の状態の HTML です。

<form class="form-horizontal">
  <fieldset>
    <legend>書籍登録</legend>
    <div class="form-group">
      <label for="inputTitle" class="col-md-2 control-label">タイトル</label>
      <div class="col-md-10">
        <input type="text" class="form-control" id="inputTitle" placeholder="書籍タイトル">
      </div>
    </div>
    <div class="form-group">
      <label for="inputSummary" class="col-md-2 control-label">サマリ</label>
      <div class="col-md-10">
        <textarea class="form-control" rows="3" id="inputSummary"></textarea>
      </div>
    </div>
    <div class="form-group">
      <label for="inputIsbn" class="col-md-2 control-label">ISBN</label>
      <div class="col-md-10">
        <input type="text" class="form-control" id="inputIsbn" placeholder="ISBN">
      </div>
    </div>
    <div class="form-group">
      <label for="inputRelease" class="col-md-2 control-label">発売日</label>
      <div class="col-md-10">
        <input type="date" class="form-control" id="inputRelease">
      </div>
    </div>
    <div class="form-group">
      <div class="col-md-10 col-md-offset-2">
        <button type="submit" class="btn btn-primary center-block">Submit</button>
      </div>
    </div>
  </fieldset>
</form>

INPUT タグの name 属性は今回利用しないため、省略しております。

Vue.js の組み込み

ソース全文

<template>
  <form class="form-horizontal">
    <fieldset>
      <legend>書籍登録</legend>
      <div :class="errorClassObject('title')" class="form-group">
        <label for="inputTitle" class="col-md-2 control-label">タイトル</label>
        <div class="col-md-10">
          <input v-model="edit.title" type="text" class="form-control" id="inputTitle" placeholder="書籍タイトル">
        </div>
      </div>
      <div :class="errorClassObject('summary')" class="form-group">
        <label for="inputSummary" class="col-md-2 control-label">サマリ</label>
        <div class="col-md-10">
          <textarea v-model="edit.summary" class="form-control" rows="3" id="inputSummary"></textarea>
        </div>
      </div>
      <div :class="errorClassObject('isbn')" class="form-group">
        <label for="inputIsbn" class="col-md-2 control-label">ISBN</label>
        <div class="col-md-10">
          <input v-model="edit.isbn" type="text" class="form-control" id="inputIsbn" placeholder="ISBN">
        </div>
      </div>
      <div :class="errorClassObject('release')" class="form-group">
        <label for="inputRelease" class="col-md-2 control-label">発売日</label>
        <div class="col-md-10">
          <input v-model="edit.release" type="date" class="form-control" id="inputRelease">
        </div>
      </div>
      <div class="form-group">
        <div class="col-md-10 col-md-offset-2">
          <button
            @click="doSubmit"
            :disabled="isValid == false"
            type="submit" class="btn btn-primary center-block">Submit</button>
        </div>
      </div>
    </fieldset>
  </form>
</template>
<script>
const isbn10RE = /^[0-9]{9}[0-9X]$/
const dateRE   = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/

export default {
  data() {
    return {
      edit: {
        title  : "",
        summary: "",
        isbn   : "",
        release: ""
      },
      maxLength: 10
    }
  },
  computed: {
    validation() {
      const edit = this.edit
      return {
        title  : (!!edit.title && edit.title.length <= this.maxLength),
        summary: (!!edit.summary),
        isbn   : (!!edit.isbn    && isbn10RE.test(edit.isbn)),
        release: (!!edit.release && dateRE.test(edit.release))
      }
    },
    isValid() {
      const validation = this.validation
      return Object
        .keys(validation)
        .every(function (key) {
          return validation[key]
       })
     }
  },
  methods: {
    errorClassObject(key) {
      return {
        'has-error': (this.validation[key] == false)
      }
    },
    doSubmit() {
      // 美しさに感動してしまうPOST処理
    }
  }
}
</script>

解説

処理の流れ

コードの動かすと次のような流れで処理が連携されていることがわかります。

  • v-model を設定したリアクティブな INPUT タグを入力する
  • 算出プロパティが再計算され、各パラメーターのバリデート結果が更新される
  • バリデート結果に応じて has-error クラスのバインドも更新される

それぞれ見ていきましょう。

v-model

Vue.js > ガイド > フォーム入力バインディング
https://jp.vuejs.org/v2/guide/forms.html

フォームといえばv-modelです。

v-model を HTML に埋め込むだけで簡単に双方向データバインディングを実現することができます。

<input v-model="edit.title" type="text" class="form-control" id="inputTitle" placeholder="書籍タイトル">

たったこれだけです。
あまりにも簡単なため、説明することがありません。。。

クラスのバインディング

Vue.js > ガイド > クラスとスタイルのバインディング
https://jp.vuejs.org/v2/guide/class-and-style.html

バリデートエラー時にクラス[ has-error ]を適用するため、v-bindでクラスをバインドします。

基本のフォーマットは次です。

v-bind:class="{クラス名: 式}"

上がオブジェクト構文ですね。

今回の HTML をオブジェクト構文で記述するならば次のようになります。

v-bind:class="{'has-error': validation.title == false }"

私は視認性から否定( ! )をあまり好まないため、[ == false]を使うようにしているのですが、全てのパラメーターにこれを埋め込むのは手間であるため、メソッドをバインドしております。

errorClassObject(key) {
  return {
    'has-error': (this.validation[key] == false)
  }
}

噛み砕くと、「クラス名と、そのクラスを適用するかの真理値」を対にしたオブジェクトを与えれば良いのです。

たとえば、次のようにして複数のクラスを適用することも可能です。

{
  'class-a': true,
  'class-b': true
}

Vue.js では複雑な処理はテンプレート書かず、メソッドなり、後述する算出プロパティに実装することが推奨されています。

できるだけ HTML は <template> に、JavaScript 処理は <script> に実装することが見通しの良いコードにするための第一歩ですよね。

バリデーション

Vue.js > ガイド > 算出プロパティとウォッチャ
https://jp.vuejs.org/v2/guide/computed.html

今回のサンプルでは算出プロパティを用いてバリデーション結果を管理しております。

算出プロパティは キャッシュされ、リアクティブ依存が変更されたときだけ再計算される ことがの大きな特徴です。
名称から常に計算されるコストが高い処理と誤解してしまいましたが、そうではありませんでした。

サンプルの該当部分は以下です。

computed: {
 validation() {
   const edit = this.edit
   return {
     title  : (!!edit.title),
     :
   }
 },
 isValid() {
   const validation = this.validation
   return Object
     .keys(validation)
     .every(function (key) {
       return validation[key]
    })
  }
},

validation() は、this.edit の各値をチェックし、次の形のオブジェクトを返しています。

{
  'title': true,
  'summary': false,
  :
}

isValid() は validation() の結果を受け、全てのバリデーションがtrueかをチェックする関数です。

これを利用し、Submit ボタンに prop.disabled をバインドしています。

<button :disabled="isValid == false"  -略- >

validation()、isValid() は、ほぼ次の公式ページのままです。

Vue.js > 例 > Firebase + バリデーション の例
https://jp.vuejs.org/v2/examples/firebase.html

もう一歩

ライブラリ連携

既存コードが Bootstrap でフォームを組み立てている場合など、日付入力に Bootstrap datepicker を利用している場合があると思います。

この datepiker を利用する場合、例えば、ブラウザ上では datepicker で「1/17」を「1/31」に変更しても、Vue.js のコードでは編集前の「1/17」となってしまうようなど、そのままでは 「画面表示とスクリプトから見た値が異なる」という不具合 となってしまいます。

このようになってしまう理由は datepicker がユーザ入力を伴わずに DOM を更新してしまい、Vue.js 側で変更を検知できないためのようです。

この対処方法は「datepicker.on でイベントを購読する」で、Github に issue が上がっていました。

when use vue2.0 and bootstrap-datepicker, and use v-model can not get the date value. #4231
https://github.com/vuejs/vue/issues/4231

上記からコードを引用します。
次のように on.changeDate を設定して、Vue.js の data に直接セットしてあげます。

$('#startDate').datepicker().on(
  'changeDate',() => {
    this.startDate = $('#startDate').val()
  }
)

仕方がありませんが、HACK ですね。

共存について、次の記事にまとめました。

[Vue.js] Vue.js と bootstrap-datepicker を連携する
リプレイスなどでやむなく bootstrap-datepicker と Vue.js を連携しなけれならない、という場合の対処方法です。 Laravel での実装を想定しておりますが、ディレクトリ構成などは適宜読
atuweb.net

なお、次のような Vue.js 用の datepieker もたくさん公開されております。

hilongjw/vue-datepicker
https://github.com/hilongjw/vue-datepicker

操作感が若干異なりますが、新規プロジェクトには大人しく Vue.js にあったものを採用するのが良いです。

複雑なバリデーション

複雑なバリデーションを行う場合、メソッドを定義したくなりますが、書き方に注意してください。

次の書き方は期待通りにはなりません。

validation() {
  return {
    title: () => {
      // これはNG、なぜなら関数が定義されるだけだから
    },
  }
},

このように即時関数を利用するとOKでした。

validation() {
  return {
    title: (() => {
      if (hoge) {
        return false
      }
      return true
    })()
  }
},

vue-validator

複雑なバリデートを行う場合、vue-validatorという素晴らしいライブラリが公開されております。

kazupon/vue-validator
https://github.com/kazupon/vue-validator

複雑な構造

イテレーターを利用する場合など、validation() で返すオブジェクトの階層が深くなる場合もあると思います。

validation() {
  return {
    address1: {
      zipCode: true,
      phone  : true,
    },
    address2: {
      zipCode: true,
      phone  : true,
    }
  }
},

前述の isValid() はオブジェクトが 1 階層であることを前提にしておりそのままでは動きません。
そのため、次のようにオブジェクトのチェックを切り出すなどして対処すれば OK です。

computed: {
  isValid() {
    const validation = this.validation
    return (
      this.itOkey(validation.address1) &&
      this.itOkey(validation.address2)
    )
  },
},
methods: {
  itOkey(params)
  {
    return Object
      .keys(params)
      .every(function (key) {
        return params[key]
    })
  }
}

おわりに

Vue.js ならフォームに双方向バインディングするのも、バリデーション結果をリアルタイムに反映するのも驚くほど簡単です。
この記事が、モダン JavaScript 導入のとっかかりになりますと幸いです。

この記事は tomita@atuweb がお届けしました。

WEB+DB PRESS Vol.97

外村 和仁,小林 徹,古川 陽介,佐藤 歩,yoku0825,是澤 太志,一野瀬 翔吾,加藤 颯史,のざき ひろふみ,うらがみ,水嶋 淳貴,久田 真寛,久保 達彦,伊藤 直也,遠藤 雅伸,ひげぽん,海野 弘成,はまちや2,竹原,倉岡 洋義
出版社:技術評論社  発売日:2017-02-24

Amazonで詳細を見る

2017年12月12日:次のリンクを追加『[Vue.js] Vue.js と bootstrap-datepicker を連携する』