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

前々回の記事でLaravel 5.4 + Vue.jsの開発環境を構築し、前回の記事でLaravel 5.4によるWeb APIを作成しました。

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

PHP側の積み残し

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


<?php
    public function index()
    {
        return response(Item::query()->where('checked', false)->get());
    }

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


<?php
    public function testIndexReturnsOnlyUncheckedItems()
    {
        $item = Item::query()->find(1);
        $item->checked = 1;
        $item->save();
        $response = $this->get('/api/items');
        $response->assertStatus(200);
        $this->assertCount(0, $response->json());
    }

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


<?php
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
Route::get('/', function () {
    return view('index');
});

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


<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>ToDo App with Laravel 5.4 + Vue.js</title>
    <link rel="stylesheet" href="css/app.css"/>
    <script type="text/javascript">
        window.Laravel = window.Laravel || {};
        window.Laravel.csrfToken = "{{csrf_token()}}";
    </script>
</head>
<body>
<div id="app"></div>
<script src="js/app.js"></script>
</body>
</html>

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

完成図

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

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


resources/assets/js
├── api
│   └── items.js
├── app.js
├── bootstrap.js
├── components
│   ├── App.vue
│   ├── Item.vue
│   ├── ItemList.vue
│   └── NewItem.vue
└── store
    ├── action-types.js
    ├── index.js
    └── mutation-types.js
3 directories, 10 files

 

コンポーネントの構成

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

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

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

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

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


$ npm install --save-dev vuex

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

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

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

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


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

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


/**
 * First we will load all of this project's JavaScript dependencies which
 * includes Vue and other libraries. It is a great starting point when
 * building robust, powerful web applications using Vue and Laravel.
 */
require('./bootstrap');
/**
 * Next, we will create a fresh Vue application instance and attach it to
 * the page. Then, you may begin adding components to this application
 * or customize the JavaScript scaffolding to fit your unique needs.
 */
const App = require('./components/App.vue');
const store = require('./store/').default;
const app = new Vue({
    el: '#app',
    store,
    render: h => h(App)
});

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

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


const axios = require('axios');
const API_URI = '/api/items';
export const ItemsAPI = {
    getAllUnchecked(callback) {
        axios.get(API_URI)
            .then((response) => {
                callback(response.data);
            })
            .catch((error) => {
                console.log(error);
            });
    },
    create(content, callback) {
        axios.post(API_URI, {content: content})
            .then((response) => {
                callback(response.data);
            })
            .catch((error) => {
                console.error(error);
            });
    },
    check(id, callback) {
        axios.patch(API_URI + '/' + id, {checked: true})
            .then((response) => {
                callback(response.data);
            })
            .catch((error) => {
                console.error(error);
            });
    },
    delete(id, callback) {
        axios.delete(API_URI + '/' + id)
            .then((response) => {
                callback(response.data);
            })
            .catch((error) => {
                console.error(error);
            });
    }
};

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

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


const Vuex = require('vuex');
const {MUTATION} = require('./mutation-types');
const {ACTION} = require('./action-types');
const {ItemsAPI} = require('../api/items');
const state = {
    items: []
};
const getters = {
    uncheckedItems: (state) => state.items.filter(item => !item.checked)
};
const actions = {
    [ACTION.GET_ITEMS] ({commit}) {
        ItemsAPI.getAllUnchecked(items => {
            commit(MUTATION.SET_ITEMS, items);
        });
    },
    [ACTION.CREATE_ITEM] ({commit}, content) {
        ItemsAPI.create(content, (item) => {
            commit(MUTATION.ADD_ITEM, item);
        });
    },
    [ACTION.CHECK_ITEM] ({commit}, id) {
        ItemsAPI.check(id, () => {
            // チェックされたアイテムをリストから削除
            commit(MUTATION.REMOVE_ITEM_BY_ID, id);
        });
    },
    [ACTION.DELETE_ITEM] ({commit}, id) {
        ItemsAPI.delete(id, () => {
            commit(MUTATION.REMOVE_ITEM_BY_ID, id);
        });
    }
};
const mutations = {
    [MUTATION.SET_ITEMS] (state, items) {
        state.items = items;
    },
    [MUTATION.ADD_ITEM] (state, item) {
        state.items.push(item);
    },
    [MUTATION.REMOVE_ITEM_BY_ID] (state, id) {
        state.items = state.items.filter(item => item.id !== id);
    }
};
export default new Vuex.Store({
    state,
    getters,
    actions,
    mutations
});

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

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

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

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


[ACTION.GET_ITEMS] ({commit}) {
    ItemsAPI.getAllUnchecked(items => {
        commit(MUTATION.SET_ITEMS, items);
    });
},

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

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

コンポーネントの実装

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


<template>
    <div class="container">
        <new-item></new-item>
        <item-list></item-list>
    </div>
</template>
<script>
    const NewItem = require('./NewItem.vue');
    const ItemList = require('./ItemList.vue');
    export default {
        components: {NewItem, ItemList}
    }
</script>

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

NewItemコンポーネント

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


<template>
    <div class="row new-item">
        <label>
            新しいタスク
            <input type="text" name="content" v-model="content" @keydown.enter="addItem"
                   @compositionstart="composing=true" @compositionend="composing=false">
        </label>
        <input type="submit" value="追加" class="btn btn-sm btn-primary"
               @click="addItem">
    </div>
</template>
<script>
    const store = require('../store/').default;
    const {CREATE_ITEM} = require('../store/action-types');
    export default {
        data() {
            return {
                content: '',
                composing: false // IMEによる入力中か否かのフラグ
            }
        },
        methods: {
            addItem(event) {
                if (!this.content) return;
                store.dispatch(CREATE_ITEM, this.content);
                this.content = '';
            }
        }
    }
</script>
<style scoped>
    .new-item {
        margin: 10px 0;
        width: 100%;
        display: flex;
    }
    label {
        justify-content: flex-start;
        flex-grow: 1;
    }
    input[name=content] {
        width: 80%;
    }
    button {
        justify-content: flex-end;
    }
</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アクションの実装は以下のようになっています。


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

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


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

 

ItemListコンポーネント

次はItemListです。


<template>
    <div class="row">
        <ul class="list-group">
            <item v-for="item in items" v-bind:item="item"></item>
        </ul>
    </div>
</template>
<script>
    const Item = require('./Item.vue');
    const store = require('../store/').default;
    const {GET_ITEMS} = require('../store/action-types');
    export default {
        components: {Item},
        computed: {
            items: () => store.getters.uncheckedItems
        },
        created() {
            store.dispatch(GET_ITEMS);
        }
    }
</script>

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

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


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

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


<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) アイテムを削除する という独自の機能を持つため、別コンポーネントとして切り出しています。


<template>
    <li class="list-group-item">
        <input type="checkbox" name="checked" @click="checkItem" v-model="item.checked">
        <span class="content">{{item.content}}</span>
        <button class="btn btn-sm remove-button" @click="deleteItem">
            <class="glyphicon glyphicon-remove"></i>
        </button>
    </li>
</template>
<script>
    const store = require('../store/').default;
    const {CHECK_ITEM, DELETE_ITEM} = require('../store/action-types');
    export default {
        props: ['item'],
        methods: {
            checkItem() {
                store.dispatch(CHECK_ITEM, this.item.id);
            },
            deleteItem() {
                if (!confirm("削除しますか?")) return;
                store.dispatch(DELETE_ITEM, this.item.id);
            }
        }
    }
</script>
<style scoped>
    li {
        display: flex;
    }
    input[name=checked] {
        cursor: pointer;
        margin-right: 10px;
    }
    .content {
        flex-grow: 1;
    }
    .remove-button {
        align-items: flex-end;
        width: 34px;
        height: 30px;
    }
</style>

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

まとめ

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

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

参考

Vue.js
vuex

前の記事へ

次の記事へ

一覧へ戻る
PAGE TOP