勉強会用の資料です.
今回の記事ではフロント側のデザインを整えていきます.
また,最初のチュートリアルで作成したAPIを修正し,質問一覧を取得,表示させます.
このチュートリアルから始めるかたは以下のリンクからソースコードをダウンロードするか,
tutorial3-startのタグが付いているリビジョン(96ab6f4)をチェックアウトしてください.
https://github.com/usa-mimi/tutorial-spa/tree/tutorial3-start
コンポーネントの話
コードを書いていく前に,Vueの コンポーネント について説明していきます.
コンポーネントについて
ページを作っていく前に,コンポーネントについて公式のドキュメントを読んでおくことをおすすめします.
コンポーネントについて公式の説明
Vueではページの部分部分をコンポーネントという単位で分割しており,これを組み合わせることでページを作成していきます.
コンポーネントはDOM構造(html),動作(js),デザイン(css)で構成されています.
そのため,コンポーネントを定義するvueファイルを <template>, <script>, <style> の3つ組で書いていくことになります.
以下のvueファイルの例を示します.
<template>
  <div class="content">
    contents
  </div>
</template>
<script>
export default {
  name: 'SampleComponent',
}
</script>
<style>
.content {
  color: red;
}
</style>
公式の図にあるように,定義されたコンポーネントは別のコンポーネント内で使用することができます.
使用する場合は<script>内で使用したいコンポーネントをimportし,componentsに登録します.
登録したコンポーネントは<template>内でhtmlのタグのように使用することができます.
以下の例を示します.
<template>
  <div>
    <sample-component></sample-component>
  </div>
</template>
<script>
import SampleComopnent from './sample'
export default {
  name: 'OtherComponent',
  components: {
    SampleComponent,
  },
}
</script>
script内のコンポーネント名は CamelCase,template内では kebab-case になっていますが,
区別されないのでどちらをどう書いても大丈夫です.
ただ,慣例的にjsコードでは変数はCamelCaseで書き,htmlやcssではkabab-caseで書かれているので,合わせたほうが無難です.
コンポーネントのスタイルガイドは以下を参考にしてください.
https://github.com/pablohpsilva/vuejs-component-style-guide/blob/master/README-JP.md
コンポーネント単位で分割することで,異なるページでパーツを使いまわしたり,
本当に関心のある処理だけに着目してコードを記述していくことが可能です.
v-btnなどのようにマテリアルデザインのボタンとクリックした際の波打つような動作を記述しただけの小さなコンポーネントもあれば,
ボタンや入力をまとめて登録Formをコンポーネントとして作るのもいいでしょう.
コンポーネントのグローバルな登録
公式ドキュメントを見て分かる通り,自作のコンポーネントをグローバルに登録することも可能です.
例えばmain.jsに下記のように書いておくと,先ほどのother.vue内でscriptでの登録なしに<sample-component>を記述することができるようになります.
import SampleComponent from '@/components/sample'
Vue.component(SampleComponent.name, SampleComponent)
@はproject_root/src/を指すためのエイリアスです.
前回の記事では App.vueのtemplate部に<v-btn>マテリアルボタン</v-btn> と書くことでマテリアルデザインのボタンが配置されましたが,
これは main.js に下記のような2行を追加したからです.
import Vuetify from 'vuetify'
Vue.use(Vuetify)
この2行があるお陰で,Vuetifyがpluginとして登録され,v-btnなどのvuetifyコンポーネントがどこからでも使用できるようになります.
Vue.use(Plugin) は内部的にはVue自身を引数としてinstallメソッドを呼び出すそうです.
実際にコードを辿っていくと, Vuetify内ではさらにvuetify自身が持っているボタンを始めとしたコンポーネントを
Vue.use(SomeComponent) の形で呼び出しています.
VBtnコンポーネントのinstallメソッドを確認すると以下のようになっています.
import VBtn from './VBtn'
/* istanbul ignore next */
VBtn.install = function install (Vue) {
  Vue.component(VBtn.name, VBtn)
}
export default VBtn
上記の通り,やっていることは Vue.component メソッドを利用して v-btn というコンポーネントをグローバルに登録しているだけです.
結果として, App.vue 内で <v-btn> が使えるようになったのでした.
プラグインの全部を読み込まず,特定のコンポーネントだけを利用することももちろん可能です.
vue-router
公式ドキュメント: https://router.vuejs.org/ja/
普段利用するウェブページはリンクを押すとURLが切り替わり,内容が入れ替わります.
この動作を模倣するために,vue-routerというプラグインが用意されています.
vue-routerはURLとコンポーネントを紐付け,特定の位置に紐付けられたコンポーネントを配置します.
<router-view>も<v-btn>のようなコンポーネントと根本的には同じものです.
ただ,表示される内容がボタンではなく,別のコンポーネントを出力するproxy的な役割をしているだけなのです.
App.vueをよくみると <router-view/> というタグがあるのがわかると思います.
ここに紐付けられた別コンポーネントが配置されます.
<template>
  <v-app id="app">
    <div>
      <v-btn>マテリアルボタン</v-btn>
      <img src="./assets/logo.png">
      <router-view/>  # ←←←← router-viewコンポーネント
    </div>
  </v-app>
</template>
componentの紐付けは router/index.js で定義します.
デフォルトでは HelloWorld コンポーネントがルートに紐付けされているのが見て取れます.
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
Vue.use(Router)
export default new Router({
  routes: [
    {
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld,
    },
  ],
})
TOPデザインの修正
ソース:
96ab6f4→5f59a32
コンポーネントやrouterの話が大丈夫そうならTOPのデザインを修正してみましょう.
vuetifyでは決まりきったデザインを用意してくれています.
今回はApp.vueの <template> をリンク先の Default application markup に置き換えてみます.
なお,デフォルトでは中寄せのstyleが記述されていますが,特に必要ないのでこれも消しておきます.
<template>
  <v-app id="app">
    <v-navigation-drawer app></v-navigation-drawer>
    <v-toolbar app></v-toolbar>
    <v-content>
      <v-container fluid>
        <router-view></router-view>
      </v-container>
    </v-content>
    <v-footer app></v-footer>
  </v-app>
</template>
<script>
export default {
  name: 'App',
}
</script>
<style>
</style>
ゴテゴテと書いてますが,名前を見るとなんとなく分かる通り,サイドバー, Header, Footerと中身(v-content)です.
開発サーバーで確認
現在の状態を確認すると,Headerなどの中身は何も書いてないので当然空っぽです.
なお,vuetifyは当然ながらレスポンシブデザインなので,ブラウザのサイズを小さくするとサイドバーが隠れます.
- 確認方法は
 
- frontendディレクトリに移動します.
 - 開発サーバーを起動します.
 
$ npm run dev
質問一覧画面の作成
ページの外側はできましたので(といっても真っ白ですが),次は内容を変えていきましょう.
Djangoチュートリアルの内容は 投票アプリ ですので,ひとまずTOPページで質問一覧を見えるようにしてみます.
質問一覧コンポーネントの用意と表示
ソース:
5f59a32→7b6f2f9
作り方は人それぞれとは思いますが,私はまず見える状態のものを用意し,そこに色々追加していく方法をオススメします.
まず frontend/src/components の下に Poll ディレクトリを用意します.
ディレクトリを作成するのは,投票アプリ用のコンポーネントとして質問一覧表示,投票画面,新規質問追加など複数のコンポーネントが必要になると見込まれるからです.
この中に一覧表示用のコンポーネントとして Index.vue を作ります.
django側のアプリ名が
pollsなので複数形にしたほうがいいかもしれないですが,個人的にアプリ名は単数形派なので...
- Poll/Index.vueの編集
 
<template>
  <div>
    polls
  </div>
</template>
<script>
export default {
  name: 'PollIndex',
}
</script>
ブラウザ上から確認できることが最優先なので内容はこれくらいで大丈夫です.
- router.js(router/index.js)を編集
 
今 / にマッピングされている HelloWorld を PollIndex に差し替えます.
import Vue from 'vue'
import Router from 'vue-router'
import PollIndex from '@/components/Poll/Index'
Vue.use(Router)
export default new Router({
  routes: [
    {
      path: '/',
      name: 'PollIndex',
      component: PollIndex,
    },
  ],
})
- 開発サーバーで確認
 
ここまで書くと,殺風景ではありますがブラウザ上に polls の文字が表示されていると思います.
$ npm run dev
- 
ちなみにrouter/index.js内の
name: 'PollIndex'の部分は逆引き用の名前です. - 
componentが増えてきてURLで切り替えするようになったら説明します.
 
APIからデータ取得
必要ライブラリのインストールとPollIndexの修正
ソース:
7b6f2f9→ad2db08
そのままvuetifyのきれいなデザインを追加するのもいいですが,先にサーバからデータ取得をしましょう.
ajax通信用のライブラリはいくつかありますが,今回は axiosを使用します.
- npmで
axiosライブラリのインストールです. 
$ npm install axios --save
次に,先ほどのPollIndexコンポーネントにAPIの接続処理を書いていきます.
まず,データ取得用に fetchData メソッドを用意します.
このメソッドはページを開いた瞬間に実行したいので, mounted メソッドを書き,そこでfetchDataメソッドを呼び出すようにします.
fetchDataメソッドでは,第1回で作成したAPI(http://localhost:8000/api/1.0/questions/)からデータを取得し,
それをコンソールログに出力してみます.
ソースコードとしては以下のような感じになります.
- poll/Index.vueの編集
 
<script>
import axios from 'axios'
export default {
  name: 'PollIndex',
  methods: {
    fetchData () {
      axios.get('http://localhost:8000/api/1.0/questions/').then(res => {
        console.log(res)
      })
    },
  },
  mounted () {
    this.fetchData()
  },
}
</script>
Vueにはライフサイクルフックが用意されており,特定のタイミングで処理を挟むことができます.
今回はページを開いた時にAPIでデータを取得したいのでmountedに処理を挟みました.
詳しい処理のタイミングや,フックポイントを知りたい方はライフサイクルダイアグラムを確認してください.
- 開発サーバーを起動してブラウザから確認
 
- 
frontendディレクトリに移動 - 
$ npm run devで開発サーバーの起動 
chromeなどでConsoleログを開くと下記のようなエラーが出ているはずです.
Failed to load http://localhost:8000/api/1.0/questions/: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Origin 'http://localhost:8080' is therefore not allowed access.
GET http://localhost:8000/api/1.0/questions/ net::ERR_CONNECTION_REFUSEDとエラーが出ている方はDjangoのサーバが起動していません.
- バックエンド(Django)側のサーバーの起動
 
- 
workonで仮想環境に入ります。 - 
(tutorial-spa) $python manage.py runserverでサーバを起動しましょう. 
- 
venvで仮想環境を起動するには 
作業ディレクトリ(tutorial-spa) → Django環境(tutorial-spa)の順にcdで移動します.その下にbinディレクトリがあるはずです.確認し、
$ source bin/activate
JavaScriptにはセキュリティのため CORS (Cross-Origin Resource Sharing) という仕様が定められており,ブラウザ上から異なるサーバへアクセスする際に,特別なヘッダーが必要になります.
今回のチュートリアルでは,Djangoは localhost:8000,Vueは localhost:8080 で起動しているため,同じlocalhostですが,ポートが違うと別サーバと見なされ,でエラーが発生しています.
Django側の修正
ソース:
ad2db08→fdb7c34
CORS用のヘッダーはサーバ側,つまりDjango側から送信する必要があります.
というわけで,今度はDjango側の修正です.
ライブラリのインストールがあるので,忘れずに仮想環境の起動しておきましょう.
CORS対応もWebサーバとしてはほぼ当たり前の起動ですので,もちろんDjango用のライブラリが用意されています.
(tutorial-spa) $ pip install django-cors-headers
- /tutorial/settings.pyの編集
 
修正箇所は3箇所で,
- INSTALLED_APPSに
corsheadersを追加, - MIDDLEWAREに
corsheaders.middleware.CorsMiddleware追加, - 
CORS_ORIGIN_ALLOW_ALL = Trueを追加,
です. 
INSTALLED_APPS = [
...
    'corsheaders', 
...
]
MIDDLEWARE = [
...
    'corsheaders.middleware.CorsMiddleware',
...
]
...
CORS_ORIGIN_ALLOW_ALL = True
ライブラリをインストールしたので,忘れずに 作業ディレクトリにあるrequirements.txt と constraints.txt を更新しておきましょう.
(tutorial-spa) $ echo 'django-cors-headers' >> requirements.txt
(tutorial-spa) $ pip freeze > constraints.txt
[解説]
>>はファイルの末尾に追加する,というシェルのリダイレクト表記です.
エディタで追加してももちろん大丈夫です.
再度開発サーバーで確認
修正が完了したらブラウザを更新してみましょう.
ちゃんと修正できていればブラウザのコンソールログに下記のように表示されるはずです.
- vue側のサーバーの起動
 
1.frontendディレクトリに移動
npm run dev
- Django側のサーバの起動
 
- 仮想環境の起動
 - manage.pyがある階層に移動
 - サーバーの起動
 
$ python manage.py runserver
Responseの修正(ページネーションの追加)
ソース:
fdb7c34→3ee9bcf
ここまでで取り敢えずデータは取得出来ていますが,内容が配列で直接返ってます.
APIの戻り値としてはトータルの件数や,次のリンクなどが欲しいところです.
ページングの設定,ページネーターはDRFで標準で用意されているので,デフォルト設定を変更してあげれば完了です.
有効にするには,ページサイズ(一回あたりの最大取得件数)と,ページネーションクラス(どういう形式で返すかを定義したクラス)を設定する必要があります.
今回はLimitOffsetPaginationにします.
# =========================
# django-restframework 設定
# =========================
REST_FRAMEWORK = {
    'PAGE_SIZE': 100,
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
}
データの確認
停止していたら、vue側、Django側のサーバーを起動します.
- 以下をクリックしてレコードを確認します.
 
http://localhost:8000/api/1.0/questions/
[解説]
設定前は結果が配列でしたが,設定後は辞書になり,内容が results の中に入っていることがわかります.
設定した件数(今回の設定では100件)以上の件数がある場合, next の中に次の100件を取得するためのURLが返ってきます.
また,101〜200件目を表示しているときには,prev の中に前の100件を取得するためのURLが返ってきます.
count は総数です.
Responseの修正(キャメルケースへの変換)
ソース:
3ee9bcf→3124f4b
pub_date や question_text など,変数名がスネークケースですが,JavaScript的には変数名はキャメルケースが望ましいです.
APIのクライアントによってはスネークケースで欲しい場合もあるため,js側で変数名をキャメルケースに戻してもいいです.
ただ,django側で処理をするなら,これもライブラリで用意されていて手間がかからないので,今回はdjango側で変換することにします.
変換用のライブラリは djangorestframework_camel_case です.
例のごとくpipでインストールします.
(tutorial-spa) $ pip install djangorestframework_camel_case
このライブラリを使って,DRFの出力時(=レンダリング)の処理を変更します.
また,JavaScriptからの入力も恐らくキャメルケースでしょうから,入力時(=パース)の処理も変更します.
先ほどページネーションの処理を加えたように,RENDERERとPARSERを追加してあげるだけです.
# =========================
# django-restframework 設定
# =========================
REST_FRAMEWORK = {
    'PAGE_SIZE': 100,
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
# === 以下を追加
    'DEFAULT_RENDERER_CLASSES': (
        'djangorestframework_camel_case.render.CamelCaseJSONRenderer',
        'rest_framework.renderers.BrowsableAPIRenderer',
    ),
    'DEFAULT_PARSER_CLASSES': (
        'djangorestframework_camel_case.parser.CamelCaseJSONParser',
        'rest_framework.parsers.FormParser',
        'rest_framework.parsers.MultiPartParser'
    ),
# === ここまで
}
再度、vueおよびDjangoのサーバーを起動します。
- 以下をクリックしてレコードを確認します.
 
http://localhost:8000/api/1.0/questions/
ライブラリをインストールしたので,再びrequirements.txtとconstraints.txtを更新しておきましょう.
(tutorial-spa) $ echo djangorestframework_camel_case >> requirements.txt
(tutorial-spa) $ pip freeze > constraints.txt
データ取得, 表示
ソース:
3124f4b→5592d25
APIからのデータがきれいになったので,再度フロントエンドに戻って表示させていきます.
取得した質問一覧を入れるために, data に questions を用意し,
apiの通信完了時にres.data.resultsを代入させます.
<script>
import axios from 'axios'
export default {
  name: 'PollIndex',
# === 以下を追加 === 
  data () {
    return {
      questions: [],
    }
  },
# === ここまで
  methods: {
    fetchData () {
      axios.get('http://localhost:8000/api/1.0/questions/').then(res => {
# === 以下を編集
        this.questions = res.data.results
# === ここまで
      })
    },
  },
  mounted () {
    this.fetchData()
  },
}
</script>
続いてtemplate部を編集し,内容を表示させてみます.
出し方は色々ありますが,今回はカードスタイルで出してみます.
<template>
  <div>
    <v-card>
      <v-container grid-list-lg>
        <v-layout row wrap>
          <v-flex xs6 v-for="data in questions" :key="data.id">
            <v-card color="primary" class="white--text">
              <v-card-title primary-title>
                <div>{{ data.questionText }}</div>
              </v-card-title>
              <v-card-text>
                <div>{{ data.pubDate }}</div>
              </v-card-text>
            </v-card>
          </v-flex>
        </v-layout>
      </v-container>
    </v-card>
  </div>
</template>
サーバーで確認
これで質問内容がカードのように並びます.
- 
<template>内のデザインについてはvuetifyのサイトからコンポーネントの使い方を色々試してみてください. - 
デザインがどうでもいいなら
<div>や<li>で並べるだけでも大丈夫です. 
日付フォーマットの修正
ソース:
5592d25→26e0b3a
apiからの公開日(日時型)の戻り値は 2018-04-03T17:59:57+09:00 のような表示になっていると思います.
これはisoformatと呼ばれ,ISO8601で定められた国際規格の形式です.
年月日時間に加え,タイムゾーン情報も付いており,世界中どこでも使用できますが,少し見辛いです.
JavaScriptではこのような文字列から日時型のオブジェクトを生成し,表示や計算を行うためのmomentjsというライブラリがあります.
今回はmonent.jsを使用して公開日のフォーマットを整えてみます.
ライブラリはnpmでインストールします.
$ npm install moment --save
次にscript内で,日付型のフォーマットを整える処理を書きます.
methodsに定義する方法とfiltersに定義する方法がありますが,今回はfiltersに書きます.
methodsに定義したmethodは,template内から {{ methodName(val) }} のように呼び出し可能です.
また,filtersで定義したmethodは {{ val|filterMethod }} のように呼び出し可能です.
methodは引数の数がいくつでも大丈夫ですが,filterのほうは1つだけしか値を受け取ることができません.
その代わり,filterのほうは適用したい値の後ろにパイプ(|)で繋ぐ形式なので,わかりやすく書きやすいです.
filtersへの定義は以下のような感じになります.
<script>
import axios from 'axios'
# === 以下を追加
import moment from 'moment'
# === ここまで
export default {
...
# === 以下を追加
  filters: {
    printDate (val) {
      return moment(val).locale('ja').format('YYYY年MM月DD日(ddd) HH時mm分ss秒')
    },
  },
# === ここまで
...
</script>
moment に文字列を渡すことで内部的に日時型のオブジェクトに変換してくれます.
今回はisoformatなので間違いないですが,もう少し曖昧な形式でもよしなにパースしてくれます.
また,引数を与えずに moment() とした場合は現在日時を示すオブジェクトを生成します.
その後ろで .locale('ja') は出力時の言語を日本語に設定しています.
formatでmomentオブジェクトを適切な文字列に変換します.
YYYY なら4桁の西暦, ddd は曜日の短縮形(日,月,火...)など,いくつかルールがあります.
もっと詳しい説明が知りたいかたは
公式サイトを参照してください.
filterが書けたらtemplate側を修正しましょう.
上記で書いた通り,filterの呼び出しは {{ val|filterMethod }} の形式で書きます.
...
<div>{{ data.pubDate|printDate }}</div>
...
ブラウザ上で日時フォーマットが適切に設定できていることを確認しましょう.
次はサーバ側にAPIを追加し,投稿処理を行えるようにしてみます.
→ チュートリアル4へ









