最近、フロントエンドのフレームワークのVue.jsを勉強し始めました。またしても世間の流行から一歩遅れての開始ですが、比較的大きなプロジェクトを管理する私としては、ピッカピッカの流行りをすぐに採用とは行きません。長期的な管理を考えてLaravelのように本当にメージャーになるかを見極めてからです。幸い、Laravelのコミュニティでは、Vue.jsが盛んに利用され情報が多いので、よりメージャーなReactAngularなどのフレームワークの存在を気にしつつもLaravelとの親近さでVue.jsの習得です。Vue.jsの1からの説明は他の人のブログに任せて、私の方はいきなり実用的なVue.jsを紹介していく予定です。まず今回は、Vue.jsをフォームの入力のバリデーションに使用するところを紹介します。

フロントエンドのバリデーション

フォームのバリデーションと言えば、Laravelでは、コントローラにおけるvalidate()です。ルールを指定するだけでとても便利です。しかし、それはバックエンドの話で、その実行には毎回毎回画面を更新するPOSTの実行が必要です。それが嫌なら、inputのHTMLのタグのrequiredで入力必須の指定やtypeでデータタイプを指定するか、あるいはjqueryvalidation inputのプラグインの使用で、フロントエンドである程度のバリデーションを肩代わりしてもらいます。

例えば、Laravelのデフォルトのインストールでの会員登録のフォームでは、以下のようにinputtyperequiredを使いフロントエンドでベーシックなチェックしています。画面の更新を必要とせず高速なので、ユーザーにとっても快適です。

@extends('layouts.app')                                                                                                                                                                                            
                                                                                                                                                                                                                   
@section('content')                                                                                                                                                                                                
<div class="container">
  <div class="row justify-content-center">
    <div class="col-md-8">
      <div class="card">
        <div class="card-header">{{ __('Register') }}</div>

        <div class="card-body">
          <form method="POST" action="{{ route('register') }}">
            @csrf

            <div class="form-group row">
              <label for="name" class="col-md-4 col-form-label text-md-right">{{ __('Name') }}</label>

              <div class="col-md-6">
                <input id="name" type="text" class="form-control @error('name') is-invalid @enderror" name="name" value="{{ old('name') }}" required autocomplete="name" autofocus>

                @error('name')
                  <span class="invalid-feedback" role="alert">
                    <strong>{{ $message }}</strong>
                  </span>
                @enderror
              </div>
            </div>

            <div class="form-group row">
              <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

              <div class="col-md-6">
                <input id="email" type="email" class="form-control @error('email') is-invalid @enderror" name="email" value="{{ old('email') }}" required autocomplete="email">

                @error('email')
                  <span class="invalid-feedback" role="alert">
                    <strong>{{ $message }}</strong>
                  </span>
                @enderror
              </div>
            </div>     
...

画面ではこんな感じ。

しかし、フロンドエンドのチェックは完璧ではなく、例えば、パスワードの確認、つまりPasswordConfirm Passwordの値が同じかどうかはバックエンドのバリデーションに任せます。

さらに、ブラウザを使用せずにPOSTで特定のURL(ここでは /register)に直接データを送信すれば、フロントエンドのバリデーションをすっ飛ばすことは簡単に可能なので、フロントエンドのバリデーションがあるからバックエンドのバリデーションが要らないということにはなりません。

もちろん、バリデーションがフロントにあるのは、バックエンドの使用が経済的(サーバーのリソースを消費しない)になるので良いのですが、同じバリデーションを2箇所で、しかも違う言語(片方はhtmlタグあるいはjquery、もう片方はlaravelやphp)で管理するのはやっかいです。そこで、Vue.jsを使用して画面を更新せずにバックエンドのコントローラのバリデーションを実行してエラーを出力すれば、というのが今回のポストです。

Vue.jsのバックエンドバリデーションパッケージ

今回使用するのは以下のパッケージです。Laracastで有名なJeffreyくんがオリジナルに作成したのをベルギーのspatieの会社がパッケージ化してオープンソースしています。ちなみに、spatieは前回紹介したWeb tinkerも開発しています。しかも、それはVue.jsで開発されています。

https://github.com/spatie/form-backend-validation

まず、インストールとしては、デフォルトのLaravelのプロジェクトを作成したことを仮定して、

$ php artisan make:auth

をコマンドラインで実行後、

$ npm install

を実行して、node_modulesをダウンロードして、

$ npm install vue
$ npm install form-backend-validation

とパッケージをインストールします。

ブレードとスクリプトの編集

それから、先の会員登録のフォームのブレードを以下のように編集します。完全にバックエンドのバリデーションのみを使うために、inputのタグ内において、requiredなどのフロントエンドのバリデーションは削除しました。また、emailではinput typeは、emailからtextに変更しています。上のHTMLを比べてみてください。

@extends('layouts.app')

@section('content')
<div class="container">
  <div class="row justify-content-center">
    <div class="col-md-8">
      <div class="card">
        <div class="card-header">{{ __('Register') }}</div>

        <div class="card-body">

          <div class="alert" :class="messageClass">
            @{{ message }}
          </div>

          <form @submit.prevent="onSubmit" @keydown="form.errors.clear($event.target.name);">

            <div class="form-group row">
              <label for="name" class="col-md-4 col-form-label text-md-right">{{ __('Name') }}</label>

              <div class="col-md-6">
                <input id="name" type="text" class="form-control" :class="{ 'is-invalid': form.errors.has('name') }" v-model="form.name">
                <div class="invalid-feedback"
                  v-if="form.errors.has('name')"
                  v-text="form.errors.first('name')"></div>
              </div>
            </div>

            <div class="form-group row">
              <label for="email" class="col-md-4 col-form-label text-md-right">{{ __('E-Mail Address') }}</label>

              <div class="col-md-6">
                <input id="email" type="text" class="form-control" :class="{ 'is-invalid': form.errors.has('email') }" v-model="form.email">
                <div class="invalid-feedback"
                  v-if="form.errors.has('email')"
                  v-text="form.errors.first('email')"></div>
              </div>
            </div>

            <div class="form-group row">
              <label for="password" class="col-md-4 col-form-label text-md-right">{{ __('Password') }}</label>

              <div class="col-md-6">
                <input id="password" type="password" class="form-control" :class="{ 'is-invalid': form.errors.has('password') }" v-model="form.password">
                <div class="invalid-feedback"
                  v-if="form.errors.has('password')"
                  v-text="form.errors.first('password')"></div>
              </div>
            </div>

            <div class="form-group row">
              <label for="password-confirm" class="col-md-4 col-form-label text-md-right">{{ __('Confirm Password') }}</label>

              <div class="col-md-6">
                <input id="password_confirmation" type="password" class="form-control" :class="{ 'is-invalid': form.errors.has('password_confirmation') }" v-model="form.password_confirmation">
                <div class="invalid-feedback"
                  v-if="form.errors.has('password_confirmation')"
                  v-text="form.errors.first('password_confirmation')"></div>
              </div>
            </div>

            <div class="form-group row mb-0">
              <div class="col-md-6 offset-md-4">
                <button type="submit" class="btn btn-primary" :disabled="form.errors.any()">
                  {{ __('Register') }}
                </button>
              </div>
            </div>
          </form>
        </div>
      </div>
    </div>
  </div>
</div>
@endsection

VueやFormのパッケージを使用するために、app.jsの編集も必要です。

require('./bootstrap');

window.Vue = require('vue');

import Form from 'form-backend-validation';

window.Form = Form;

const app = new Vue({
    el: '#app',

    data: {
            form: new Form({
                name: '',
                email: '',
                password: '',
                password_confirmation: ''
            }),
            message: '',
            messageClass: ''
    },

    methods: {
        onSubmit() {
            this.form['post']('/register') // 適切なパスに変更してください!
                .then(response => this.displaySuccessMessage('Your account was created'))
                .catch(response => this.displayErrorMessage('Your account was not created'));
        },

        displaySuccessMessage(message) {
            this.messageClass = 'alert-success';
            this.message = message;
        },

        displayErrorMessage(message) {
            this.messageClass = 'alert-danger';
            this.message = message;
        },

        clearMessage() {
            this.message = '';
        },
    }

});
                                                                                                                                                                                 

最後に、以下の実行が必要です。

$ npm run dev

ブラウザで見てみましょう。RegisterController.phpのコントローラのvalidationが実行されてエラーが画面を更新せずにエラーが出力されています。

インラインのスクリプト

上の例では、app.jsでは、登録画面だけにしか対応していません。他にも入力画面があるなら問題です。必要な部分だけをそれぞれに画面のインラインスクリプトとしてみます。

まず、app.jsからVueのオブジェクト生成のコードを削除します。

require('./bootstrap');

window.Vue = require('vue');

import Form from 'form-backend-validation';

window.Form = Form;

そして、それをコンパイルします。

$ npm run dev

次に、app.jsで削除したオブジェクト生成を、register.blade.phpのインラインとします。

@extends('layouts.app')

@section('content')
<div class="container">
...
@endsection

@section('script')
    const app = new Vue({
        el: '#app',

        data: {
                form: new Form({
                    name: '',
                    email: '',
                    password: '',
                    password_confirmation: ''
                }),
                message: '',
                messageClass: ''
        },

        methods: {
            onSubmit() {
                this.form['post']('/register')
                    .then(response => this.displaySuccessMessage('Your account was created'))
                    .catch(response => this.displayErrorMessage('Your account was not created'));
            },

            displaySuccessMessage(message) {
                this.messageClass = 'alert-success';
                this.message = message;
            },

            displayErrorMessage(message) {
                this.messageClass = 'alert-danger';
                this.message = message;
            },

            clearMessage() {
                this.message = '';
            },
        }
    });
@endsection

最後に、レイアウトを編集して、インラインのスクリプトを出力するようにします。以下の@yield('script')の部分です。
必ず、<script type="module">で取り囲んでください。以前に説明したように実行の順序が重要です。

<!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="{{ asset('js/app.js') }}" defer></script>

    <script type="module">
        @yield('script')
    </script>
...

インラインへの移行はこれで完了ですが、インラインのスクリプトをそれぞれの画面で繰り返すのは面倒ですね。簡単化あるいはコンポーネント化ができればいいですが、それは私にとっても課題です。

By khino