Vue.js と Laravel バリデーションの連携

## はじめに

jQuery を極めすぎて、とんと食わず嫌いになっていた Vue.js を本格導入するにあたって、フォームのバリデーションをサーバーサイドの Laravel で実装することだけは譲れなかった。
まして、サーバーサイドとフロントエンドの両方で同じ処理（バリデーション）を、しかも異なる言語（php と javascript）で記述するなど論外。

  * Laravel v 6.0
  * Vue.js v 2.6.12
  * form-backend-validation v 2.4.0

## パッケージの選択

### ~~Vuetify~~

* リアルタイムバリデーションのパッケージ。
* フォームエレメントを &lt;v-form や &lt;v-input のように記述し、Vue.js とは別の学習コストが必要。
* そもそも、リアルタイムバリデーションを javascript で定義する気はないので却下。

### ~~FormVuelar~~

* サーバーサイドバリデーションのパッケージ。
* Vuetify の &lt;-v-form が &lt;ivl-from に変わるもので学習コストがさらに増えるので却下。

### form-backend-validation

* 同じくサーバーサイドバリデーションのパッケージ。
* Vue.js の data に与える from オブジェクトを拡張するだけなので扱いやすい。
* フォーム Blade が、通常の Laravel の記述に近い。
* [ララジャパンの記事](https://www.larajapan.com/2019/11/24/vue-js%ef%bc%9a%e3%83%90%e3%83%83%e3%82%af%e3%82%a8%e3%83%b3%e3%83%89%e3%81%ae%e3%83%90%e3%83%aa%e3%83%87%e3%83%bc%e3%82%b7%e3%83%a7%e3%83%b3%e3%82%92%e4%bd%bf%e3%81%86/) がわかりやすい。


## fom-backend-validation の導入

### 共通処理の切り分け

[ララジャパンの記事](https://www.larajapan.com/2019/11/24/vue-js%ef%bc%9a%e3%83%90%e3%83%83%e3%82%af%e3%82%a8%e3%83%b3%e3%83%89%e3%81%ae%e3%83%90%e3%83%aa%e3%83%87%e3%83%bc%e3%82%b7%e3%83%a7%e3%83%b3%e3%82%92%e4%bd%bf%e3%81%86/) の最後にあるように、フォームを持つすべての Blade に Vue.js 初期化のインラインスクリプトを繰り返し記述することは避けたいので、共通処理を切り分けてみた。

#### resources/js/app.js

* Vue.js の初期化は各ページに任せるので app.js は最小限。

```js
require('./bootstrap');

window.Vue = require('vue');

import Form from 'form-backend-validation';

window.Form = Form;
```



#### 各 Blade の記述

* フォームにはブラウザのバリデーションが発生しないよう ``novalidate`` を付与。<br>
* インラインスクリプトで定義するのは、フォーム内のカラム要素 ``data.fields`` だけ。<br>
* ページ固有の処理のため ``data`` に定義を追加したり、``watch`` 等を追加してもよい。


```HTML
@extends('layouts.app')

@section('content)
  （中略）
  <div class="alert" :class="messageClass" v-if="message">
    <a class="float-right close" v-on:click="clearMessage()">x</a>
      @{{ message }}
  </div>

  <form @submit.prevent="onSubmit" 
    @keydown="form.errors.clear($event.target.name);" novalidate>
    <div class="form-group row">
      <label for="email" class="col-md-4 col-form-label text-md-right">メールアドレス</label>
      <div class="col-md-6">
        <input id="email" type="email" v-model="form.email"
          class="form-control" :class="{ 'is-invalid': form.errors.has('email') }">
        <div class="invalid-feedback"
          v-if="form.errors.has('email')"
          v-text="form.errors.first('email')"></div>
      </div>
    </div>    
  （中略）
@endsection

@section('module')
<script type="module">
  import {formBackendValidation} from "{{ mix('/js/libs.js') }}";
  formBackendValidation({
    data: {
      fields: {
        email:     "{{ $user->email }}",
        password:  "",
        name:      "{{ $user->name }}",
        is_active: {{ $user->is_active }},
      }
    }
  }, "{{ route('admin.user.update', $user) }}");
</script>
@endsection
```

#### resources/js/libs.js

* 各ページから呼び出される共通処理。
* ページ側で Vue.js に渡す定義が追加できるように、ディープマージしている。<br>
* ページ側で ``form`` を直接定義せずに ``fields`` で渡してるのは、将来の拡張の可能性のため（後段参照）。



```js
/**
 * Vue.js with form-backend-validation の初期定義
 * 
 * @param {object} option data.fields を含む Vue へ渡すオプション
 * @param {string} url    フォームのポスト先URL
 */
export function formBackendValidation(option, url) {
  const app = new Vue(merge({
    el: '#app',
    data: {
      form: new Form(option.data.fields),
      message: '',
      messageClass: ''
    },
    methods: {
      onSubmit() {
        this.form['post'](url)
          .then(res => {
            if (res.redirect) {
              location.href = res.redirect
            } else if (res.message) {
              this.displayMessage(message, true);
            } else {
              this.displayMessage('更新しました。', true);
            }
          })
          .catch(res => this.displayMessage('エラーを確認してください。', false));
      },
      displayMessage(message, success) {
        this.messageClass = 'alert-' + (success ? 'success' : 'danger')
        this.message = message;
      },
      clearMessage() {
        this.message = '';
      },
    },
  }, option));
  return app;
}

/**
 * deep merge
 */
function merge() {
  return [].reduce.call(arguments, function merge(a, b) {
    Object.keys(b).forEach(function (key) {
      a[key] = (typeof a[key] === "object" && typeof b[key] === "object")
        ? a[key] = merge(a[key], b[key]) : a[key] = b[key];
    });
    return a;
  });
}
```

### webpack.mix.js

* libs.js を mix.js() で処理すると webpack 変換されて import できなかったので、app.js とは処理を分けた。


```js
mix.js('resources/js/app.js', 'public/js')
   .sass('resources/sass/app.scss', 'public/css');

// モジュールで呼び出される js は webpack変換されないよう .scripts を使う
mix.scripts(['resources/js/libs.js'], 'public/js/libs.js');

mix.version();
```

### resources/views/layouts/app.blade.php

* CSRFトークン、app.js の組み込みは Laravel 初期通りに必須。
* フォームで定義したインラインスクリプトの ``@yield`` ディレクティブを追加。
  * ララジャパンの記事と異なるのは ``<script>`` タグをフォームに移動。vscode のコード色分けに対応するため。
* コンテンツコンテナに ``id="app"``。

```html
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- CSRF Token -->
  <meta name="csrf-token" content="{{ csrf_token() }}">

  <title>{{ config('app.name', 'Laravel') }}</title>

  <!-- Scripts -->
  <script src="{{ mix('js/app.js') }}" defer></script>
  @yield('module')
  （中略）
</head>
<body>
  <div id="app">
    （中略）
    <main class="py-4">
      @yield('content')
    </main>
  </div>
</body>
<body>
```

### コントローラ

バリデーションが通りDB更新後は、libs.js の ``then`` メソッドで返り値を受け取って分岐できるよう、リダイレクト先の URL を json で返す。

```php
    /**
     * @param \App\Http\Requests\UserPost $request
     * @param \App\Models\User            $user
     * @return \Illuminate\Http\JsonResponse
     */
    public function update(UserPost $request, User $user)
    {
        $validated = $request->validated();
        $user->update($validated);
        return response()->json(['redirect' => route('admin.user.show', $user)]);
    }
```

以上で、フォームのサブミットボタンをクリックしたときに、画面遷移を伴わないバリデーション処理が実現できた。万歳。

## Laravel によるリアルタイムバリデーションの模索

``this.form`` の変更を watch して１項目ずつサーバーに問い合わせることができれば、一元管理されたバリデーション定義で、リアルタイムバリデーションも実現できるのでは？・・・と、試してみた。

* libs.js に ``computed`` と ``app.$watch`` のテストコードを追加した。


```js
/**
 * Vue.js with form-backend-validation の初期定義
 * 
 * @param {object} option data.fields を含む Vue へ渡すオプション
 * @param {string} url    フォームのポスト先URL
 */
export function formBackendValidation(option, url) {
  const app = new Vue(merge({
    el: '#app',
    data: {
      form: new Form(option.data.fields),
      message: '',
      messageClass: ''
    },
    methods: {
      onSubmit() {
        this.form['post'](url)
          .then(res => {
            if (res.redirect) {
              location.href = res.redirect
            } else if (res.message) {
              this.displayMessage(message, true);
            } else {
              this.displayMessage('更新しました。', true);
            }
          })
          .catch(res => this.displayMessage('エラーを確認してください。', false));
      },
      displayMessage(message, success) {
        this.messageClass = 'alert-' + (success ? 'success' : 'danger')
        this.message = message;
      },
      clearMessage() {
        this.message = '';
      },
    },
    computed: {
      computedForm() {
        return JSON.parse(JSON.stringify(this.form))
      }
    }
  }, option));

  app.$watch('computedForm', function (newVal, oldVal) {
    for (let key in app.fields) {
      if (newVal[key] != oldVal[key]) {
        console.log(key, newVal[key], oldVal[key]);
      }
    }
  }, {deep: true});

  return app;
}
（後略）
```

``deep: true`` で定義した ``this.form`` 一括 watch では、２つの引数 newVal と oldVal の値が同じになってしまうため、一旦 ``computed`` で計算した値を watch する必要があったが、これで変更があったカラムを抽出することが可能となった。

次の手順としては・・・

* フロント側
  1. 変更がなかったカラムを ``disabled`` にし、変更があったカラムだけでフォームポストする。
  2. または、変更があったカラム名を、``hidden`` 要素としてフォームに追加したポストする。
* Laravel 側
  * 2の場合は、変更があったカラムのポストだけに Laravel 側で絞り込む。
  * バリデーション定義にはすべて ``somtimes`` を加え、ポストされたカラムに対してだけ判定を返す。

２の方法では、フォームに file 要素があった場合に負荷が大きすぎるので、1の方法がベターか。
なんとか実現の方向性が見えてきたように気がした・・・この時点では。

### リアルタムバリデーション計画の行方は・・・

１文字入力するたびにサーバーへ通信するわけにはいかないので、v-model に修飾子 lazy を追加する。これにより入力を終えてフォーカスが移ったときの判定になる・・・はずだった。

```html
<input id="email" type="email" v-model.lazy="form.email"
  class="form-control" :class="{ 'is-invalid': form.errors.has('email') }">
```

ところがなんと、この追加で入力要素において１文字以上入力できない問題が発生した！

ネイティブの Vue.js では生じない症状なので、form-backend-validation 固有の問題であるよう。この問題が解決するまでは、リアルタムバリデーションはお預け。