Vue.js チュートリアル(フォーム編)

勉強がてら [Vue.js](https://jp.vuejs.org/) で Todo リストを実装してみたので、その過程をメモしておく。

対象バージョン: Vue.js 2.2.6

## メッセージを表示する
[demo](https://jsfiddle.net/murank/yqxz0w6x/)

```html:index.html
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta charset="utf-8">
<title>Vue.js example</title>
<!--[if lt IE 9]>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>

  <div id="app">
    {{ message }}
  </div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.2.6/vue.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>
```
```js:js/app.js
new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
});
```
最初の一歩ということでとりえず文字列を表示してみる。
Vue.js が読み込まれるまでの間に `{{ message }}` がチラ見えするのを防止するには [v-cloak](https://jp.vuejs.org/v2/api/#v-cloak) を使うと良いらしい。(けど、今回は気にしない方向で)


## Todo をリスト表示する
[demo](https://jsfiddle.net/murank/1jbavxxh/)

```diff:index.html
   <div id="app">
-    {{ message }}
+    <ol>
+      <li v-for="todo in todos">
+        {{ todo.text }}
+      </li>
+    </ol>
   </div>
```
```diff:js/app.js
 new Vue({
   el: '#app',
   data: {
-    message: 'Hello Vue!'
+    todos: [
+      { text: 'Learn JavaScript' },
+      { text: 'Learn Vue' },
+      { text: 'Build something awesome' }
+    ]
   }
 });
```
`v-for` を使ってリストをループさせる。
`v-for` が **指定されている** 要素が繰り返されるので注意。


## コンポーネントを使う
[demo](https://jsfiddle.net/murank/ffzbarpw/)

```diff:index.html
   <div id="app">
     <ol>
-      <li v-for="todo in todos">
-        {{ todo.text }}
-      </li>
+      <todo-item v-for="todo in todos" v-bind:todo="todo"></todo-item>
     </ol>
   </div>
```
```diff:js/app.js
+Vue.component('todo-item', {
+  props: [ 'todo' ],
+  template: '<li>{{ todo.text }}</li>'
+});
+
 new Vue({
   el: '#app',
   data: {
     todos: [
       { text: 'Learn JavaScript' },
       { text: 'Learn Vue' },
       { text: 'Build something awesome' }
     ]
   }
 });
```
`props` と `v-bind` を使って値をコンポーネントに引き渡す。
`v-bind:～` の部分が `props` に指定した値と対応する。
また、`v-bind:todo=～` は `:todo=～` と [省略することも可能](https://jp.vuejs.org/v2/guide/syntax.html#v-bind-%E7%9C%81%E7%95%A5%E8%A8%98%E6%B3%95)。


## Todoリストを追加できるようにする
[demo](https://jsfiddle.net/murank/Lsqwq9zz/)

```diff:index.html
   <div id="app">
     <ol>
       <todo-item v-for="todo in todos" v-bind:todo="todo"></todo-item>
     </ol>
+    <div>
+      <input type="text" v-model="newTodo" />
+      <button v-on:click="add">Add</button>
+    </div>
   </div>
```
```diff:js/app.js
 Vue.component('todo-item', {
   props: [ 'todo' ],
   template: '<li>{{ todo.text }}</li>'
 });
 
 new Vue({
   el: '#app',
   data: {
     todos: [
       { text: 'Learn JavaScript' },
       { text: 'Learn Vue' },
       { text: 'Build something awesome' }
-    ]
+    ],
+    newTodo: ''
+  },
+  methods: {
+    add: function() {
+      this.todos.push({ text: this.newTodo });
+      this.newTodo = '';
+    }
   }
 });
```
Todo の入力用フィールドと追加ボタンを用意して、それぞれを新しい `data` と `methods` に紐付ける。
`v-on:～` は `@～` と [省略することも可能](https://jp.vuejs.org/v2/guide/syntax.html#v-on-%E7%9C%81%E7%95%A5%E8%A8%98%E6%B3%95)。


## バリデーションを行う
[demo](https://jsfiddle.net/murank/a9qd32gw/)

```diff:index.html
   <div id="app">
     <ol>
       <todo-item v-for="todo in todos" v-bind:todo="todo"></todo-item>
     </ol>
     <div>
       <input type="text" v-model="newTodo" />
-      <button v-on:click="add">Add</button>
+      <button v-on:click="add" v-bind:disabled="$v.$invalid">Add</button>
+      <div v-show="$v.newTodo.$invalid">
+        <div v-if="!$v.newTodo.required">値を指定してください</div>
+        <div v-if="!$v.newTodo.minLength">{{ $v.newTodo.$params.minLength.min }} 文字以上で入力してください</div>
+      </div>
     </div>
   </div>
 
 <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.2.6/vue.min.js"></script>
+<script src="https://unpkg.com/vuelidate/dist/vuelidate.min.js"></script>
+<script src="https://unpkg.com/vuelidate@0.3.1/dist/validators.min.js"></script>
 <script src="js/app.js"></script>
```
```diff:lib/app.js
+Vue.use(vuelidate.default);
+
 Vue.component('todo-item', {
   props: [ 'todo' ],
   template: '<li>{{ todo.text }}</li>'
 });

 new Vue({
   el: '#app',
   data: {
     todos: [
       { text: 'Learn JavaScript' },
       { text: 'Learn Vue' },
       { text: 'Build something awesome' }
     ],
     newTodo: ''
   },
+  validations: {
+    newTodo: {
+      required: validators.required,
+      minLength: validators.minLength(3)
+    }
+  },
   methods: {
     add: function() {
       this.todos.push({ text: this.newTodo });
       this.newTodo = '';
     }
   }
 });
```
[vue-validator](https://github.com/kazupon/vue-validator) を使おうと思ったら Vue.js 2.2 に対応していなかったので、代わりに [vuelidate](https://github.com/monterail/vuelidate) を使うことに。
`validators.min.js` の最新版ではエラーメッセージ内で使用している `$params` を [取得できない](https://github.com/monterail/vuelidate/issues/108) ため、うまく動作するバージョンを指定している。
ただし、bundle した js ファイルが悪いとのことなので、Browserify 等を使うのであれば最新版でも問題ないと思う。


## 非同期バリデーションを行う
[demo](https://jsfiddle.net/murank/kq964oe6/)

```diff:index.html
   <div id="app">
     <ol>
       <todo-item v-for="todo in todos" v-bind:todo="todo"></todo-item>
     </ol>
     <div>
       <input type="text" v-model="newTodo" />
       <button v-on:click="add" v-bind:disabled="$v.$invalid">Add</button>
       <div v-show="$v.newTodo.$invalid">
         <div v-if="!$v.newTodo.required">値を指定してください</div>
         <div v-if="!$v.newTodo.minLength">{{ $v.newTodo.$params.minLength.min }} 文字以上で入力してください</div>
+        <div v-if="!$v.$pending &amp;&amp; !$v.newTodo.unique">すでに登録されています</div>
       </div>
     </div>
   </div>
 
+<!--[if IE]><!-->
+  <script src="https://cdnjs.cloudflare.com/ajax/libs/es6-promise/4.1.0/es6-promise.auto.min.js"></script>
+<!--<![endif]-->
 <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.2.6/vue.min.js"></script>
 <script src="https://unpkg.com/vuelidate/dist/vuelidate.min.js"></script>
 <script src="https://unpkg.com/vuelidate@0.3.1/dist/validators.min.js"></script>
 <script src="js/app.js"></script>
```
```diff:lib/app.js
 Vue.use(vuelidate.default);
 
 Vue.component('todo-item', {
   props: [ 'todo' ],
   template: '<li>{{ todo.text }}</li>'
 });

 new Vue({
   el: '#app',
   data: {
     todos: [
       { text: 'Learn JavaScript' },
       { text: 'Learn Vue' },
       { text: 'Build something awesome' }
     ],
     newTodo: ''
   },
   validations: {
     newTodo: {
       required: validators.required,
-      minLength: validators.minLength(3)
+      minLength: validators.minLength(3),
+      unique: function(value) {
+        if (value === '') {
+          return true;
+        }
+        return new Promise(function(resolve, reject) {
+          setTimeout(function() {
+            resolve(typeof value === 'string' && (value.length % 2 !== 0));
+          }, 500);
+        });
+      }
     }
   },
   methods: {
     add: function() {
       this.todos.push({ text: this.newTodo });
       this.newTodo = '';
     }
   }
 });
```
Promise を使って非同期バリデーションを行う。
ただし、IE は Promise に対応していないので [polyfill](https://github.com/stefanpenner/es6-promise) が必要。
バリデーション関数で Promise を返すと、それが resolve (か reject) されるまでバリデーションが pending 状態になるようだ。

## 非同期バリデーションの回数を間引く
```diff:index.html
   <div id="app">
     <ol>
       <todo-item v-for="todo in todos" v-bind:todo="todo"></todo-item>
     </ol>
     <div>
       <input type="text" v-model="newTodo" />
       <button v-on:click="add" v-bind:disabled="$v.$invalid">Add</button>
       <div v-show="$v.newTodo.$invalid">
         <div v-if="!$v.newTodo.required">値を指定してください</div>
         <div v-if="!$v.newTodo.minLength">{{ $v.newTodo.$params.minLength.min }} 文字以上で入力してください</div>
         <div v-if="!$v.$pending &amp;&amp; !$v.newTodo.unique">すでに登録されています</div>
       </div>
     </div>
   </div>
 
 <!--[if IE]><!-->
   <script src="https://cdnjs.cloudflare.com/ajax/libs/es6-promise/4.1.0/es6-promise.auto.min.js"></script>
 <!--<![endif]-->
 <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.2.6/vue.min.js"></script>
 <script src="https://unpkg.com/vuelidate/dist/vuelidate.min.js"></script>
 <script src="https://unpkg.com/vuelidate@0.3.1/dist/validators.min.js"></script>
 <script src="js/app.js"></script>
```
```diff:lib/app.js
 Vue.use(vuelidate.default);
 
 Vue.component('todo-item', {
   props: [ 'todo' ],
   template: '<li>{{ todo.text }}</li>'
 });
+
+function debounce(func, wait) {
+  var timerId;
+  var promise;
+  var resolve;
+  var current = 0;
+
+  return function() {
+    var lastThis = this;
+    var lastArguments = arguments;
+
+    if (timerId) {
+      clearTimeout(timerId);
+    }
+    timerId = setTimeout(function() {
+      promise = undefined;
+      var lastSeq = current = (current < 2147483647 ? current + 1 : 0);
+      var result = func.apply(lastThis, lastArguments);
+      if (result.then) {
+        result.then(function(value) {
+          if (lastSeq == current) {
+            resolve(value);
+          }
+        });
+        return;
+      }
+      resolve(result);
+    }, wait);
+
+    if (promise === undefined) {
+      promise = new Promise(function(r) {
+        resolve = r;
+      });
+    }
+    return promise;
+  }
+}
 
 new Vue({
   el: '#app',
   data: {
     todos: [
       { text: 'Learn JavaScript' },
       { text: 'Learn Vue' },
       { text: 'Build something awesome' }
     ],
     newTodo: ''
   },
   validations: {
     newTodo: {
       required: validators.required,
       minLength: validators.minLength(3),
-      unique: function(value) {
+      unique: debounce(function(value) {
         if (value === '') {
           return true;
         }
         return new Promise(function(resolve, reject) {
           setTimeout(function() {
             resolve(typeof value === 'string' && (value.length % 2 !== 0));
           }, 500);
         });
-      }
+      }, 100)
     }
   },
   methods: {
     add: function() {
       this.todos.push({ text: this.newTodo });
       this.newTodo = '';
     }
   }
 });
```
ひとつ前のバージョンだと入力のたびにバリデーションが行われるため、サーバサイドでバリデーションを行うことを考えるとちょっと無駄が多すぎる。
その場合の定石としては入力が落ち着くまでバリデーションを遅延させることだけど、既存のもの ([lodash の `_.debounce()`](https://lodash.com/docs/#debounce) 等) は Vuelidate のバリデーション関数に適用できなかったため、自前で用意することにした。

ひとつ目のポイントとしては、バリデーションを遅延させるためにdebounce 関数から Promise を返却するわけだけど、本来のバリデーション関数も Promise を返却する (こともある) ので、その点を踏まえて最終のバリデーション結果を返却している。
また、バリデーション中に次のバリデーションの呼び出しが発生することもあるため、その場合には一番最後のバリデーションの結果だけを返却するようにしている。[^1]

[^1]: どうも最初に返却した結果がキャッシュされるようで、毎回、バリデーションの結果を返却するとバリデーション結果がうまく反映されなかった


## まとめ
ひとまずよく使いそうなパターンを実装してみたわけだけど、元々 [Knockout.js](http://knockoutjs.com/) を使っていたこともあって、とりあえず使う分にはそれほど学習コストは高くないと感じた。
ただ、Vue.js でできることはこれだけではない (SPA、SSR、単一ファイルコンポーネント…) ので、Vue.js を使いこなすにはまだまだ勉強していかねば。