Vue.js + Django REST framework

# はじめに

この記事は[Misoca+弥生+ALTOA Advent Calendar 2018](https://qiita.com/advent-calendar/2018/misoca-yayoi)の24日目のエントリーになります。

フロントエンドの勉強のためVue.jsを使用してシングルページアプリケーションを試してみました。
Angular、Reactと比較してVue.jsが一番学習量が少ないだろうと思い、今回はVue.jsを選択。
また普段Djangoを使用しているためDjango REST frameworkと合わせて動かしてみました。

*とりあえず動くかすことが目的のためかなり説明少ないです..

# Django REST framework

Django REST frameworkで簡単なAPIを作成します。長くなりそうなので別の記事にしました。
[Django REST frameworkでAPIを作ってみた](https://qiita.com/ryo_mt09sp/items/1b36c414af3001d548e7)

# Vue.js

## node

まずはnode.jsのバージョンを管理するためにnodebrewをインストールします。

```shell
$ brew install nodebrew
$ echo 'export PATH=$HOME/.nodebrew/current/bin:$PATH' >> ~/.bash_profile
$ source ~/.bash_profile
$ nodebrew setup
$ nodebrew install-binary stable
$ nodebrew use v10.14.2
```
これでnode v10.14.2が使用できます。

## Vue.js
DjangoとVue.jsを共存させます。
[すでに作ったDjangoのプロジェクト](https://qiita.com/ryo_mt09sp/items/1b36c414af3001d548e7)のプロジェクトディレクトリに移動して、vueを追加します。

```shell
$ npm install -g vue-cli
$ vue init webpack-simple
```

質問を聞かれますが、適当に答えてください。

```shell
Generate project in current directory ? Yes
? Project name : myproject
? Project description : A Vue.js project
? Author : default
? Lincense : default
? Use Sass : Yes
```

すると色々なファイルが作成されて、プロジェクトの中が以下のようになるはずです。

```shell:ディレクトリ構成
myproject
|_ myproject
   |_ __init__.py
   |_ settings.py
   |_ urls.py
   |_ wsgi.py
|_ myapp
   |_ migrations
   |_ __init__.py
   |_ admin.py
   |_ apps.py
   |_ models.py
   |_ renderers.py
   |_ tests.py
   |_ urls.py
   |_ views.py
|_ manage.py
|_ db.sqlite3
|_ src
  |_ assets
  |_ App.vue
  |_ main.js
|_ .babelrc
|_ .editorconfig
|_ index.html
|_ manage.py
|_ package.json
|_ README.md
|_ webpack.config.js
```

そしてnpm installを実行するとpackage.jsonに記載されているpackageがインストールされます。

```shell
npm install
```

ここで正しくインストールされた確認するために、サーバを起動します。

```shell
npm run dev
```
この画面が表示されたらインストールは成功してそうです。

![screencapture-localhost-8080-2018-12-17-19_54_28.png](https://qiita-image-store.s3.amazonaws.com/0/324886/81a0da2c-9555-4e8b-3bfc-506b36f353e2.png)

## Vue.js + Django

ここからはDjangoとVue.jsを統合していきます。

まず必要となるプラグインをインストールします。

```
npm install webpack-bundle-tracker --save-dev
npm install write-file-webpack-plugin --save-dev
```

次にインストールしたプラグインを利用するため、webpack.config.jsに追加します。

```javascript:myproject/webpack.config.js
var path = require('path')
var webpack = require('webpack')
var BundleTracker = require('webpack-bundle-tracker')
var WriteFilePlugin = require('write-file-webpack-plugin')

module.exports = {
  entry: './src/main.js',
  output: {
    path: path.resolve(__dirname, './dist'),
    filename: 'build.js'
  },
  plugins: [
    new BundleTracker({filename: 'webpack-stats.json'}),
    new WriteFilePlugin()
  ],
  module: {
    rules: [
...

 resolve: {
    alias: {
      'vue$': 'vue/dist/vue.esm.js',
      '@': path.resolve(__dirname, 'src')
    },
    extensions: ['*', '.js', '.vue', '.json']
  },

...
```

以下のコマンドでビルドするとdistにビルドされたファイルができます。

```shell
npm run build
```

Djangoの方も設定します。
必要なdjangoのプラグインをインストール。

```
pip install django-webpack-loader
```

その後、settings.pyを変更します。

```python:myproject/settings.py
...

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'myapp',
    'webpack_loader', #追加
]

...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR], #変更
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

...

STATIC_URL = '/public/' #変更

# 以下追加
STATIC_ROOT = os.path.join(BASE_DIR, 'public')
STATICFILES_DIRS = (
    os.path.join(BASE_DIR, 'dist'),
)

WEBPACK_LOADER = {
    'DEFAULT': {
        'CACHE': not DEBUG,
        'BUNDLE_DIR_NAME': '',
        'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
        'POLL_INTERVAL': 0.1,
        'TIMEOUT': None,
        'IGNORE': ['.+\.hot-update.js', '.+\.map']
    }
}
```

index.htmlでbundle.jsを呼ぶようにします。

```html:myproject/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>saicoro</title>
  </head>
  <body>
    {% load render_bundle from webpack_loader %}
    <div id="app">
      <app></app>
    </div>
    {% render_bundle 'main' %}
  </body>
</html>
```

urls.pyでindex.htmlを呼ぶようにします。

```python:myproject/urls.py
from django.contrib import admin
from django.urls import path
from django.conf.urls import include, url
from rest_framework import routers
from myapp import urls
from django.views.generic import TemplateView
from django.contrib.staticfiles.urls import staticfiles_urlpatterns

router = routers.DefaultRouter()

urlpatterns = [
    path('admin/', admin.site.urls),
    url('api/', include(router.urls)),
    url('api/', include(urls, namespace='spots')),
    url('', TemplateView.as_view(template_name='index.html'), name='Home'),
]

urlpatterns += staticfiles_urlpatterns()

```

<app>タグをmain.jsを定義。

```javascript
import Vue from 'vue'
import App from './App.vue'

new Vue({
  el: '#app',
  components: {
    'app': App
  }
})
```

Vue.jsのアプリをビルドし、Dajngoのサーバを起動します。

```shell
$ npm run build

# 静的ファイルを一箇所に集める
$ python manage.py collectstatic
$ python manage.py runserver
```

これでDajngoとVue.jsを共存させることができました。
ブラウザで http://localhost:8000/ アクセスするとVue.jsのデフォルト画面が表示されるはずです。

![screencapture-localhost-8000-2018-12-17-20_39_14.png](https://qiita-image-store.s3.amazonaws.com/0/324886/3316ed4b-20fe-0638-1fe6-df25263db45b.png)

## Components

すべての画面で利用するHeaderとFooterのコンポーネントを作ります。

まずcssをビルドするのに必要なloaderをインストールします。

```
$ npm install --save-dev node-sass sass-loader
```

webpack.config.jsに以下を追加します。

```javascript:saicoro/webpack.config.js
...
      {
        test: /\.(css|sass)$/,
        loader: ['node-sass', 'sass-loader']
      }
...
```

### Header

```vuejs:src/components/Header.vue
<template>
  <header>
    <h1>Header</h1>
  </header>
</template>

<script>
  export default {
    data() {
      return {

      }
    }
  }
</script>

<style scoped lang="sass">
  header
    display: flex
    background-color: #000000
    border-bottom: 2px solid darken(#000000, 30%)
    color: #ffffff
</style>

```

### Footer

```vuejs:src/components/Footer.vue
<template>
  <footer>
    <h1>Footer</h1>
  </footer>
</template>

<script>
  export default {
    data () {
      return {

      }
    }
  }
</script>

<style scoped lang="sass">
  footer
    position: fixed
    bottom: 0
    width: 100%
    background-color: #000000
    border-top: 2px solid darken(#000000, 20%)
    color: #ffffff
</style>

```

### App.vue

```vuejs
<template>
  <div id="app">
    <MyHeader></MyHeader>
    <h1> Some content... </h1>
    <MyFooter></MyFooter>
  </div>
</template>

<script>
  import MyHeader from '@/components/Header'
  import MyFooter from '@/components/Footer'

  export default {
    components: {
      MyHeader,
      MyFooter
    }
  }
</script>

<style scoped lang="sass">
  html, body
    margin: 0
    min-height: 100vh
</style>
```

画面を確認します。

```shell
$ nmp run build
$ python manage.py collectstatic
$ python manage.py runserver
```

![screencapture-localhost-8000-2018-12-18-22_18_20.png](https://qiita-image-store.s3.amazonaws.com/0/324886/42ebcc91-c2b7-7c91-5717-68ed0d701e65.png)

### Button.vue

APIを呼ぶためのボタンを配置するコンポーネントを作成します。

```vuejs:src/components/Button.vue
<template>
  <button type="button" @click="goTo">押さないでください！</button>
</template>

<script>
  export default {
    props:{
      pathname: {
        type: String,
        required: true
      }
    },
    methods: {
      goTo() {
        this.$router.push({name: this.pathname})
      }
    }
  }
</script>

<style scoped lang="sass">
  button
    width: 80%
    background-color: transparent
    color: #000000
    border: 2px solid #000000
    border-radius: 5px
    min-height: 50px
    text-transform: uppercase
    &:hover
        background-color: #ffefd5
        color: #000000
        border-color: #000000
</style>
```

またボタンを配置するコンポーネントも作成。

```vuejs:src/views/Home.vue
<template>
  <section class="container">
    <redirectButton pathname="spot"></redirectButton>
  </section>
</template>

<script>
  import redirectButton from '@/components/Button'

  export default {
    components: {
      redirectButton
    }
  }
</script>
```

ボタンを配置したHome.vueを結びつけます。

```
$ npm install --save vue-router
```

```javascript:src/router/index.js
import Vue from 'vue'
import Router from 'vue-router'

import Home from '@/views/Home'

Vue.use(Router)

export default new Router({
  routes: [
    {
      path: '/',
      name: 'home',
      component: Home
    }
  ]
})
```

App.vueを更新してHome.vueを配置します。

```vuejs:src/App.vue
<template>
  <div id="app">
    <MyHeader></MyHeader>
    <router-view></router-view>
    <MyFooter></MyFooter>
  </div>
</template>
...
```

```javascript:src/main.js
import Vue from 'vue'
import App from './App.vue'
import router from '@/router'

new Vue({
  el: '#app',
  router,
  components: {
    'app': App
  }
})
```


画面を確認します。ボタンが表示できていると思います。

```shell
nmp run build
python manage.py collectstatic
python manage.py runserver
```

![screencapture-localhost-8000-2018-12-18-23_18_35.png](https://qiita-image-store.s3.amazonaws.com/0/324886/9cf6cc9a-6ddb-c2a0-fe66-d3b66b60b47d.png)

ボタンを押しても何も表示されません。とりあえず配置したかたちになります。

## Vuex

Django REST frameworkで作成したAPIから情報を受け取り、画面に表示されるように実装していきます。

### API呼び出し

まず必要なパッケージをインストールします。

```
npm install --save axios vue-axios vuex
```

APIの呼び出し処理を実装。

```javascript:src/services/api.service.js
import Vue from 'vue'
import axios from 'axios'
import VueAxios from 'vue-axios'
import { API_URL } from '@/services/config'

const ApiService = {
  init () {
    Vue.use(VueAxios, axios)
    Vue.axios.defaults.baseURL = API_URL
  },

  get (resource, slug='') {
    return Vue.axios
      .get(`${resource}\${slug}`)
      .catch((error) => {
        throw new Error(`ApiService ${error}`)
      })
  },
}

export default ApiService
```

```javascript:src/services/config.js
export default {}
export const API_URL = 'http://localhost:8000/api'
```

```javascript:src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

import spot from './spots.module'

Vue.use(Vuex)

export default new Vuex.Store({
  modules: {
    spots
  }
})
```

```javascript:src/store/actions.type.js
export const FETCH_A_SPOT = "fetchASpot"
export const FETCH_SPOTS = "fetchSpots"
```

```javascript:src/store/mutations.type.js
export const FETCH_START = "loadingOn"
export const FETCH_END = "loadingOff"
export const SET_ERROR = "setError"
export const SET_A_SPOT = "setACat"
export const SET_SPOTS = "setCats"
```

```javascript:src/store/spots.module.js
import ApiService from '@/services/api.service'
import {FETCH_A_SPOT, FETCH_SPOTS} from './actions.type'
import {FETCH_START, FETCH_END, SET_A_SPOT, SET_SPOTS, SET_ERROR} from './mutations.type'

const state = {
  spots: [],
  spot: {},
  loading: false
}

const getters = {
  currentSpot(state) {
    return state.spot
  },
  spots(state) {
    return state.spots
  },
  ifLoading(state) {
    return
  }
}

const actions = {
  [FETCH_SPOTS](context, payload) {
    context.commit(FETCH_START)
    return ApiService
      .get(`spots`)
      .then((response) => {
        console.log('status:', response.status);
        console.log('body:', response.data);
        context.commit(SET_SPOTS, response.data.spots);
        context.commit(FETCH_END)
      })
      .catch((response) => {
        context.commit(SET_ERROR, response.errors)
      })
  },
  [FETCH_A_SPOT](context, payload) {
    context.commit(FETCH_START)
    const {spot_id} = payload
    return ApiService
      .get(`spots/${spot_id}`)
      .then((response) => {
        console.log('status:', response.status);
        console.log('body:', response.data);
        context.commit(SET_A_SPOT, response.data.spots);
        context.commit(FETCH_END)
      })
      .catch(((response) => {
        context.commit(SET_ERROR, response.errors)
      }))
  }
}

const mutations = {
  [FETCH_START](state) {
    state.loading = true
  },
  [FETCH_END](state) {
    state.loading = false
  },
  [SET_A_SPOT](state, pSpot) {
    state.spot = pSpot
    state.errors = {}
  },
  [SET_SPOTS](state, pSpots) {
    state.spots = pSpots
    state.errors = {}
  },
  [SET_ERROR](state) {
    state.errors = errors
  }
}

export default {
  state,
  getters,
  actions,
  mutations
}


```
### 画面表示

Homeにスポットの一覧を表示するために、まずSpotList.vueを作成します。

```vuejs:src/components/SpotList.vue
<template>
  <ul id="spotList">
    <li v-for="spot in spots">
      <div class="description">
        <p><span class="grey"> 名前 </span>{{ spot.name }}</p>
        <p><span class="grey"> カテゴリー </span>{{ spot.category }}</p>
        <p><span class="grey"> 都道府県 </span>{{ spot.address_prefecture }}</p>
      </div>
    </li>
  </ul>
</template>

<script>
  import {mapGetters} from 'vuex'

  export default {
    name: 'spotList',
    computed: {
      ...mapGetters([
        'spots',
        'isLoading'
      ])
    }
  }
</script>

<style scoped lang='sass'>
  #spotList
    min-width: 85%
    li
      list-style: none
      width: 500px
      display: flex
      justify-content: space-between
      border: solid 3px #e0e0e0
      margin: 15px 0
      box-shadow: 2px 3px #adadad
      .description
        flex: 2
        padding: 10px 15px
        background: #f5f5f5
        p
          text-transform: uppercase
          span
            font-size: 18px
            color: darkgrey
            padding: 0 10px 0 0
</style>
```

次にHome.vueを更新します。

```vuejs:src/views/Home.vue
<template>
  <section class="container">
    <h1>スポット一覧</h1>
    <spotList></spotList>
    <redirectButton></redirectButton>
  </section>
</template>

<script>
  import redirectButton from '@/components/Button'
  import spotList from '@/components/SpotList'
  import { FETCH_SPOTS } from '@/store/actions.type'
  import { mapGetters } from 'vuex'

  export default {
    components: {
      redirectButton,
      spotList
    },
    mounted () {
      this.$store.dispatch(FETCH_SPOTS)
      .then(() => {
        console.log("YOU API");
      })
      .catch((err) => {
        console.log("ERR : ", err);
      })
    }
  }
</script>
```

画面を確認すると以下のようなります。

![screencapture-localhost-8000-2018-12-23-16_48_32.png](https://qiita-image-store.s3.amazonaws.com/0/324886/cf89a89d-ac5d-6fbb-d68e-11fd98c12742.png)

個々のSpot情報を表示するために、SpotList.vueにボタンを追加します。

```vuejs:src/components/SpotList.vue
<template>
  <ul id="spotList">
    <li v-for="spot in spots">
      <div class="description">
        <p><span class="grey"> 名前 </span>{{ spot.name }}</p>
        <p><span class="grey"> カテゴリー </span>{{ spot.category }}</p>
        <p><span class="grey"> 都道府県 </span>{{ spot.address_prefecture }}</p>
        <div class="more">
          <router-link :to="{ name: 'spot', params: {spot_id: spot.id} }">See more...</router-link>
        </div>
      </div>
    </li>
  </ul>
</template>

...

<style scoped lang='sass'>
...
        p
          text-transform: uppercase
          span
            font-size: 18px
            color: darkgrey
            padding: 0 10px 0 0
        .more
          height: 35px
          text-align: center
          padding-top: 8px
          font-size: 19px
          background: #5bab74
          border-radius: 5px
          &:hover
            background-color: #3e8353
          a
            text-decoration: none
            color: #f5f5f5
</style>
```

個々のSpot情報をどのように表示するか定義するIdSpot.vueを作成します。

```vuejs:src/components/IdSpot.vue
<template>
  <div id="idSpot">
    <h1>{{ currentSpot.name }}</h1>
    <div class="description">
      <div>
        <p><span class="grey"> 名前 </span>{{ currentSpot.name }}</p>
        <p><span class="grey"> カテゴリー </span>{{ currentSpot.category }}</p>
        <p><span class="grey"> ジャンル </span>{{ currentSpot.genre }}</p>
        <p><span class="grey"> 都道府県 </span>{{ currentSpot.address_prefecture }}</p>
        <p><span class="grey"> 市区町村 </span>{{ currentSpot.address_city }}</p>
        <p><span class="grey"> 丁目番地等 </span>{{ currentSpot.address_street }}</p>
        <p><span class="grey"> 緯度 </span>{{ currentSpot.latitude }}</p>
        <p><span class="grey"> 経度 </span>{{ currentSpot.longitude }}</p>
      </div>
    </div>
  </div>
</template>

<script>
  import { mapGetters } from 'vuex'

  export default {
    name: 'idSpot',
    computed: {
      ...mapGetters([
        'currentSpot',
        'isLoading'
      ])
    }
  }
</script>

<style scoped lang='sass'>
  h1
    text-transform: uppercase
    font-size: 40px
  .description
    width: 500px
    flex: 2
    padding: 10px 15px
    display: flex
    flex-direction: column
    justify-content: space-around
    div
      p
        text-transform: uppercase
        display: flex
        justify-content: space-between
        span
          font-size: 18px
          padding: 0 10px 0 0

</style>
```

IdSpot.vueを表示するSpot.vueを作成します。

```vuejs:src/views/Spot.vue
<template>
  <section class="container">
    <idSpot></idSpot>
  </section>
</template>

<script>
  import idSpot from '@/components/IdSpot'
  import { FETCH_A_SPOT } from '@/store/actions.type'
  import { mapGetters } from 'vuex'

  export default {
    data () {
      return{}
    },
    components: {
      idSpot
    },
    mounted () {
      this.$store.dispatch(FETCH_A_SPOT, this.$route.params)
        .then(() => {
          console.log('YOU API');
        })
        .catch(() => {
          console.log('ERR: ', err)
        })
    }
  }
</script>


```
これで一通り実装完了です。画面を確認してみます。

一覧画面
![screencapture-localhost-8000-2018-12-23-19_00_06.png](https://qiita-image-store.s3.amazonaws.com/0/324886/4d7e092e-7a20-a407-9f99-849a50f01b64.png)

詳細画面
![screencapture-localhost-8000-2018-12-23-18_56_38.png](https://qiita-image-store.s3.amazonaws.com/0/324886/b5494d73-627b-54c7-d151-f60e4827331d.png)

今回はここまでになります。
クリスマス・イブなのでこれからケーキ買いに行ってきます。

# 終わりに

Vue.js + Django REST frameworkで一応動くものができました。

コピペすれば動くはずなので試してみてはいかがでしょうか。

みんなVue.js + Dajngo REST frameworkでSPAを作るべし！

## 参考資料

[Vue.js](https://jp.vuejs.org/index.html)
[Cute cats web app Django + VueJs](https://medium.com/@jrmybrcf/how-to-build-a-project-with-django-vuejs-custom-frontend-part-273e7ea8ce21)