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">
<i 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でも公開しているので、参考にしてください。