はじめに
jQuery を極めすぎて、とんと食わず嫌いになっていた Vue.js を本格導入するにあたって、フォームのバリデーションをサーバーサイドの Laravel で実装することだけは譲れなかった。
まして、サーバーサイドとフロントエンドの両方で同じ処理(バリデーション)を、しかも異なる言語(php と javascript)で記述するなど論外。
- Laravel v 6.0
- Vue.js v 2.6.12
- form-backend-validation v 2.4.0
パッケージの選択
Vuetify
- リアルタイムバリデーションのパッケージ。
- フォームエレメントを <v-form や <v-input のように記述し、Vue.js とは別の学習コストが必要。
- そもそも、リアルタイムバリデーションを javascript で定義する気はないので却下。
FormVuelar
- サーバーサイドバリデーションのパッケージ。
- Vuetify の <-v-form が <ivl-from に変わるもので学習コストがさらに増えるので却下。
form-backend-validation
- 同じくサーバーサイドバリデーションのパッケージ。
- Vue.js の data に与える from オブジェクトを拡張するだけなので扱いやすい。
- フォーム Blade が、通常の Laravel の記述に近い。
- ララジャパンの記事 がわかりやすい。
fom-backend-validation の導入
共通処理の切り分け
ララジャパンの記事 の最後にあるように、フォームを持つすべての Blade に Vue.js 初期化のインラインスクリプトを繰り返し記述することは避けたいので、共通処理を切り分けてみた。
resources/js/app.js
- Vue.js の初期化は各ページに任せるので app.js は最小限。
require('./bootstrap');
window.Vue = require('vue');
import Form from 'form-backend-validation';
window.Form = Form;
各 Blade の記述
- フォームにはブラウザのバリデーションが発生しないよう
novalidate
を付与。
- インラインスクリプトで定義するのは、フォーム内のカラム要素
data.fields
だけ。
- ページ固有の処理のため
data
に定義を追加したり、watch
等を追加してもよい。
@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 に渡す定義が追加できるように、ディープマージしている。
- ページ側で
form
を直接定義せずにfields
で渡してるのは、将来の拡張の可能性のため(後段参照)。
/**
* 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 とは処理を分けた。
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"
。
<!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 で返す。
/**
* @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 して1項目ずつサーバーに問い合わせることができれば、一元管理されたバリデーション定義で、リアルタイムバリデーションも実現できるのでは?・・・と、試してみた。
- libs.js に
computed
とapp.$watch
のテストコードを追加した。
/**
* 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 では、2つの引数 newVal と oldVal の値が同じになってしまうため、一旦 computed
で計算した値を watch する必要があったが、これで変更があったカラムを抽出することが可能となった。
次の手順としては・・・
- フロント側
- 変更がなかったカラムを
disabled
にし、変更があったカラムだけでフォームポストする。 - または、変更があったカラム名を、
hidden
要素としてフォームに追加したポストする。
- 変更がなかったカラムを
- Laravel 側
- 2の場合は、変更があったカラムのポストだけに Laravel 側で絞り込む。
- バリデーション定義にはすべて
somtimes
を加え、ポストされたカラムに対してだけ判定を返す。
2の方法では、フォームに file 要素があった場合に負荷が大きすぎるので、1の方法がベターか。
なんとか実現の方向性が見えてきたように気がした・・・この時点では。
リアルタムバリデーション計画の行方は・・・
1文字入力するたびにサーバーへ通信するわけにはいかないので、v-model に修飾子 lazy を追加する。これにより入力を終えてフォーカスが移ったときの判定になる・・・はずだった。
<input id="email" type="email" v-model.lazy="form.email"
class="form-control" :class="{ 'is-invalid': form.errors.has('email') }">
ところがなんと、この追加で入力要素において1文字以上入力できない問題が発生した!
ネイティブの Vue.js では生じない症状なので、form-backend-validation 固有の問題であるよう。この問題が解決するまでは、リアルタムバリデーションはお預け。