28
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Python Django チュートリアル SPA編(3)

Last updated at Posted at 2018-04-05

勉強会用の資料です.
今回の記事ではフロント側のデザインを整えていきます.
また,最初のチュートリアルで作成したAPIを修正し,質問一覧を取得,表示させます.

このチュートリアルから始めるかたは以下のリンクからソースコードをダウンロードするか,
tutorial3-start のタグが付いているリビジョン(96ab6f4)をチェックアウトしてください.
https://github.com/usa-mimi/tutorial-spa/tree/tutorial3-start

コンポーネントの話

コードを書いていく前に,Vueの コンポーネント について説明していきます.

コンポーネントについて

ページを作っていく前に,コンポーネントについて公式のドキュメントを読んでおくことをおすすめします.
コンポーネントについて公式の説明

Kobito.QGenep.png

Vueではページの部分部分をコンポーネントという単位で分割しており,これを組み合わせることでページを作成していきます.
コンポーネントはDOM構造(html),動作(js),デザイン(css)で構成されています.
そのため,コンポーネントを定義するvueファイルを <template>, <script>, <style> の3つ組で書いていくことになります.
以下のvueファイルの例を示します.

sample.vue
<template>
  <div class="content">
    contents
  </div>
</template>

<script>
export default {
  name: 'SampleComponent',
}
</script>

<style>
.content {
  color: red;
}
</style>

公式の図にあるように,定義されたコンポーネントは別のコンポーネント内で使用することができます.
使用する場合は<script>内で使用したいコンポーネントをimportし,componentsに登録します.
登録したコンポーネントは<template>内でhtmlのタグのように使用することができます.
以下の例を示します.

other.vue
<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をコンポーネントとして作るのもいいでしょう.

コンポーネントのグローバルな登録

公式ドキュメント:https://jp.vuejs.org/v2/guide/components.html

公式ドキュメントを見て分かる通り,自作のコンポーネントをグローバルに登録することも可能です.
例えばmain.jsに下記のように書いておくと,先ほどのother.vue内でscriptでの登録なしに<sample-component>を記述することができるようになります.

main.js
import SampleComponent from '@/components/sample'

Vue.component(SampleComponent.name, SampleComponent)

@project_root/src/ を指すためのエイリアスです.

前回の記事では App.vueのtemplate部に<v-btn>マテリアルボタン</v-btn> と書くことでマテリアルデザインのボタンが配置されましたが,
これは main.js に下記のような2行を追加したからです.

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メソッドを確認すると以下のようになっています.

node_modules/vuetify/src/components/VBtn/index.js
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/> というタグがあるのがわかると思います.
ここに紐付けられた別コンポーネントが配置されます.

App.vue
<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 コンポーネントがルートに紐付けされているのが見て取れます.

router/index.js
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デザインの修正

ソース: 96ab6f45f59a32

コンポーネントやrouterの話が大丈夫そうならTOPのデザインを修正してみましょう.
vuetifyでは決まりきったデザインを用意してくれています.

https://vuetifyjs.com/ja/layout/pre-defined

今回はApp.vue<template> をリンク先の Default application markup に置き換えてみます.
なお,デフォルトでは中寄せのstyleが記述されていますが,特に必要ないのでこれも消しておきます.

App.vue
<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は当然ながらレスポンシブデザインなので,ブラウザのサイズを小さくするとサイドバーが隠れます.

  • 確認方法は
  1. frontendディレクトリに移動します.
  2. 開発サーバーを起動します.
開発サーバーの起動
$ npm run dev

Kobito.MlSIbZ.png
画面幅が大きい場合

Kobito.OMBYel.png
画面幅が小さい場合

質問一覧画面の作成

ページの外側はできましたので(といっても真っ白ですが),次は内容を変えていきましょう.
Djangoチュートリアルの内容は 投票アプリ ですので,ひとまずTOPページで質問一覧を見えるようにしてみます.

質問一覧コンポーネントの用意と表示

ソース: 5f59a327b6f2f9

作り方は人それぞれとは思いますが,私はまず見える状態のものを用意し,そこに色々追加していく方法をオススメします.
まず frontend/src/components の下に Poll ディレクトリを用意します.
ディレクトリを作成するのは,投票アプリ用のコンポーネントとして質問一覧表示,投票画面,新規質問追加など複数のコンポーネントが必要になると見込まれるからです.
この中に一覧表示用のコンポーネントとして Index.vue を作ります.

django側のアプリ名が polls なので複数形にしたほうがいいかもしれないですが,個人的にアプリ名は単数形派なので...

  • Poll/Index.vueの編集
frontend/src/components/Poll/Index.vue
<template>
  <div>
    polls
  </div>
</template>

<script>
export default {
  name: 'PollIndex',
}
</script>

ブラウザ上から確認できることが最優先なので内容はこれくらいで大丈夫です.

  • router.js(router/index.js)を編集

/ にマッピングされている HelloWorldPollIndex に差し替えます.

frontend/src/router/index.js
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

Kobito.e4wvv8.png

  • ちなみにrouter/index.js内の name: 'PollIndex' の部分は逆引き用の名前です.

  • componentが増えてきてURLで切り替えするようになったら説明します.

APIからデータ取得

必要ライブラリのインストールとPollIndexの修正

ソース: 7b6f2f9ad2db08

そのまま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の編集
frontend/src/components/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 に処理を挟みました.
詳しい処理のタイミングや,フックポイントを知りたい方はライフサイクルダイアグラムを確認してください.

  • 開発サーバーを起動してブラウザから確認
  1. frontendディレクトリに移動
  2. $ 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)側のサーバーの起動
  1. workon で仮想環境に入ります。
  2. (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側の修正

ソース: ad2db08fdb7c34

CORS用のヘッダーはサーバ側,つまりDjango側から送信する必要があります.
というわけで,今度はDjango側の修正です.

ライブラリのインストールがあるので,忘れずに仮想環境の起動しておきましょう.

CORS対応もWebサーバとしてはほぼ当たり前の起動ですので,もちろんDjango用のライブラリが用意されています.

(tutorial-spa) $ pip install django-cors-headers
  • /tutorial/settings.pyの編集

修正箇所は3箇所で,

  1. INSTALLED_APPSにcorsheadersを追加,
  2. MIDDLEWAREにcorsheaders.middleware.CorsMiddleware追加,
  3. CORS_ORIGIN_ALLOW_ALL = True を追加,
    です.
tutorial/settings.py
INSTALLED_APPS = [
...
    'corsheaders', 
...
]

MIDDLEWARE = [
...
    'corsheaders.middleware.CorsMiddleware',
...
]

...

CORS_ORIGIN_ALLOW_ALL = True

ライブラリをインストールしたので,忘れずに 作業ディレクトリにあるrequirements.txtconstraints.txt を更新しておきましょう.

(tutorial-spa) $ echo 'django-cors-headers' >> requirements.txt
(tutorial-spa) $ pip freeze > constraints.txt

[解説]

>> はファイルの末尾に追加する,というシェルのリダイレクト表記です.

エディタで追加してももちろん大丈夫です.

再度開発サーバーで確認

修正が完了したらブラウザを更新してみましょう.
ちゃんと修正できていればブラウザのコンソールログに下記のように表示されるはずです.

  • vue側のサーバーの起動

1.frontendディレクトリに移動

サーバーの起動
npm run dev
  • Django側のサーバの起動
  1. 仮想環境の起動
  2. manage.pyがある階層に移動
  3. サーバーの起動
サーバーの起動
$ python manage.py runserver

Kobito.ciVDM9.png

Responseの修正(ページネーションの追加)

ソース: fdb7c343ee9bcf

ここまでで取り敢えずデータは取得出来ていますが,内容が配列で直接返ってます.
APIの戻り値としてはトータルの件数や,次のリンクなどが欲しいところです.

ページングの設定,ページネーターはDRFで標準で用意されているので,デフォルト設定を変更してあげれば完了です.
有効にするには,ページサイズ(一回あたりの最大取得件数)と,ページネーションクラス(どういう形式で返すかを定義したクラス)を設定する必要があります.
今回はLimitOffsetPaginationにします.

tutorial/settings.py
# =========================
# django-restframework 設定
# =========================
REST_FRAMEWORK = {
    'PAGE_SIZE': 100,
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
}

データの確認

停止していたら、vue側、Django側のサーバーを起動します.

  • 以下をクリックしてレコードを確認します.

http://localhost:8000/api/1.0/questions/

Kobito.ITDkIC.png
設定前

Kobito.z8Cw5i.png
設定後

[解説]
設定前は結果が配列でしたが,設定後は辞書になり,内容が results の中に入っていることがわかります.
設定した件数(今回の設定では100件)以上の件数がある場合, next の中に次の100件を取得するためのURLが返ってきます.
また,101〜200件目を表示しているときには,prev の中に前の100件を取得するためのURLが返ってきます.
count は総数です.

Responseの修正(キャメルケースへの変換)

ソース: 3ee9bcf3124f4b

pub_datequestion_text など,変数名がスネークケースですが,JavaScript的には変数名はキャメルケースが望ましいです.
APIのクライアントによってはスネークケースで欲しい場合もあるため,js側で変数名をキャメルケースに戻してもいいです.
ただ,django側で処理をするなら,これもライブラリで用意されていて手間がかからないので,今回はdjango側で変換することにします.

変換用のライブラリは djangorestframework_camel_case です.
例のごとくpipでインストールします.

(tutorial-spa) $ pip install djangorestframework_camel_case

このライブラリを使って,DRFの出力時(=レンダリング)の処理を変更します.
また,JavaScriptからの入力も恐らくキャメルケースでしょうから,入力時(=パース)の処理も変更します.
先ほどページネーションの処理を加えたように,RENDERERとPARSERを追加してあげるだけです.

tutorial/settings.py
# =========================
# 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/

これで戻り値の変数名がキャメルケースになります.
Kobito.3vDNuH.png

ライブラリをインストールしたので,再びrequirements.txtとconstraints.txtを更新しておきましょう.

(tutorial-spa) $ echo djangorestframework_camel_case >> requirements.txt
(tutorial-spa) $ pip freeze > constraints.txt

データ取得, 表示

ソース: 3124f4b5592d25

APIからのデータがきれいになったので,再度フロントエンドに戻って表示させていきます.
取得した質問一覧を入れるために, dataquestions を用意し,
apiの通信完了時にres.data.resultsを代入させます.

frontend/src/components/Poll/Index.vue
<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部を編集し,内容を表示させてみます.
出し方は色々ありますが,今回はカードスタイルで出してみます.

https://vuetifyjs.com/ja/components/cards

src/components/Poll/Index.vue
<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>

サーバーで確認

これで質問内容がカードのように並びます.

Kobito.ur6FR6.png

  • <template> 内のデザインについてはvuetifyのサイトからコンポーネントの使い方を色々試してみてください.

  • デザインがどうでもいいなら <div><li> で並べるだけでも大丈夫です.

日付フォーマットの修正

ソース: 5592d2526e0b3a

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への定義は以下のような感じになります.

src/components/Poll/Index.vue
<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 }} の形式で書きます.

src/components/Poll/Index.vue
...
<div>{{ data.pubDate|printDate }}</div>
...

ブラウザ上で日時フォーマットが適切に設定できていることを確認しましょう.

Kobito.bXljlT.png


次はサーバ側にAPIを追加し,投稿処理を行えるようにしてみます.
チュートリアル4へ

チュートリアルまとめ

28
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?