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

# 目次

四部作です。

1. [はじめに](http://qiita.com/acro5piano/items/908cd751b2ea97e19be1)
- [Todoアプリ作成編 / サーバーサイド](http://qiita.com/acro5piano/items/7ae04c0e57d0027f63c9)
- Todoアプリ作成編 / フロントエンド        **← 今ここ**
- [JWTAuthでログイン編] (http://qiita.com/acro5piano/items/eb29f13b82f386220460)

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

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

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

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

# Vue インスタンスについて

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

- Template
- Script
- Style

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

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

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

# Root Component

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

```html+jinja: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` を作ります。

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

```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` という静的なコンポーネントから作ります。

```html+jinja:resources/assets/js/components/About.vue
<template>
  <div>
    This page describes who we are.
  </div>
</template>
```

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

```js: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.js` を `app.js` から読み込みます。

```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](https://qiita-image-store.s3.amazonaws.com/0/103885/752cbd94-915e-6270-d8e1-910c5ca37828.png)


# Layoutを作る

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

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

- `/`
- `/about`
- `/login`

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

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

- `components/Tasks.vue`
- `components/About.vue`
- `components/Login.vue`

です。

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

- `components/Navbar.vue`

も必要です。

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

```html+jinja: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>
```

```html+jinja:resources/assets/js/components/About.vue
<template>
  <div>
    This page describes who we are.
  </div>
</template>
```


```html+jinja: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>
```

```html+jinja: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に登録します。

```js: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` に書きます。

```html+jinja: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](https://qiita-image-store.s3.amazonaws.com/0/103885/a819bbaa-bcec-10d5-7eff-c3640cd8a48a.gif)

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

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

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

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

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


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

```js: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](https://github.com/phanan/koel) のパクリです。


```js: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` でやってあげます。

```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` サービスを利用してリクエストを飛ばします。

```html+jinja: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](https://qiita-image-store.s3.amazonaws.com/0/103885/e1f49fc8-142f-590a-ca82-5ccfa2ada08a.gif)


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