PHP
JavaScript
laravel
vue.js

Laravel 5.4 + Vue 2.1 でSPAアプリケーション作成チュートリアル

何となく LaravelVue.js の使い方が分かってきた。
自分の頭の整理を兼ねて、書き残しておく。

処理の流れ

  • ブラウザ → Laravel: SPAのViewをリクエスト(初回のみ)
  • Vue → Laravel: XHRでデータをリクエスト
  • Vue → ブラウザ: クライアントサイドでのルーティング

Laravel の役割

  • Webサーバー。起点となるSPAのViewを初回だけ返す。
  • APIサーバー。サーバーサイドでデータを処理し、JSONを返す。

Vue の役割

  • Viewの描画。変数のバインド、繰り返し、イベント検知など。
  • Router。 vue-router を使って、URLに対応する Vue Component を設定する。
  • サーバーサイドにリクエスト。 axios を使う。

Laravel には上記を実現する機構が整っている。

完成イメージ

ただ記事を表示するだけの、ブログのようなものを作っていく。

SPAなので、コンポーネント志向を使ってモジュール分割することを前提にする。

今後、 JWTAuth を使ったログイン、記事作成、コメント投稿、なども追加していきたい。

Screenshot_2017-03-07_11-06-41.png

(あくまでイメージです、このチュートリアルではCSSやSeedを入れてないためこうはなりません)

ちなみにログインが必要な時は、JWTというトークンベースのステートレスな認証を使うと良さげ。本記事は単純化を目指しているので、下記の記事で解説した。

https://qiita.com/acro5piano/items/eb29f13b82f386220460

アプリ作成

この辺はいつも通り

composer create-project --prefer-dist laravel/laravel blog
cd blog

# change database driver from mysql to sqlite
perl -i -pe 's/DB_.+\n//g' .env
echo 'DB_CONNECTION=sqlite' >> .env
touch database/database.sqlite

php artisan serve

サーバーサイド (Laravel)

Model

記事を取ってくる Article モデルを作成する

database/migrations/2017_03_05_064019_create_articles_table.php
<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateArticlesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('user_id')->references('id')->on('users')->unsigned()->index();
            $table->text('content');
            $table->string('title');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('articles');
    }
}
app/Models/Article.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
}
php artisan migrate

Routing

Web

routes/web.php
<?php

Route::get('/{any}', function () {
    return view('app');
})->where('any', '.*');

ブラウザの Routing は Vue が担当するので、不要。
Laravel /api/* 以外の全てのリクエストに対して、 resources/views/app.blade.php を返す。

/ だけでいいじゃんと思うかもしれないが、それだと / 以外で画面を更新すると Laravel 側のルーティングエラーになってしまう。

よって全てのルーティングで設定しておかないといけない。

参考: https://laracasts.com/discuss/channels/vue/using-vue-router-with-laravel

API

routes/api.php
<?php

use Illuminate\Http\Request;

Route::group(['middleware' => 'api'], function() {
    Route::get('articles',  function() {
        $articles = Article::all()->take(5);
        return $articles;
    });
});

GET /api/articles で、記事5件をJsonで返す。

Controller は簡略化のために今は使わない。
アプリが成熟してきたら、Controller を作成して

routes/api.php
Route::group(['middleware' => 'api'], function() {
    Route::get('articles',  'ArticleController@index');
});

などとしても良いだろう。

フロントエンド

vue-router を使う。クライアントサイドでルーティングするためのVue.js公式ライブラリ。

package.json
       "lodash": "^4.17.4",
       "vue": "^2.1.10",
+++    "vue-router": "^2.3.0"
  }
}
npm install

Webpack を起動

npm run watch

npm run watchWebpack が起動し、アプリケーションの変更を検知してフロントエンドをビルドする。
もしここでエラーになったら、 package.jsonscripts 以下のパスを適当に調整する。

axios を使う

エイクシオス って発音するらしい。jQueryの $.ajax の代替という認識

resources/assets/js/bootstrap.js
// ...

window.axios = require('axios')

window.axios.defaults.headers.common = {
    'X-CSRF-TOKEN': window.Laravel.csrfToken,
    'X-Requested-With': 'XMLHttpRequest'
}

Vue.prototype.$http = window.axios

// ...

こうしておくと、 Vue Component から this.$http.get(.... みたいに使える。

(追記)
@foursue さんから、

ESLintがエラーを検出してしまうので、こちらのほうが良いのではないでしょうか?
window.Vue.prototype.$http = window.axios

という編集リクエストを頂きました。ありがとうございます。

これについてちょっと調べたのですが、公式アナウンスで

Vue.prototype.$http = axios と設定すればよいです。

とあります。

また、GitHubでもこのコードはちらほら見かけます。例)

window.Vue.prototype.$http ... の書き方を採用すると、 Vue.use(...) なども window.Vue.use(...) としなくてはならないので、不自然なのかなと思います。

そもそも window はグローバルオブジェクトで、ブラウザ側からも参照したい値を格納するのに使うものかと思います。

例: window.Laravel.csrfToken = {{ csrf_token() }}

なので、このままにしておきます。

ESLint側の設定を変更するか、 window オブジェクトを使っていないファイルに記述するべきかと思います。

ちゃんと window が何だか知る良い機会になりました。重ねてお礼申し上げます。

メインのJSファイル

resources/assets/js/app.js
import Vue from 'vue'
import VueRouter from 'vue-router'

require('./bootstrap')

Vue.use(VueRouter)

Vue.component('navbar', require('./components/Layouts/Navbar.vue'))

const router = new VueRouter({
    mode: 'history',
    routes: [
        { path: '/', component: require('./components/Articles/Index.vue') },
        { path: '/about', component: require('./components/About.vue') },
    ]
})

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

ここで vue-router を定義しているので、 HTMLに書かれた <router-view></router-view> が下記のように動的に変わる。

  • / -> Articles/Index.vue を表示
  • /about -> About.vue を表示

記事詳細画面を作るならば

{ path: '/articles/:id', component: require('./components/Articles/Show.vue') },

などというルーティングを設定してもいいでしょう。

View(HTML)

resouces/views/app.blade.php
<!DOCTYPE html>
<html lang="{{ config('app.locale') }}">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <link rel="stylesheet" href="{{ mix('css/app.css') }}"></script>

        <script>
            window.Laravel = {
                csrfToken: "{{ csrf_token() }}"
            };
        </script>
    </head>
    <body>
        <div id="app">
            <navbar></navbar>
            <div class="container">
                <router-view></router-view>
            </div>
        </div>
    </body>
    <script src="{{ mix('js/app.js') }}"></script>
</html>

Laravel はこのファイルを返すだけ。どのURLに対してもこの app.blade.php を返す。

Vue Component

まずは静的なコンポーネント

resources/assets/js/components/Layouts/Navbar.vue
<template>
    <nav class="navbar navbar-default">
        <li>
            <router-link  to="/about">About</router-link>
        </li>
    </nav>
</template>
resources/assets/js/components/About.vue
<template>
    <div>
        This page describe who we are.
    </div>
</template>

いよいよVueがLaravelに対してリクエストを送る

resources/assets/js/components/Articles/Index.vue
<template>
    <div v-for="article in articles">
        <h1>
            <router-link :to="'/articles/' + article.id">{{ article.title }}</router-link>
        </h1>
        <p>
            {{ article.content }}
        </p>
    </div>
</template>

<script>
    export default {
        created() {
            this.fetchArticles()
        },
        data() {
            return {
                articles: []
            }
        },
        methods: {
            fetchArticles() {
                this.$http.get('/api/articles')
                .then(res =>  {
                    this.articles = res.data
                })
            }
        }
    }
</script>

created() で、 Component が読み込まれた際に行う処理を書ける。
data() は、Viewに渡す変数を定義する。

TODO:

router-link のところは、もっとエレガントに書けるはず。
this.$router.push とかすれば良いのだろうが、長いので今はこのままにする。

c.f.) http://router.vuejs.org/en/essentials/navigation.html

TODO: createdmounted との違いは何だろう?

↓ 追記

created は、SPAアプリが最初に読み込まれたタイミングで一度だけ実行される。
mounted は、 vue-router によってコンポーネントがセットされる度に更新される。

・・・だと思う。本当に最初だけしかやらなくて良い処理は created に書くのが吉。

所感

以上は走り書きなので、抜け漏れなどあれば指摘お願いしますm(_ _)m

Laravel いいなと思ったのが、最初から

  • Vue, axios がインストール済み
  • Webpack によるビルドが設定済み
  • routes/api.php, routes/web.php のルーティング切り分け

となっているところです。

JSの管理を自前で行わずに、NodeJSに任せているところが潔くて好きです。

この辺の設計思想は、 webpacker.gem を導入した Rails とは真逆だなと思いました。

c.f. Rails5.1から導入されるwebpacker.gemは本当にRailsのフロントエンド開発に福音をもたらすのか?
http://qiita.com/yuroyoro/items/a29e39989f4469ef5e41

フルスタックにこだわり、Railに載せて開発させるRails。

自身はサーバーサイドに徹し、JSに多くを任せるLaravel。

どちらが今後の主流になっていくのか、みものですね。
もちろんこの2つ以外も。