LoginSignup
8
4

More than 3 years have passed since last update.

Vuexのモジュールを使用してストアを管理する

Posted at

Vuex で状態管理を行っているアプリケーションでは、基本的にストアは1つだと思います。
アプリケーションが大きくなるにつれストアで管理する必要がある状態は多くなった場合、沢山のステートやミューテーションなどを一つのオブジェクトで管理する事になってしまうでしょう。
そういった事態を防ぐ為に、Vuex にはモジュールオプションが用意されています。
これに加えて、個人的にモジュールオプションを使用する事のメリットとして、管理するステートに紐づくオプションをまとめてグループ化できる事だと感じています。

私がVuexのモジュールオプションについて調べた時、分割したモジュールのステートへのアクセスや更新を行う方法に混乱して、なかなか理解が進みませんでした。
個人的な振り返りも含めて、簡単な「TODOアプリ」の作成を通して Vuex のモジュールの使用について記事にします。

Jul-01-2019 12-17-41.gif

この記事では、以下の環境で作業を進めていきます。

  • 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を編集します。

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.jssrc/store/modules/配下に作成します。

touch src/store/modules/tasks.js src/store/modules/persons.js

最後にsrc/main.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')

モジュールにステートを定義をして読み込む

src/store/modules/tasks.js
export default {
  state: {
    tasks: [
      {id: 1, name: 'sample task 1', status: true, personId: 1},
      {id: 2, name: 'sample task 1', status: false, personId: 2},
    ],
  },
}
src/store/modules/persons.js
export default {
  state: {
    persons: [
      {id: 1, name: '一郎'},
      {id: 2, name: '次郎'}
    ]
  }
}

modulesのオブジェクトの中でインポートしたモジュールを読み込みます、

src/store/store.js
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
  }
})

スクリーンショット 2019-07-01 13.50.05.png

ストアに登録したモジュールのステートをコンポーネント側で読み込む

モジュールとして分割した場合でもステートを読み込む方法は変わりません。
コンポーネント側で読み込んだステートはモジュールの名前が付いたオブジェクトにラップされた状態となります。
これによってステートは、モジュール毎にスコープを分離する事ができます。

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 として渡します。

src/App.vue
<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 コンポーネントを作成します。

src/components/TaskList.vue
<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>

これで、ストアにモジュールとして登録したステートを使用する事ができました。

スクリーンショット 2019-07-01 13.55.34.png

ステートの状態を更新する

チェックボックスの状態が変更されたタイミングでタスクのステートを変更できるようにします。
まずは、src/store/modules/tasks.jsにミューテーションとアクションを定義します。

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側で先程定義したアクションを読み込みます。

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()を実行します。

src/components/TaskList.vue
<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>

Jul-01-2019 14-09-58.gif

モジュールとして登録されているミューテーション・アクション・ゲッターは、モジュールを使用しない場合と同じようにコンポーネント側での読み込み・使用が可能です。
しかし、モジュールを分けていても同じスコープ上に各ミューテーション・アクション・ゲッターが登録される為、モジュール間で使用している名前が競合する場合があります。

試しに tasks モジュールと persons モジュールに test というミューテーションを定義してみます。

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
    },
    test () {
      window.alert('task のアラート')
    }
  },
  actions: {
    changeCheckStatus ({commit}, payload) {
      commit('changeCheckStatus', payload)
    }
  },
}
src/store/modules/tasks.js
export default {
  state: {
    persons: [
      {id: 1, name: '一郎'},
      {id: 2, name: '次郎'},
    ],
  },
  mutations: {
    test () {
      window.alert('person のアラート')
    },
  }
}

TaskList コンポーネントの各チェックボックスにおいてチェンジイベントが発生した時に、先程登録したミューテーション test をコミットしてみます。

src/App.vue
<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>

Jul-01-2019 14-16-23.gif

モジュール間において同じ名前で登録されたミューテーションは、コミットで呼び出される時に一致する名前の処理がすべて実行されます。
(アクションも同様の挙動・ゲッターはエラーが発生します。)

名前空間の指定

モジュールとして分割するだけでは、ゲッター・ミューテーション・アクションも同一のスコープ上に登録されてしまいます。それらを、モジュール内に閉じ込める場合はnamespacedオプションにtrueを渡して名前空間を指定します。

src/store/modules/tasks.js
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)
    }
  },
}
src/store/modules/tasks.js
export default {
  namespaced: true, // 名前空間の指定
  state: {
    persons: [
      {id: 1, name: '一郎'},
      {id: 2, name: '次郎'},
    ],
  },
  mutations: {
    test () {
      window.alert('person のアラート')
    },
  }
}

名前空間を指定した事によって、モジュールのミューテーションへのアクセス方法が若干変更になります。名前空間が有効な場合は、接頭辞にモジュール名を指定します。

src/App.vue
<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 モジュールにミューテーションとアクションを定義します。

src/store/modules/tasks.js
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にフォームを設置します。

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>

まとめ

  • モジュールとして分割する事でコードの見通しがよくなる
  • モジュールとして分割する事でモジュール間のステートのスコープを分ける事ができる
  • モジュールとして分割 + 名前空間を指定する事でゲッター・ミューテーション・アクションもモジュール内に閉じ込める事ができる

名前空間の指定をしない場合は、コミットやディスパッチで指定した名前の処理がすべてのモジュールに対して実行される為、どのモジュールにどのような処理が定義されているか把握している必要がありそうです。しかし、チームで開発している場合や規模の大きなアプリケーションだと全て把握する事は難しいので、基本的にモジュールを分ける場合は名前空間を指定した方が良いかなと思いました。

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4