Laravel 5.4 と Vue.js 2.2 と JWTAuth で、ログインできる SPA アプリケーションのチュートリアル 3/4

目次

四部作です。

  1. はじめに
  2. Todoアプリ作成編 / サーバーサイド
  3. Todoアプリ作成編 / フロントエンド ← 今ここ
  4. JWTAuthでログイン編

今回は、Vue.jsを使ってSPAを作っていきます。

これまでに作成したREST APIに問い合わせて、画面を更新します。

JS初心者なので、おかしいところとかベストじゃないところが多々あると思いますので
ご指摘頂けたら嬉しいです!

なお、ユニットテストはまだ作ってません・・・

Vue インスタンスについて

Vue.js はコンポーネント志向なので、

  • Template
  • Script
  • Style

がひとまとまりになった .vue という拡張子のコンポーネントを作ります。

それを組み合わせて、クライアントサイドでのページ遷移を実現しています。

詳しくは本家サイトをご覧下さい。
https://vuejs.org/v2/guide/components.html

Root Component

まずは大元のインスタンスである app.vue を作ります。

resources/assets/js/app.vue
<template>
  <div id="app">
      <div class="container">
        <router-view></router-view>
      </div>
      <hr>
      <div class="container-fluid">
          <a href="https://github.com/acro5piano/laravel-vue-jwtauth-spa-todo-app" target="_blank">
              <img src="https://image.flaticon.com/icons/svg/25/25231.svg" width="30" height="20">
          </a>
      </div>
  </div>
</template>

このコンポーネントを読み込む app.js を作ります。

ブラウザがサーバーからレスポンスを受取った後、最初に実行されるエントリーポイント?という認識です。

resources/assets/js/app.js
import Vue from 'vue'

require('bootstrap-sass')

const app = new Vue({
  el: '#app',
  render: h => h(require('./app.vue')),
})

Child component + Routing

親コンポーネントができたので、子コンポーネントを作っていきます。

まずは about us という静的なコンポーネントから作ります。

resources/assets/js/components/About.vue
<template>
  <div>
    This page describes who we are.
  </div>
</template>

このコンポーネントを vue-router に登録します。

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

Vue.use(VueRouter)

export default new VueRouter({
  mode: 'history',
  routes: [
    { path: '/about', component: require('./components/About.vue') },
  ],
  scrollBehavior (to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  },
})

app.js に書いても良いのですが、肥大化してしまうので別ファイルにするのが定石のようです。

何となく分かると思いますが、

/about というURLで About.vue コンポーネントが、
app.vue<router-view></router-view> にマウントされます。

mode: 'history', のところで、HTML5の push state を利用しています。
mode のデフォルトは hash です。

scrollBehavior の部分は、ブラウザバックでスクロール位置を保つためのもので、
おまじないだと思っててOKです。

作った router.jsapp.js から読み込みます。

resources/assets/js/app.js
import Vue from 'vue'

// 追加
import router from './router'

require('bootstrap-sass')

const app = new Vue({
  // 追加
  router,
  el: '#app',
  render: h => h(require('./app.vue')),
})

試す

この時点で、 localhost:8000/about にアクセスすると、
下記のようにルーティングしてくれるはずです。

Screenshot_2017-03-17_11-14-22.png

Layoutを作る

先に全てのコンポーネントとそれに対するルーティングを作ってしまいましょう。

今回必要なルーティングは

  • /
  • /about
  • /login

の3種類です。 なお、 / でタスク一覧が表示されるようにします。

なので、必要な子コンポーネントはそれぞれ

  • components/Tasks.vue
  • components/About.vue
  • components/Login.vue

です。

これに加えて、常に表示されるナビゲーションバーとして

  • components/Navbar.vue

も必要です。

とりあえずずらーっと書いてしまいます。

resources/assets/js/components/Tasks.vue
<template>
  <div>
    please <router-link to="/login">Login.</router-link>

    <div>
      <strong>Hello, Kazuya!</strong>
      <p>Your tasks here.</p>

      <ul>
        <li>
          Learn Vue.js
        </li>
        <button class="btn btn-sm btn-success">Done</button>

        <button class="btn btn-sm btn-danger">Remove</button>
      </ul>

      <div class="form-group">
        <div class="alert alert-danger" role="alert">
           Task name should not be blank.
        </div>
        <input type="text" class="form-control" placeholder="new task...">
        <button class="btn btn-primary">
          Add task
        </button>
      </div>
    </div>
  </div>
</template>
resources/assets/js/components/About.vue
<template>
  <div>
    This page describes who we are.
  </div>
</template>
resources/assets/js/components/Login.vue
<template>
  <div>
    <div class="container">
      <div class="row">
        <div class="col-md-8 col-md-offset-2">
          <div class="panel panel-default">
            <div class="panel-heading">Login</div>
            <div class="panel-body">


              <div class="alert alert-danger" role="alert">
                Wrong email or password.
              </div>

              <div class="form-group">
                <label for="email" class="col-md-4 control-label">E-Mail Address</label>
                <div class="col-md-6">
                  <input id="email" type="email" class="form-control" required autofocus>
                </div>
              </div>

              <div class="form-group">
                <label for="password" class="col-md-4 control-label">Password</label>
                <div class="col-md-6">
                  <input id="password" type="password" class="form-control" required autofocus>
                </div>
              </div>

              <div class="form-group">
                <div class="col-md-8 col-md-offset-4">
                  <button type="submit" class="btn btn-primary">
                    Login
                  </button>
                </div>
              </div>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
resources/assets/js/components/Navbar.vue
<template>
  <nav class="navbar navbar-default">
    <div class="container-fluid">
      <!-- Brand and toggle get grouped for better mobile display -->
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed"
                 data-toggle="collapse" data-target="#bs-example-navbar-collapse-1"
                 aria-expanded="false">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
        <router-link to="/" class="navbar-brand">Vue TODO</router-link>
      </div>

      <!-- Collect the nav links, forms, and other content for toggling -->
      <div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
        <ul class="nav navbar-nav navbar-right">
          <li><router-link to="/about">About</router-link></li>

          <li>
            <router-link to="/login">Log in</router-link>
          </li>
        </ul>
      </div><!-- /.navbar-collapse -->
    </div><!-- /.container-fluid -->
  </nav>
</template>

次に、作成したコンポーネントをRouterに登録します。

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

Vue.use(VueRouter)

export default new VueRouter({
  mode: 'history',
  routes: [
    { path: '/',      component: require('./components/Tasks.vue') },  // 追加
    { path: '/about', component: require('./components/About.vue') },
    { path: '/login', component: require('./components/Login.vue') },  // 追加
  ],
  scrollBehavior (to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { x: 0, y: 0 }
    }
  },
})

Navbarを配置します。
Routerで変化する部分ではないので、 app.vue に書きます。

resources/assets/js/app.vue
<template>
  <div id="app">
      <!-- 追加 -->
      <navbar></navbar>

      <div class="container">
        <router-view></router-view>
      </div>
      <hr>
      <div class="container-fluid">
          <a href="https://github.com/acro5piano/laravel-vue-jwtauth-spa-todo-app" target="_blank">
              <img src="https://image.flaticon.com/icons/svg/25/25231.svg" width="30" height="20">
          </a>
      </div>
  </div>
</template>

<script>
  // この部分全部追加
  export default {
    components: {
      navbar: require('./components/Navbar.vue'),
    },
  }
</script>

この時点で、SPAっぽい画面遷移をするようになっているはずです。

out.gif

アラートが常に出ていますが、気にしないで下さい。

次から動的なページにしていきます。

axios を使ってリクエストを飛ばす

いよいよ tasks をAPIサーバーから取得する処理を書いていきます。

axios を使ってリクエストするのですが、方法はいくつかあります。

Laravelをインストールした直後は、

resources/assets/js/app.js
window.axios = require('axios');

window.axios.defaults.headers.common = {
    'X-Requested-With': 'XMLHttpRequest'
};

となっており、 window.axios.get(... という書き方ができるようになっています。

が、これだとどのコンポーネントもリクエストを発行できるので、
アプリケーション全体の見通しが悪くなりがちです。

そこで、 services/http.js を作成して、リクエストはそこに一任させるような設計にします。

これも koel のパクリです。

resources/assets/js/services/http.js
import axios from 'axios'

/**
 * Responsible for all HTTP requests.
 */
export default {
  request (method, url, data, successCb = null, errorCb = null) {
    axios.request({
      url,
      data,
      method: method.toLowerCase()
    }).then(successCb).catch(errorCb)
  },

  get (url, successCb = null, errorCb = null) {
    return this.request('get', url, {}, successCb, errorCb)
  },

  post (url, data, successCb = null, errorCb = null) {
    return this.request('post', url, data, successCb, errorCb)
  },

  put (url, data, successCb = null, errorCb = null) {
    return this.request('put', url, data, successCb, errorCb)
  },

  delete (url, data = {}, successCb = null, errorCb = null) {
    return this.request('delete', url, data, successCb, errorCb)
  },

  /**
   * Init the service.
   */
  init () {
    axios.defaults.baseURL = '/api'

    // Intercept the request to make sure the token is injected into the header.
    axios.interceptors.request.use(config => {
      config.headers['X-CSRF-TOKEN']     = window.Laravel.csrfToken
      config.headers['X-Requested-With'] = 'XMLHttpRequest'
      return config
    })
  }
}

これの初期化を app.js でやってあげます。

resources/assets/js/app.js
import Vue from 'vue'
import router from './router'
import http from './services/http.js' // 追加

require('bootstrap-sass')

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

  // 追加
  created () {
    http.init()
  },
  render: h => h(require('./app.vue')),
}).$mount('#app')

Task コンポーネントで、この http サービスを利用してリクエストを飛ばします。

resources/assets/js/components/Tasks.vue
<template>
  <div>
    please <router-link to="/login">Login.</router-link>

    <div>
      <strong>Hello, Kazuya!</strong>
      <p>Your tasks here.</p>

      <ul v-for="task in tasks">
        <li v-if="task.is_done">
          <strike> {{ task.name }} </strike>
        </li>
        <li v-else>
          {{ task.name }}
        </li>
        <button @click="completeTask(task)" class="btn btn-sm btn-success" v-if="task.is_done">Undo</button>
        <button @click="completeTask(task)" class="btn btn-sm btn-success" v-else>Done</button>

        <button @click="removeTask(task)" class="btn btn-sm btn-danger">Remove</button>
      </ul>

      <div class="form-group">
        <div class="alert alert-danger" role="alert" v-if="showAlert">
          {{ alertMessage }}
        </div>
        <input type="text" class="form-control"
            v-model="name" @keyup.enter="addTask" placeholder="new task...">
        <button class="btn btn-primary" disabled="disabled" v-if="name === ''">
          Add task
        </button>
        <button class="btn btn-primary" @click='addTask' v-else>
          Add task
        </button>
      </div>
    </div>
  </div>
</template>
<script>
  import http from '../services/http'

  export default {
    mounted() {
      this.fetchTasks()
    },
    data() {
      return {
        tasks: [],
        name: '',
        showAlert: false,
        alertMessage: '',
      }
    },
    methods: {
      fetchTasks () {
        // TODO: not to send request when the user is not authenticated
        http.get('tasks', res => {
          this.tasks = res.data
        })
      },
      addTask () {
        if (this.name === '') {
          this.showAlert = true
          this.alertMessage = 'Task name should not be blank.'
          return false
        }
        http.post('tasks', {name: this.name}, res => {
          this.tasks[res.data.id] = res.data
          this.name = ''
          this.showAlert = false
          this.alertMessage = ''
        })
      },
      completeTask (task) {
        http.put('tasks/' + task.id, {is_done: !task.is_done}, res => {
          this.tasks[task.id] = res.data
          this.$forceUpdate()
        })
      },
      removeTask (task) {
        http.delete('tasks/' + task.id, {}, () => {
          delete this.tasks[task.id]
          this.$forceUpdate()
        })
      },
    }
  }
</script>

上手く行けばこんな感じになると思います。

out.gif

ここまで読んで頂き、ありがとうございました。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.