Asial Blog

Recruit! Asialで一緒に働きませんか?

Vue.js + vuexによるToDoアプリケーションの実装

カテゴリ :
バックエンド(プログラミング)
タグ :
JavaScript
前々回の記事でLaravel 5.4 + Vue.jsの開発環境を構築し、前回の記事でLaravel 5.4によるWeb APIを作成しました。

今回は、作成したWeb APIを使用したToDoリストアプリケーションを、Vue.jsを使って作成します。

PHP側の積み残し



はじめに、昨日のコントローラーを修正します。
もともと、ItemControllerのindex()メソッドは「Item::all()」メソッドで全てのアイテムを返していました。しかし、画面上に表示するのは未完了のアイテムだけです。そこで、index()メソッドを以下のように変更します。

  1. <?php
  2.  
  3.     public function index()
  4.     {
  5.         return response(Item::query()->where('checked', false)->get());
  6.     }

これで、未完了(checkd=false)のアイテムだけを取得できるようになりました。tests/Feature/ItemTest.php にも機能テストを追加しておきましょう。

  1. <?php
  2.  
  3.     public function testIndexReturnsOnlyUncheckedItems()
  4.     {
  5.         $item = Item::query()->find(1);
  6.         $item->checked = 1;
  7.         $item->save();
  8.  
  9.         $response = $this->get('/api/items');
  10.  
  11.         $response->assertStatus(200);
  12.         $this->assertCount(0, $response->json());
  13.     }

次に、ルーティングを変更し、新しいビューテンプレートを作成します。
routes/web.phpは以下のように、'/'へのアクセスでindexというテンプレートを表示するようにします。

  1. <?php
  2.  
  3. /*
  4. |--------------------------------------------------------------------------
  5. | Web Routes
  6. |--------------------------------------------------------------------------
  7. |
  8. | Here is where you can register web routes for your application. These
  9. | routes are loaded by the RouteServiceProvider within a group which
  10. | contains the "web" middleware group. Now create something great!
  11. |
  12. */
  13.  
  14. Route::get('/', function () {
  15.     return view('index');
  16. });

次に、resources/views/index.blade.phpに以下の内容でファイルを追加します。

  1. <!doctype html>
  2. <html>
  3. <head>
  4.     <meta charset="UTF-8">
  5.     <title>ToDo App with Laravel 5.4 + Vue.js</title>
  6.     <link rel="stylesheet" href="css/app.css"/>
  7.     <script type="text/javascript">
  8.         window.Laravel = window.Laravel || {};
  9.         window.Laravel.csrfToken = "{{csrf_token()}}";
  10.     </script>
  11. </head>
  12. <body>
  13. <div id="app"></div>
  14. <script src="js/app.js"></script>
  15. </body>
  16. </html>

以上でバックエンド側の準備は完了です。

完成図



Vue.jsアプリケーションの完成図を示します。



シンプルなToDoリストで、アイテムの一覧・追加・完了・削除の機能を持っています。最終的なファイル構造は以下のようになります。

  1. resources/assets/js
  2. ├── api
  3. │   └── items.js
  4. ├── app.js
  5. ├── bootstrap.js
  6. ├── components
  7. │   ├── App.vue
  8. │   ├── Item.vue
  9. │   ├── ItemList.vue
  10. │   └── NewItem.vue
  11. └── store
  12.     ├── action-types.js
  13.     ├── index.js
  14.     └── mutation-types.js
  15. 3 directories, 10 files

コンポーネントの構成



このアプリケーションのコンポーネントの構成は以下のようになっています。



新しいアイテムを追加するための「NewItem」と、アイテムのリストである「ItemList」が並列になっています。
ItemListの中にはItemが入れ子になっています。
これら全体を包含するのが「App」コンポーネントです。

ここで問題になるのが、NewItemからItemListへのデータの受け渡しです。
新しいアイテムを追加したら、一覧の末尾に追加したいのですが、Vue.jsが提供する機能(propsによるデータの受け渡し)では、コンポーネントに親子関係がある場合にしかできません。
Appコンポーネントが全ての親になっているので、Appコンポーネントにデータを管理する役割をもたせれば何とかなりそうですが…。

と、このようにコンポーネント間通信が必要になった場合には、状態管理のためのライブラリであるvuexの導入を検討するとよいでしょう。

vuexは、以下のコマンドでインストールできます。

  1. $ npm install --save-dev vuex

このアプリケーションでは、状態管理にはvuexを使用します。

アプリケーションのセットアップ



はじめに、不要なコンポーネントを削除します。
resources/assets/js/components から、Example.vue(と、Hello.vue)を削除しましょう。

次に、Vue.jsでvuexを使えるようにする設定を行います。
resources/assets/js/bootstrap.js で、「window.Vue」の定義の直後に以下のコードを追加します。

  1. window.Vue.use(require('vuex'));

これでVue.jsがvuexを使えるようになります。次に、アプリケーションの初期化処理を記述したresources/assets/js/app.jsを見てみます。

  1. /**
  2.  * First we will load all of this project's JavaScript dependencies which
  3.  * includes Vue and other libraries. It is a great starting point when
  4.  * building robust, powerful web applications using Vue and Laravel.
  5.  */
  6.  
  7. require('./bootstrap');
  8.  
  9. /**
  10.  * Next, we will create a fresh Vue application instance and attach it to
  11.  * the page. Then, you may begin adding components to this application
  12.  * or customize the JavaScript scaffolding to fit your unique needs.
  13.  */
  14.  
  15. const App = require('./components/App.vue');
  16. const store = require('./store/').default;
  17.  
  18. const app = new Vue({
  19.     el: '#app',
  20.     store,
  21.     render: h => h(App)
  22. });

ここでは、以下の仕事を行っています。

1. Appコンポーネントの読み込み
2. Storeオブジェクトの読み込み
3. Vueアプリケーションの初期化
4. Appコンポーネントの描画

AppコンポーネントとStoreについては後述します。
Vueアプリケーションの初期化時に、Appコンポーネントの描画を実行させているのがポイントです。

Storeの実装



Fluxの仕組みを解説をすると長くなってしまうので、解説は省略します。
vuexのドキュメントがわかりやすいので、こちらを参照してください。

vuexでは、Vuex.Storeオブジェクトに、アプリケーションの横断的な状態(state)を閉じ込めます。
また、stateの変更(mutation)はミューテーターと呼ばれる関数を介して行う必要があります。
このように、情報の一元化 + アクセス方法の制限によって、アプリケーションがもつデータの管理を行いやすくなるのがvuexの利点です。

依存関係の少ないところから見ていくとわかりやすいので、まずはWeb APIとの通信を行う機能の実装から紹介します。

resources/assets/js/api/items.js

  1. const axios = require('axios');
  2. const API_URI = '/api/items';
  3.  
  4. export const ItemsAPI = {
  5.     getAllUnchecked(callback) {
  6.         axios.get(API_URI)
  7.             .then((response) => {
  8.                 callback(response.data);
  9.             })
  10.             .catch((error) => {
  11.                 console.log(error);
  12.             });
  13.     },
  14.     create(content, callback) {
  15.         axios.post(API_URI, {content: content})
  16.             .then((response) => {
  17.                 callback(response.data);
  18.             })
  19.             .catch((error) => {
  20.                 console.error(error);
  21.             });
  22.     },
  23.     check(id, callback) {
  24.         axios.patch(API_URI + '/' + id, {checked: true})
  25.             .then((response) => {
  26.                 callback(response.data);
  27.             })
  28.             .catch((error) => {
  29.                 console.error(error);
  30.             });
  31.     },
  32.     delete(id, callback) {
  33.         axios.delete(API_URI + '/' + id)
  34.             .then((response) => {
  35.                 callback(response.data);
  36.             })
  37.             .catch((error) => {
  38.                 console.error(error);
  39.             });
  40.     }
  41. };

実行している処理がほとんど同じなので、冗長な感じですが、やってることは簡単で、Web APIへ問合せて、結果をコールバック関数に渡しているだけです。

次に、Storeオブジェクトの実装を示します。

  1. const Vuex = require('vuex');
  2. const {MUTATION} = require('./mutation-types');
  3. const {ACTION} = require('./action-types');
  4. const {ItemsAPI} = require('../api/items');
  5.  
  6. const state = {
  7.     items: []
  8. };
  9.  
  10. const getters = {
  11.     uncheckedItems: (state) => state.items.filter(item => !item.checked)
  12. };
  13.  
  14. const actions = {
  15.     [ACTION.GET_ITEMS] ({commit}) {
  16.         ItemsAPI.getAllUnchecked(items => {
  17.             commit(MUTATION.SET_ITEMS, items);
  18.         });
  19.     },
  20.     [ACTION.CREATE_ITEM] ({commit}, content) {
  21.         ItemsAPI.create(content, (item) => {
  22.             commit(MUTATION.ADD_ITEM, item);
  23.         });
  24.     },
  25.     [ACTION.CHECK_ITEM] ({commit}, id) {
  26.         ItemsAPI.check(id, () => {
  27.             // チェックされたアイテムをリストから削除
  28.             commit(MUTATION.REMOVE_ITEM_BY_ID, id);
  29.         });
  30.     },
  31.     [ACTION.DELETE_ITEM] ({commit}, id) {
  32.         ItemsAPI.delete(id, () => {
  33.             commit(MUTATION.REMOVE_ITEM_BY_ID, id);
  34.         });
  35.     }
  36. };
  37.  
  38. const mutations = {
  39.     [MUTATION.SET_ITEMS] (state, items) {
  40.         state.items = items;
  41.     },
  42.     [MUTATION.ADD_ITEM] (state, item) {
  43.         state.items.push(item);
  44.     },
  45.     [MUTATION.REMOVE_ITEM_BY_ID] (state, id) {
  46.         state.items = state.items.filter(item => item.id !== id);
  47.     }
  48. };
  49.  
  50. export default new Vuex.Store({
  51.     state,
  52.     getters,
  53.     actions,
  54.     mutations
  55. });

stateの中に、このアプリケーションが管理するデータを格納します。
gettersはデータの取得、mutationsはデータの変更を行う関数です。

ミューテーション(mutations)は、必ず同期処理にする必要があります。ミューテションで、HTTPリクエスト等の非同期処理を実行してはいけません。

非同期処理は、アクション(actions)の役割です。アクションは、(1) 非同期処理を実行する (2) ミューテーションを実行する という仕事を行います。外部からデータを取得して、それをstateに格納する際は、アクションを使用します。

アクション/ミューテーションは直接呼び出すことはできません。
外部から呼び出す際は、アクションなら「store.dispatch('アクションの名前')」、ミューテーションなら「store.commit('ミューテーションの名前')」という方式で呼び出す必要があります。
アクション/ミューテーションの名前に「ACTION.GET_ITEMS」といった定数を使用しているのは、文字列による呼び出しが必要だからです。
たとえば、GET_ITEMSというアクションでは、以下のようにしてミューテーションを呼び出しています。

  1. [ACTION.GET_ITEMS] ({commit}) {
  2.     ItemsAPI.getAllUnchecked(items => {
  3.         commit(MUTATION.SET_ITEMS, items);
  4.     });
  5. },

データをサーバに送信する際に実行される流れは以下のようになります。

1. コンポーネントでイベントが発火
2. コンポーネントがStoreのアクションを呼び出す(dispatch)
3. アクションがAPIへ問合せを行う
4. アクションがリクエストの結果に応じてミューテーションを呼び出す(commit)
5. ミューテーションがstateを書き換える
6. 画面上に変更が反映される

コンポーネントの実装



次に、Storeを利用するコンポーネントの側を見ていきます。
まずは、全てのコンポーネントのルートであるAppコンポーネント(resources/assets/js/components/App.vue)です。

  1. <template>
  2.     <div class="container">
  3.         <new-item></new-item>
  4.         <item-list></item-list>
  5.     </div>
  6. </template>
  7.  
  8. <script>
  9.     const NewItem = require('./NewItem.vue');
  10.     const ItemList = require('./ItemList.vue');
  11.  
  12.     export default {
  13.         components: {NewItem, ItemList}
  14.     }
  15. </script>

ここでは、NewItemとItemListを読み込んで、これらをコンテナとなるdiv要素に加えています。

NewItemコンポーネント



NewItemコンポーネント(resources/assets/js/components/NewItem.vue)は以下のようになります。

  1. <template>
  2.     <div class="row new-item">
  3.         <label>
  4.             新しいタスク
  5.             <input type="text" name="content" v-model="content" @keydown.enter="addItem"
  6.                    @compositionstart="composing=true" @compositionend="composing=false">
  7.         </label>
  8.         <input type="submit" value="追加" class="btn btn-sm btn-primary"
  9.                @click="addItem">
  10.     </div>
  11. </template>
  12.  
  13. <script>
  14.     const store = require('../store/').default;
  15.     const {CREATE_ITEM} = require('../store/action-types');
  16.  
  17.     export default {
  18.         data() {
  19.             return {
  20.                 content: '',
  21.                 composing: false // IMEによる入力中か否かのフラグ
  22.             }
  23.         },
  24.         methods: {
  25.             addItem(event) {
  26.                 if (!this.content) return;
  27.                 store.dispatch(CREATE_ITEM, this.content);
  28.                 this.content = '';
  29.             }
  30.         }
  31.     }
  32. </script>
  33.  
  34. <style scoped>
  35.     .new-item {
  36.         margin: 10px 0;
  37.         width: 100%;
  38.         display: flex;
  39.     }
  40.  
  41.     label {
  42.         justify-content: flex-start;
  43.         flex-grow: 1;
  44.     }
  45.  
  46.     input[name=content] {
  47.         width: 80%;
  48.     }
  49.  
  50.     button {
  51.         justify-content: flex-end;
  52.     }
  53. </style>

単純な入力ボックスです。入力ボックス上でEnterキーを押すと送信されるようにしています。注意点は、IMEの状態を管理する必要があるということです。変換モード時のEnterキーで送信されると非常に不便です。ここではcomposition(start/end)というイベントを利用して、IMEの変換モードでは送信を行わないようにしています。

また、前述したように、新しいアイテムを追加したら、ItemListに新しい要素が追加されるようにする必要があります。
ここでは以下の流れで処理を実行しています。

1. NewItemがCREATE_ITEMアクションをdispatch
2. CREATE_ITEMアクションがADD_ITEMミューテーションをcommitしてstate.itemsを書き換え
3. state.itemsが書き換えられたのでItemListにも変更が伝播する

CREATE_ITEMアクションの実装は以下のようになっています。

  1. [ACTION.CREATE_ITEM] ({commit}, content) {
  2.     ItemsAPI.create(content, (item) => {
  3.         commit(MUTATION.ADD_ITEM, item);
  4.     });
  5. },

CREATE_ITEMアクションが呼び出しているADD_ITEMミューテーションの実装は以下のようになっています。
ここでstate.itemsに新しい要素が追加されると、ItemListは新しいItemを描画します。

  1. [MUTATION.ADD_ITEM] (state, item) {
  2.     state.items.push(item);
  3. },

ItemListコンポーネント



次はItemListです。

  1. <template>
  2.     <div class="row">
  3.         <ul class="list-group">
  4.             <item v-for="item in items" v-bind:item="item"></item>
  5.         </ul>
  6.     </div>
  7. </template>
  8.  
  9. <script>
  10.     const Item = require('./Item.vue');
  11.     const store = require('../store/').default;
  12.     const {GET_ITEMS} = require('../store/action-types');
  13.  
  14.     export default {
  15.         components: {Item},
  16.         computed: {
  17.             items: () => store.getters.uncheckedItems
  18.         },
  19.         created() {
  20.             store.dispatch(GET_ITEMS);
  21.         }
  22.     }
  23. </script>

ItemListのポイントは、リストが作成されたタイミング(created())で、GET_ITEMSアクションをdispatchしている点です。
GET_ITEMSアクションは、APIに問合せを行って、アイテムの一覧を取得し、そのデータをstateに格納します。

ItemListは、itemsというcomputedプロパティでstore.getters.uncheckedItemsという関数を使用しています。
この関数は、以下のようにstate.itemsにフィルタリングを行って返します。

  1. const getters = {
  2.     uncheckedItems: (state) => state.items.filter(item => !item.checked)
  3. };

また、ItemListのテンプレートでは、以下のようにitemsプロパティを使用してItemを描画しています。

  1. <item v-for="item in items" v-bind:item="item"></item>

APIからデータが返ってきて、state.itemsが変更されると、以下の流れで情報がでんぱして画面が更新されます。

1. uncheckedItems()が返す値が変わる
2. ItemListのitemsプロパティが返す値が変わる
3. ビューに変更が反映される

Itemコンポーネント



最後がItemコンポーネントです。リストの要素をコンポーネントにするかは意見の分かれるところでしょうが、Itemコンポーネントは、(1) 完了済みのチェックをつける (2) アイテムを削除する という独自の機能を持つため、別コンポーネントとして切り出しています。

  1. <template>
  2.     <li class="list-group-item">
  3.         <input type="checkbox" name="checked" @click="checkItem" v-model="item.checked">
  4.         <span class="content">{{item.content}}</span>
  5.         <button class="btn btn-sm remove-button" @click="deleteItem">
  6.             <i class="glyphicon glyphicon-remove"></i>
  7.         </button>
  8.     </li>
  9. </template>
  10.  
  11. <script>
  12.     const store = require('../store/').default;
  13.     const {CHECK_ITEM, DELETE_ITEM} = require('../store/action-types');
  14.  
  15.     export default {
  16.         props: ['item'],
  17.         methods: {
  18.             checkItem() {
  19.                 store.dispatch(CHECK_ITEM, this.item.id);
  20.             },
  21.             deleteItem() {
  22.                 if (!confirm("削除しますか?")) return;
  23.                 store.dispatch(DELETE_ITEM, this.item.id);
  24.             }
  25.         }
  26.     }
  27. </script>
  28.  
  29. <style scoped>
  30.     li {
  31.         display: flex;
  32.     }
  33.  
  34.     input[name=checked] {
  35.         cursor: pointer;
  36.         margin-right: 10px;
  37.     }
  38.  
  39.     .content {
  40.         flex-grow: 1;
  41.     }
  42.  
  43.     .remove-button {
  44.         align-items: flex-end;
  45.         width: 34px;
  46.         height: 30px;
  47.     }
  48. </style>

ここでもやっていることは単純で、CHECK_ITEMアクションないしDELETE_ITEMアクションをdispatchしているだけです。
このように、Storeに機能を寄せて作ると、肥大化しがちなコンポーネントの実装をシンプルに保つことができます。

まとめ



vuexを使うのは初めてでしたが、責務が分かれてきれいに書ける反面、コード量はどうしても多くなりますね。
今回のアプリケーションくらいなら、Appコンポーネントでデータを一元管理するような実装でも十分かもしれません。

本当はOAuth 2.0による認証機能の実装もやる予定だったのですが、予想以上に分量が膨らんでしまったので、認証機能の実装は次回にします。
コードの全体はGitHubでも公開しているので、参考にしてください。

参考



Vue.js
vuex

アシアルの会社情報

アシアル株式会社はPHP、HTML5、JavaScriptに特化したWebエンジニアリング企業です。ユーザーエクスペリエンス設計から大規模システム構築まで、アシアルメンバーが各々の専門性を通じてインターネットの進化に貢献します。

会社情報詳細

Laravel 5.4でWeb APIを作る

カテゴリ :
バックエンド(プログラミング)
タグ :
PHP
items_json.png
LaravelでWeb APIを作る方法を解説します。

Laravel 5.4で Vue.js開発環境を手軽に作る

カテゴリ :
バックエンド(プログラミング)
タグ :
PHP
JavaScript
hello_laravel.png
Laravelのインストール方法と、フロントエンド開発環境のセットアップ、簡単なVueコンポーネントの作り方を解説します。

2月のソーシャルランチ

カテゴリ :
日常
タグ :
日常
ランチ
グリーンサラダ.JPG
2月のソーシャルランチの概要です。

12月&1月のソーシャルランチ

カテゴリ :
日常
タグ :
日常
ランチ
グルメ
attachment00.jpg
12月と1月のソーシャルランチの模様です。

温泉マーク問題

カテゴリ :
デザイン・UI
タグ :
Onsen UI
マーケティング
newonsen.jpg
この記事はOnsen UI Advent Calendar 2016向けの記事として書きました。

アシアルのマーケティング担当の塚田です。

先日報道されたOnsen UIのマーケティング上、非常に重要なニュースについて書きたいと思います。

Onsen UIが生まれたきっかけ

カテゴリ :
フロントエンド(HTML5)
タグ :
社長BLOG
Monaca
JavaScript
Tech
Cordova
images.png
本記事はOnsen UI Advent Calendar 2016のエントリーです。Onsen UIが生まれたきっかけについて、簡単に紹介したいと思います。

11月のソーシャルランチ

カテゴリ :
日常
タグ :
日常
PB150250.JPG
11月のソーシャルランチの模様です。

「HTML5モバイルアプリDay」大盛況でした!

カテゴリ :
Monaca
タグ :
Monaca
slack_for_ios_upload_720.jpg
「HTML5モバイルアプリDay」のご報告です。

『HTML5モバイルアプリDAY』まもなく開催します!

カテゴリ :
Monaca
タグ :
Monaca
DSC00209.JPG
「HTML5モバイルアプリDAY」のお知らせです。