Vuexを使用してデータを更新する方法をサンプルアプリを制作して解説します。
このようなアプリ制作ではCRUDすべてを実装すると、学びが多いので、網羅する事のできるTODOリスト管理アプリを制作ていきます。
TODOリスト管理アプリの機能要件は以下になります。
- タスクを取得して表示する(R)
- タスクを追加する(C)
- タスクを完了/未対応切り替えられる(U)
- タスクを消す事ができる(D)
今回のアプリ制作はNuxt.jsとvuexを使用して制作します。
Nuxt.jsについて、開発環境の制作方法については以下の記事を参照ください。
Nuxtについて知っている方も、上記記事をベースに今回のアプリを制作しますので、目を通すとスムーズに進める事が可能とりますのでおすすめです。
制作するアプリのデモ
Vuexとは
Vuexとはアプリケーションの状態を管理する手法を提供するライブラリです。
といってもイメージが付かないかと思いますので説明します。
まず、もしVuexを使用しない場合、Vueではどのようにしてアプリケーションを制作するかを考えてみます。
アプリケーションを制作する際、意識しなければいけないのはデータです。
webアプリは何を提供しているかという事を抽象的にまとめると『ユーザーのイベントに合わせて適切なデータを表示する』と言えます。
ですので、アプリの要はイベントとデータになります。
そのイベントとデータをVueのみで管理しようとすると以下のような構造となります。
親からpropsを通して子へデータを渡し、イベントは$emitで親へ渡す事になります。
小規模なアプリケーションの場合、親子の階層が深くならないのでこれでもよいかもしれませんが、大規模になると、階層が深くなりpropsと$emitを使い、バケツリレーのように受け渡しが頻繁におこります。
こうなると、中間のパーツに末端のデータやイベントの受け渡しを記載しなくてはならず、その処理分のコード無駄に記載し、管理が難しくなってしまします。
さらに、○○パーツの子供は△△パーツでなければいけないなどと依存性が高くなり、再利用性のない設計となります。
このバケツリレーを回避する事ができるのがVuexです。
Vuexのデータ更新の流れ
VuexではFluxのデータ更新の流れを使用してデータを更新します。
Fluxについて以下の記事が参考になります。
VuexはFluxを使用していますが、それぞれ名前や各層の役割が異なり、以下のような構造となっております。
名前 | 説明 |
actions | 非同期処理を記載します。 |
mutations | stateの状態を更新する処理を記載します。 |
state | アプリの状態(データ)を記載します。 |
getters | sateの値は直接参照するのではなく、こちらに記載した処理を通じて取得します。 |
上記4つの処理をactions→mutations→sate→gettersの順で通してデータを更新します。
例えば、APIからタスクデータを取得して表示するという処理をVuexを使って記載すると以下のようになります。
export const actions = { async getData({commit}) { // 1: APIを叩いてデータを取得する処理 await // 2: commitを使用してmutationsの中のsetData関数を実行する(第二引数から取得したデータを渡す事ができる) } } export const mutations = { setData(state, todo) { // 3: stateの中のtodoListにtodoのデータをセットする } } export const state = { todoList: [] } export const getters = { getTodoList(state) { // 4: todoListのデータを取得する return state.todoList; } }
表にあるactions,mutations,sate,gettersをそれぞれconstでobjectとして定義し、exportします。
その中にメソッドを制作して各処理を記載します。
処理の内容は表の説明部分に記載した通り、それぞれに役割があり、例えばactionsには非同期処理を記載しなければいけまんせん。
もちろん、非同期処理の必要のない場合はactionsを飛ばしてmutationsから始めてもかまいません。
とにかく、非同期処理が必要ならば、actionsに書きます。
そして、流れで行くと次はmutationsです。
mutationsにはstateを更新する処理を記載します。
actionsでstateを更新する処理を記載する事も可能ですが、一貫性をもたせ、管理しやすくするためにルールを守り秩序を持たせます。
そして、stateが更新されると、Vueが変更を自動で検知し、DOMの再レンダリングが行われます。
再レンダリングをする際に使用するデータの取得はstateを直接見るのでわなく、gettersに記載してある取得用の関数を使用してデータを取得します。
これがVuexでの状態管理の一連の流れです。
ページを制作する
pages配下に今回制作するアプリが入るディレクトリを制作します。
名前は何でもよいですが、vuexTutとしました。
こちらにindex.vueを制作してページを生成します。
pages vuexTut index.vue
ページのテンプレートです。
<template> <div> <h2>Vuex tutorial page</h2> </div> </template> <script> export default { name: 'vuexTut', } </script> <style scoped> </style>
こちらにTODOを一覧表示します。
タスク一覧を表示する
まずはタスクを表示しましょう。
タスクを表示するためにはタスクデータを定義する必要がありますのでそのデータを格納する場所の制作から行います。
NuxtでVuexを使用する場合、storeディレクトリ内に定義する事で自動的にVuexとして機能します。
ですのでstore/todo.jsを制作します。
そして、『データ更新の流れ』の部分を参考にactions,mutations,sate,getters を制作していきます。
WebAPIを使用しデータを取得しますのでaxiosもインポートしておきましょう。
import axios from "axios"; export const state = { list: [], }; export const mutations = { } export const getters = { } export const actions = { }
ここにデータやイベント時の処理を記載していく事でアプリを制作てきます。
stateにはアプリ内で使用する状態(データ)をセットします。
今回はTODOをlistという配列に格納する事にします。
WebAPIをたたいてデータを取得する
APIを制作するのは大変なので、jsonplaceholderを使用します。
APIのエンドポイントをファイルの頭に記載し、actionsには以下の処理を追加します。
const END_POINT = 'https://jsonplaceholder.typicode.com/todos'; ...略 async getTodo( {commit} ) { try { const res = await axios.get(END_POINT); console.log(${res.data}); } catch (error) { console.log(`error: ${error}`); } }
getTodoというメソッドを用意し、引数からcommitを取得します。
本来、引数にはconstextをとり、context.commitという形で関数を取得しますが{}で囲う事で context 中のプロパティ名を指定して取得する事が可能となります。
今回使用するのはcommit処理です。
こちらのactions/getTodoを実行するためにindex.vueからactionsをdispatchで呼び出します。
created() { this.$store.dispatch('todo/getTodo'); },
createdメソッドを追加してコンポーネントが生成されたタイミングでgetTodoを実行します。
Vuexの流れの図を思い出してください。
actionsを実行させるためにはdispatchを使用する必要がありました。
this.$store.dispatch();と記載し、引数からどの処理を実行するかの情報を渡します。
第一引数に実行する処理の名前を渡す必要がありますが、その名前は階層構造を『/』でつなぐ形となります。
storeの中にtoto.jsを制作し、その中に処理を定義しましたのでtodo/getTodoとなります。
第二引数をセットする事も可能で、セットしたものはactionsのメソッドの第二引数から取得可能です。
取得したデータをセットする
現在は取得したデータをconsole.logで表示しているだけです。
一覧ページに表示するために、listに取得したデータをセットしましょう。
state内の値を更新するためには必ずmutationsから行う必要があります。
ですのでmutationsに以下の処理を追加しましょう。
setTodos(state, todos) { state.list = todos; },
上記コードを記載しただけでは意味がなく、こちらの処理を実行する必要があります。
mutationsの処理を実行するにはdispatchと同じような形でcommitという関数から実行します。
async getTodo( {commit} ) { try { const res = await axios.get(END_POINT); console.log(${res.data}); commit('setTodos', res.data); // ← 追加 } catch (error) { console.log(`error: ${error}`); } }
第一引数に実行する処理の名前を、第二引数に渡す値をセットしています。
これでstate/listにデータが格納されました。
このstate/listの値を取得する事でTODOを一覧ページに表示する事ができます。
listデータを取得してTODOをレンダリングする
lsitのデータを取得する方法ですが、Vuexの場合、直接lsitのデータを取得する事は推奨されていません。
gettersを使用すしてstate内のデータを取得します。
以下のコードをgettersに追加します。
getList:(state) => { return state.list },
このメソッドを実行してlsitのデータを取得します。
index.vueを以下のように変更してデータをレンダリングしましょう。
<template> <div> <h2>Vuex tutorial page</h2> <div class="p-todos"> <ul> <li class="p-todos__item" v-for="(todo, index) in todos" :key="'todo-' + index" > <span>{{todo.title}}</span> </li> </ul> </div> </div> </template> <script> export default { name: 'vuexTut', computed: { todos() { return this.$store.getters['todo/getList']; }, }, created() { this.$store.dispatch('todo/getTodo'); }, } </script> <style scoped> .p-todos { margin-top: 28px; } .p-todos__item { background-color: #ccc; border: #5a5a5a 1px solid; cursor: pointer; user-select: none; list-style: none; padding: 5px 16px; position: relative; border-radius: 3px; } .p-todos__item + .p-todos__item { margin-top: 3px; } </style>
styleを当てて軽く見た目を整えています。
computedにtodosメソッドを追加しgettersを使ってgettersに記載した処理を実行し、state内のlistデータを取得します。
取得したlistデータをv-forでループさせ、各タスクをレンダリングしています。
タスクを追加する
タスクを追加するためのinputとボタンを追加します。
ボタンにはクリックイベントを持たせ、クリックでメソッドを実行できるようにしておきます。
--- template --- <input type="text" v-model="value" placeholder="enter new todo"/> <button @click="addButtonClickHandler">add</button> --- methods --- addButtonClickHandler: function() { this.$store.dispatch('todo/addTodo', this.value); this.value = ''; }
state/todo.jsのactionsにAPIを通じてタスクを追加する処理を記載します。
async addTodo( {commit} , title) { try { const res = await axios.post(END_POINT,{title, completed: false, user: 1}); commit('addTodo', res.data); } catch (error) { console.log(`error: ${error}`); } },
mutationsにstate/listを更新する処理を追記します。
addTodo(state, todo) { state.list.unshift(todo); },
タスクを消去する
各タスクに消去するためのボタンを追加しましょう。
index.vueを以下のように変更します。
--- template --- <li class="p-todos__item" v-for="(todo, index) in todos" :key="'todo-' + index" > <span>{{todo.title}}</span> <button class="p-todos__delete-button" @click="removeTodoHandler(todo)">remove</button> </li> --- methods --- removeTodoHandler(todo) { this.$store.dispatch('todo/deleteTodo', todo.id); }, --- style --- .p-todos__delete-button { background-color: #fff; padding: 3px 10px; font-size: 12px; border: solid #5a5a5a 1px; border-radius: 3px; position: absolute; right: 16px; top: 50%; transform: translate(0, -50%); cursor: pointer; }
button要素を追加してクリックいイベントにremoveTodoHandlerを持たせます。
removeTodoHandlerではdispatchを使用してdeleteTodoメソッドを実行し、引数からクリックしたアイテムのデータを渡します。
deleteTodo メソッドをactionsに追加しましょう。
async deleteTodo( {commit}, id) { try { commit('removeTodo', id); await axios.delete(END_POINT + `/${id}`); } catch(error) { console.log(`error: ${error}`); } },
API通信の処理を実行し、 mutationsからstate/listの値を更新します。
今回はjsonplaceholderを使用しているので、本当に値を消す事はできません。
ですので、listからデータのindex番号を特定し、そのindex番めの値を配列から消す事で消去機能を実装します。
removeTodo(state, id) { let res = null; state.list.map((item, index) => { if(item.id === id) res = index; }); if(res !== null) state.list.splice(res, 1); },
mapを使用して配列の全てに対してクリックしたアイテムかどうかを確認し、合致したものをlistからけします。
タスクを完了/未完了切り替え可能にする
各タスクをダブルクリックする事で完了と未完了を切り替える事ができるようにします。
index.vueを変更してダブルクリックイベントを持たせます。
--- template --- <ul> <li v-for="(todo, index) in todos" :key="'todo-' + index" class="p-todos__item" v-bind:class="[{'-completed': todo.completed}]" @dblclick="upDataTodoHandler(todo)" > <span>{{todo.title}}</span> <button class="p-todos__delete-button" @click="removeTodoHandler(todo)">remove</button> </li> </ul> --- methods --- upDataTodoHandler(todo) { let upDateTodo = {...todo}; upDateTodo.completed = !upDateTodo.completed; this.$store.dispatch('todo/upDataTodo', upDateTodo); }, --- style --- .p-todos__item.-completed { background: #5a5a5a; color: #fff; text-decoration: line-through; }
vueでは@dblclickを使用する事でダブルクリックに対してイベントをつける事がかのうです。
タスクの完了/未完了はcompletedのboolean値で管理しています。
upDataTodoHandlerにはdispatchを使用してupDataTodoという処理を実行するようにしていますが、引数から渡す値はすでにcompletedの値を反転させたものになります。
ですが、Vuexのルールに mutations とありますので、単純にupDataTodoHandlerの引数から取得した completed の値を加工してしまうとルールを破ってしまう事になります。
ですので、{…}を使用し、オブジェクトをコピーして参照を変える事でルールを守っています。
次にupDataTodoをactionsに追記します。
async upDataTodo( {commit}, upDateTodo) { try { commit('upDataTodo', upDateTodo); const res = await axios.put(END_POINT + `/${upDateTodo.id}`); } catch (error) { console.log(`error: ${error}`); } }
mutations に upDataTodoを追記し、そこからstateの更新を行います。
upDataTodo(state, todo) { const index = state.list.findIndex(item => item.id === todo.id); if(index !== -1) { state.list.splice(index, 1, todo); } }
こちらもAPIの内容を実際に更新する事はできないのでlsitから該当するアイテムの状態を更新します。
以上で完成となります。
コード
今回制作したアプリのコードです。
pages/vuexTut/index.vue
<template> <div> <h2>Vuex tutorial page</h2> <input type="text" v-model="value" placeholder="enter new todo"/> <button @click="addButtonClickHandler">add</button> <div class="p-todos"> <ul> <li v-for="(todo, index) in todos" :key="'todo-' + index" class="p-todos__item" v-bind:class="[{'-completed': todo.completed}]" @dblclick="upDataTodoHandler(todo)" > <span>{{todo.title}}</span> <button class="p-todos__delete-button" @click="removeTodoHandler(todo)">remove</button> </li> </ul> </div> </div> </template> <script> export default { name: 'vuexTut', data() { return { value: null } }, methods: { addButtonClickHandler: function() { this.$store.dispatch('todo/addTodo', this.value); this.value = ''; }, removeTodoHandler(todo) { this.$store.dispatch('todo/deleteTodo', todo.id); }, upDataTodoHandler(todo) { let upDateTodo = {...todo}; upDateTodo.completed = !upDateTodo.completed; this.$store.dispatch('todo/upDataTodo', upDateTodo); }, }, computed: { todos() { return this.$store.getters['todo/getList']; }, }, created() { this.$store.dispatch('todo/getTodo'); }, } </script> <style scoped> ul { padding: 0; } .p-todos { margin-top: 28px; } .p-todos__item { background-color: #ccc; border: #5a5a5a 1px solid; cursor: pointer; user-select: none; list-style: none; padding: 5px 16px; position: relative; border-radius: 3px; } .p-todos__item.-completed { background: #5a5a5a; color: #fff; text-decoration: line-through; } .p-todos__item + .p-todos__item { margin-top: 3px; } .p-todos__delete-button { background-color: #fff; padding: 3px 10px; font-size: 12px; border: solid #5a5a5a 1px; border-radius: 3px; position: absolute; right: 16px; top: 50%; transform: translate(0, -50%); cursor: pointer; } </style>
store/todo.js
import axios from "axios"; const END_POINT = 'https://jsonplaceholder.typicode.com/todos'; export const state = { list: [], }; export const mutations = { setTodos(state, todos) { state.list = todos; }, addTodo(state, todo) { state.list.unshift(todo); }, removeTodo(state, id) { let res = null; state.list.map((item, index) => { if(item.id === id) res = index; }); if(res !== null) state.list.splice(res, 1); }, upDataTodo(state, todo) { const index = state.list.findIndex(item => item.id === todo.id); if(index !== -1) { state.list.splice(index, 1, todo); } } } export const getters = { getList:(state) => { return state.list } } export const actions = { async getTodo( {commit} ) { try { const res = await axios.get(END_POINT); console.log(res.data); commit('setTodos', res.data); } catch (error) { console.log(`error: ${error}`); } }, async addTodo( {commit} , title) { try { const res = await axios.post(END_POINT,{title, completed: false, user: 1}); commit('addTodo', res.data); } catch (error) { console.log(`error: ${error}`); } }, async deleteTodo( {commit}, id) { try { commit('removeTodo', id); await axios.delete(END_POINT + `/${id}`); } catch(error) { console.log(`error: ${error}`); } }, async upDataTodo( {commit}, upDateTodo) { try { commit('upDataTodo', upDateTodo); const res = await axios.put(END_POINT + `/${upDateTodo.id}`); } catch (error) { console.log(`error: ${error}`); } } }