Vuex で状態管理を行っているアプリケーションでは、基本的にストアは1つだと思います。
アプリケーションが大きくなるにつれストアで管理する必要がある状態は多くなった場合、沢山のステートやミューテーションなどを一つのオブジェクトで管理する事になってしまうでしょう。
そういった事態を防ぐ為に、Vuex にはモジュールオプションが用意されています。
これに加えて、個人的にモジュールオプションを使用する事のメリットとして、管理するステートに紐づくオプションをまとめてグループ化できる事だと感じています。
私がVuexのモジュールオプションについて調べた時、分割したモジュールのステートへのアクセスや更新を行う方法に混乱して、なかなか理解が進みませんでした。
個人的な振り返りも含めて、簡単な「TODOアプリ」の作成を通して Vuex のモジュールの使用について記事にします。
この記事では、以下の環境で作業を進めていきます。
- node -> 10.15.3
- npm -> 6.4.1
- @vue/cli -> 3.8.4
プロジェクトの作成
まずは、適当なディレクトリでプロジェクトを作成します。
vue create vue-todo-app
Vuexを使用する為、Manually select features
を選択し、その後Vuexを選びます。
? Please pick a preset:
default (babel, eslint)
❯ Manually select features
? Check the features needed for your project:
◉ Babel
◯ TypeScript
◯ Progressive Web App (PWA) Support
◯ Router
❯◉ Vuex
◯ CSS Pre-processors
◉ Linter / Formatter
◯ Unit Testing
◯ E2E Testing
その他、いくつかの質問が表示されるので回答していきます。
今回は以下のように選択を進めました。
? Pick a linter / formatter config: (Use arrow keys)
❯ ESLint with error prevention only
ESLint + Airbnb config
ESLint + Standard config
ESLint + Prettier
? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i
> to invert selection)
❯◉ Lint on save
◯ Lint and fix on commit
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? (Use arro
w keys)
❯ In dedicated config files
In package.json
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedica
ted config files
? Save this as a preset for future projects? (y/N) N
インストールが完了したら、開発環境を実行します。
cd vue-todo-app
yarn serve
続いて、src/App.vue
を編集します。
<template>
<div id="app">
<h1>Vuex module option demo</h1>
</div>
</template>
<script>
export default {
name: 'app'
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
margin-top: 60px;
}
</style>
ストアの作成
@vue/cli でプロジェクトを作成した場合は、src/store.js
にストアを定義したファイルが配置されています。
モジュールオプションを使用する際に ES Modules を使用する為にディレクトリの構成を変更していきます。
mkdir src/store
mkdir src/store/modules
mv src/store.js src/store/store.js
続いて、タスクを管理するモジュールとしてtasks.js
と、担当者を管理するモジュールとしてpersons.js
をsrc/store/modules/配下
に作成します。
touch src/store/modules/tasks.js src/store/modules/persons.js
最後にsrc/main.js
でストアの読み込み先を変更します。
import Vue from 'vue'
import App from './App.vue'
import store from './store/store'
Vue.config.productionTip = false
new Vue({
store,
render: h => h(App)
}).$mount('#app')
モジュールにステートを定義をして読み込む
export default {
state: {
tasks: [
{id: 1, name: 'sample task 1', status: true, personId: 1},
{id: 2, name: 'sample task 1', status: false, personId: 2},
],
},
}
export default {
state: {
persons: [
{id: 1, name: '一郎'},
{id: 2, name: '次郎'}
]
}
}
modulesのオブジェクトの中でインポートしたモジュールを読み込みます、
import Vue from 'vue'
import Vuex from 'vuex'
import tasks from './modules/tasks'
import persons from './modules/persons'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
tasks,
persons
}
})
ストアに登録したモジュールのステートをコンポーネント側で読み込む
モジュールとして分割した場合でもステートを読み込む方法は変わりません。
コンポーネント側で読み込んだステートはモジュールの名前が付いたオブジェクトにラップされた状態となります。
これによってステートは、モジュール毎にスコープを分離する事ができます。
tasks
というモジュールをmapState
で読み込んだ場合は、コンポーネント側では以下のようなオブジェクトとして保持しています。
{
// ~~~ 省略 ~~~~
tasks: {
tasks: [
{id: 1, name: 'sample task 1', status: true, personId: 1},
{id: 2, name: 'sample task 2', status: false, personId: 2},
]
},
// ~~~ 省略 ~~~~
}
この点を踏まえた上で、src/App.vue
を編集します。
src/App.vue
で読み込んだステートを TaskList コンポーネントに props として渡します。
<template>
<div id="app">
<h1>Vuex module option demo</h1>
<task-list
:taskList="tasks.tasks"
:personList="persons.persons"
/>
</div>
</template>
<script>
import Vuex from 'vuex'
import TaskList from '@/components/TaskList'
export default {
name: 'app',
components: {
TaskList,
},
computed: {
...Vuex.mapState(['tasks', 'persons']),
}
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
margin-top: 60px;
}
</style>
src/components/配下
に TaskList コンポーネントを作成します。
<template>
<ul class="task-list">
<li v-for="task in taskList" :key="task.id">
<label>
<input type="checkbox" :checked="task.status">
<span>{{task.name}}</span>
<span> / </span>
<span>担当者: {{getPersonName(task.personId)}}</span>
</label>
</li>
</ul>
</template>
<script>
export default {
name: 'TaskList',
props: {
taskList: {
type: Array,
default: () => [],
},
personList: {
type: Array,
default: () => [],
},
},
methods: {
getPersonName (id) {
const person = this.personList.find((person) => {
return person.id === id
})
return person ? person.name : '未設定'
},
},
}
</script>
<style scoped>
.task-list {
list-style: none;
padding-left: 0;
}
</style>
これで、ストアにモジュールとして登録したステートを使用する事ができました。
ステートの状態を更新する
チェックボックスの状態が変更されたタイミングでタスクのステートを変更できるようにします。
まずは、src/store/modules/tasks.js
にミューテーションとアクションを定義します。
export default {
state: {
tasks: [
{id: 1, name: 'sample task 1', status: true, personId: 1},
{id: 2, name: 'sample task 2', status: false, personId: 2},
],
},
mutations: {
changeCheckStatus (state, {id, checked}) {
const tasks = state.tasks.slice()
const task = tasks.find((task) => {
return task.id === id
})
task.status = checked
state.tasks = tasks
},
},
actions: {
changeCheckStatus ({commit}, payload) {
commit('changeCheckStatus', payload)
},
},
}
次に、src/App.vue
側で先程定義したアクションを読み込みます。
<template>
<div id="app">
<h1>Vuex module option demo</h1>
<task-list
:taskList="tasks.tasks"
:personList="persons.persons"
@changeCheckStatus="changeCheckStatus"
/>
</div>
</template>
<script>
import Vuex from 'vuex'
import TaskList from '@/components/TaskList'
export default {
name: 'app',
components: {
TaskList,
},
computed: {
...Vuex.mapState(['tasks', 'persons']),
},
methods: {
...Vuex.mapActions(['changeCheckStatus']),
},
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
margin-top: 60px;
}
</style>
最後に TaskList コンポーネント側から、input タグで change イベントが発生するタイミングで$emit()
を実行します。
<template>
<ul class="task-list">
<li v-for="task in taskList" :key="task.id">
<label>
<input type="checkbox" :checked="task.status" @change="handleCheck($event, task.id)">
<span>{{task.name}}</span>
<span> / </span>
<span>担当者: {{getPersonName(task.personId)}}</span>
</label>
</li>
</ul>
</template>
<script>
export default {
name: 'TaskList',
props: {
taskList: {
type: Array,
default: () => [],
},
personList: {
type: Array,
default: () => [],
},
},
methods: {
getPersonName (id) {
const person = this.personList.find((person) => {
return person.id === id
})
return person ? person.name : '未設定'
},
handleCheck (e, id) {
this.$emit('changeCheckStatus', {
id: id,
checked: e.currentTarget.checked,
})
},
},
}
</script>
<style scoped>
.task-list {
list-style: none;
padding-left: 0;
}
</style>
モジュールとして登録されているミューテーション・アクション・ゲッターは、モジュールを使用しない場合と同じようにコンポーネント側での読み込み・使用が可能です。
しかし、モジュールを分けていても同じスコープ上に各ミューテーション・アクション・ゲッターが登録される為、モジュール間で使用している名前が競合する場合があります。
試しに tasks モジュールと persons モジュールに test というミューテーションを定義してみます。
export default {
state: {
tasks: [
{id: 1, name: 'sample task 1', status: true, personId: 1},
{id: 2, name: 'sample task 2', status: false, personId: 2},
],
},
mutations: {
changeCheckStatus (state, {id, checked}) {
const tasks = state.tasks.slice()
const task = tasks.find((task) => {
return task.id === id
})
task.status = checked
state.tasks = tasks
},
test () {
window.alert('task のアラート')
}
},
actions: {
changeCheckStatus ({commit}, payload) {
commit('changeCheckStatus', payload)
}
},
}
export default {
state: {
persons: [
{id: 1, name: '一郎'},
{id: 2, name: '次郎'},
],
},
mutations: {
test () {
window.alert('person のアラート')
},
}
}
TaskList コンポーネントの各チェックボックスにおいてチェンジイベントが発生した時に、先程登録したミューテーション test をコミットしてみます。
<template>
<div id="app">
<h1>Vuex module option demo</h1>
<task-list
:taskList="tasks.tasks"
:personList="persons.persons"
@changeCheckStatus="test"
/>
</div>
</template>
<script>
import Vuex from 'vuex'
import TaskList from '@/components/TaskList'
export default {
name: 'app',
components: {
TaskList,
},
computed: {
...Vuex.mapState(['tasks', 'persons']),
},
methods: {
...Vuex.mapActions(['changeCheckStatus']),
test () {
this.$store.commit('test')
},
},
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
margin-top: 60px;
}
</style>
モジュール間において同じ名前で登録されたミューテーションは、コミットで呼び出される時に一致する名前の処理がすべて実行されます。
(アクションも同様の挙動・ゲッターはエラーが発生します。)
名前空間の指定
モジュールとして分割するだけでは、ゲッター・ミューテーション・アクションも同一のスコープ上に登録されてしまいます。それらを、モジュール内に閉じ込める場合はnamespaced
オプションにtrue
を渡して名前空間を指定します。
export default {
namespaced: true, // 名前空間の指定
state: {
tasks: [
{id: 1, name: 'sample task 1', status: true, personId: 1},
{id: 2, name: 'sample task 2', status: false, personId: 2},
],
},
mutations: {
changeCheckStatus (state, {id, checked}) {
const tasks = state.tasks.slice()
const task = tasks.find((task) => {
return task.id === id
})
task.status = checked
state.tasks = tasks
},
test () {
window.alert('task のアラート')
}
},
actions: {
changeCheckStatus ({commit}, payload) {
commit('changeCheckStatus', payload)
}
},
}
export default {
namespaced: true, // 名前空間の指定
state: {
persons: [
{id: 1, name: '一郎'},
{id: 2, name: '次郎'},
],
},
mutations: {
test () {
window.alert('person のアラート')
},
}
}
名前空間を指定した事によって、モジュールのミューテーションへのアクセス方法が若干変更になります。名前空間が有効な場合は、接頭辞にモジュール名を指定します。
<template>
<div id="app">
<h1>Vuex module option demo</h1>
<task-list
:taskList="tasks.tasks"
:personList="persons.persons"
@changeCheckStatus="test"
/>
</div>
</template>
<script>
import Vuex from 'vuex'
import TaskList from '@/components/TaskList'
export default {
name: 'app',
components: {
TaskList,
},
computed: {
...Vuex.mapState(['tasks', 'persons']),
},
methods: {
...Vuex.mapActions(['changeCheckStatus']),
test () {
this.$store.commit('tasks/test') // 接頭辞としてモジュール名を指定して`/`で繋ぎます。
},
},
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
margin-top: 60px;
}
</style>
それでは、各モジュールの名前空間が有効な状態でタスクの新規登録機能を作成していきます。
task モジュールにミューテーションとアクションを定義します。
export default {
namespaced: true,
state: {
tasks: [
{id: 1, name: 'sample task 1', status: true, personId: 1},
{id: 2, name: 'sample task 2', status: false, personId: 2},
],
},
mutations: {
changeCheckStatus (state, {id, checked}) {
const tasks = state.tasks.slice()
const task = tasks.find((task) => {
return task.id === id
})
task.status = checked
state.tasks = tasks
},
addTask (state, {name, personId}) {
state.tasks = [
...state.tasks,
{
id: new Date().getTime(),
name: name,
status: false,
personId: personId,
},
]
},
},
actions: {
changeCheckStatus ({commit}, payload) {
commit('changeCheckStatus', payload)
},
addTask ({commit}, payload) {
commit('addTask', payload)
},
},
}
src/App.vue
にフォームを設置します。
<template>
<div id="app">
<h1>Vuex module option demo</h1>
<form @submit.prevent="addTask(newTask)">
<table>
<tr>
<th>タスク名</th>
<td><input type="text" placeholder="taskName" v-model="newTask.name"></td>
</tr>
<tr>
<th>担当者</th>
<td>
<select v-model.number="newTask.personId">
<option value="0">未選択</option>
<option
v-for="person in persons"
:value="person.id"
:key="person.id"
>
{{person.name}}
</option>
</select>
</td>
</tr>
</table>
<button type="submit">タスク追加</button>
</form>
<hr>
<task-list
:taskList="tasks"
:personList="persons"
@changeCheckStatus="changeCheckStatus"
/>
</div>
</template>
<script>
import Vuex from 'vuex'
import TaskList from '@/components/TaskList'
export default {
name: 'app',
components: {
TaskList,
},
data() {
return {
newTask: {
name: name,
personId: 0,
}
}
},
computed: {
// ヘルパー関数でモジュール名を指定してステートの読み込み
...Vuex.mapState('tasks', ['tasks']),
...Vuex.mapState('persons', ['persons']),
},
methods: {
// ヘルパー関数でモジュール名を指定してアクションの読み込み
...Vuex.mapActions('tasks', ['changeCheckStatus', 'addTask']),
},
}
</script>
<style>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
margin-top: 60px;
}
</style>
まとめ
- モジュールとして分割する事でコードの見通しがよくなる
- モジュールとして分割する事でモジュール間のステートのスコープを分ける事ができる
- モジュールとして分割 + 名前空間を指定する事でゲッター・ミューテーション・アクションもモジュール内に閉じ込める事ができる
名前空間の指定をしない場合は、コミットやディスパッチで指定した名前の処理がすべてのモジュールに対して実行される為、どのモジュールにどのような処理が定義されているか把握している必要がありそうです。しかし、チームで開発している場合や規模の大きなアプリケーションだと全て把握する事は難しいので、基本的にモジュールを分ける場合は名前空間を指定した方が良いかなと思いました。