JavaScript
vue.js
Vuex

Vuexの動的なモジュールの活用方法を考えてみた - フォームサンプル

動的なモジュールってなに?

とりあえず公式ドキュメントを見てみると、

動的なモジュール登録があることで、他の Vue プラグインが、モジュールをアプリケーションのストアに付属させることで、状態の管理に Vuex を活用できます。

また、

モジュールの再利用

時どき、モジュールの複数インスタンスを作成する必要があるかもしれません。例えば:

  • 同じモジュールを使用する複数のストアを作成する
  • 同じストアに同じモジュールを複数回登録する

との記述が。
ふむふむ、どんなユースケースで使えばよいのかよーわからん。

使用例

ということで、ひとまずは公式で紹介されている使用例を見てます。

vuex-router-sync

vuexでvue-routerの状態を管理できるようにするためのプラグインですね。

公式ドキュメントの方では

動的に付属させたモジュール内部でアプリケーションのルーティングのステートを管理することで vue-router と vuex を統合しています。

と紹介されています。

ググってみた

Dynamic Modules with Vuex and Vue

https://github.com/cdbkr/vue-dynamic-modules

特定の機能の塊(この例ではECサイト想定でお店毎のカート機能)= モジュールを、コンポーネントのcreatedのタイミングで登録している感じですかね。(あんまりじっくり見てないので英語詳しい人解説お願いします!)
ソースを読んだところ、コンポーネントの名前でモジュールを登録しているみたいです。

そのコンポーネントが表示されるタイミングで初めて必要になるモジュールを登録する。
もしすでに登録済みだったら登録されているモジュール使いまっせ。といった感じの実装になっていました。

↓予め動的に登録するモジュールのステートやメソッドをmapできるのはいいこと知った。

  const name = 'home';
  export default {
    extends: componentWidthDynamicModule(name),
    computed: {
      ...mapState(name, {
        items: state => state.items
      })
    },
    computed: {
      ...mapState(name, {
        items: state => state.items
      })
    },
    methods: {
      ...mapActions(name, ['generate'])
    },
    mounted: function () {
      this.generate();
    },
  };
componentWidthDynamicModule
import register from './register';

export default (name) => {
  return {
    created: function () {
      const store = this.$store;
      if (!(store && store.state && store.state[name])) {
        register(name, store);
      } else {
        console.log(`reusing module: ${name}`);
      }
    },
  }
};
register
import commonModule from '../store/modules/common/index';
export default (name, store) => {
  store.registerModule(name, commonModule);
};

で、結局どうやって使えばよいの?

vuex-router-syncのようにアプリケーションとは独立したプラグインを作りたい時に使えるようですが、
自身でアプリケーション毎に使えるプラグインを書くこともなかなかないので、どうしたものかと。

使うなら「モジュールの再利用」をメインで使うことになりそう。

実装方法

何はともあれ、実装方法を見てみます。
公式ドキュメントを参照いただければと思いますが、こちらでもご紹介。

// `myModule` モジュールを登録します
store.registerModule('myModule', {
  // ...
})

// ネストされた `nested/myModule` モジュールを登録します
store.registerModule(['nested', 'myModule'], {
  // ...
})

// 動的に登録したモジュールの削除
store.unregisterModule(moduleName)

実装はとても簡単にできます。
storeを持っているところからなら登録できる感じですね。

具体的な実装でどんなことができるのか、今回はよくありそうなフォームパターンで実装してみました。

ユーザーフォームで動的モジュールを使う

ユーザー情報の編集・新規登録なんかで使うフォームの機能を、動的に独立したモジュールで実装してみます。

画面としては以下3画面。

  • ホーム画面(ユーザー一覧):Home
  • ユーザー編集画面:Edit
  • ユーザー作成画面:Create

この内

  • ユーザー編集画面:Edit
  • ユーザー作成画面:Create

の2つがcreatedされるタイミングで動的にモジュールを登録します。
また、画面を離れる際(beforeRouteLeave)時に動的に登録したモジュールを削除をしてみます。

実装はvue-cliをベースに行い、vuexを追加しています。

ソースはgithubで公開しています。
RikutoYamaguchi/vuex-dynamic-module-sample

ユーザー情報を扱うモジュール(user)を作成

./src/store/user/index.js
import Vue from 'vue';

const userStub = {
  user_1: {
    uid: 1,
    name: 'Taro',
    tel: '000-0000-0000',
    email: 'sample@test.com'
  },
  user_2: {
    uid: 2,
    name: 'Jiro',
    tel: '000-0000-0000',
    email: 'sample@test.com'
  },
  user_3: {
    uid: 3,
    name: 'Saburo',
    tel: '000-0000-0000',
    email: 'sample@test.com'
  }
};

const state = {
  all: userStab
};

// mutation types
const UPDATE_USER = 'update_user';
const CREATE_USER = 'create_user';

const mutations = {
  /**
   * ユーザーの更新
   * @param all
   * @param user
   */
  [UPDATE_USER] ({ all } , { user }) {
    all[`user_${user.uid}`] = { ...all[`user_${user.uid}`], ...user };
  },
  /**
   * ユーザーの作成
   * @param all
   * @param user
   */
  [CREATE_USER] ({ all }, { user }) {
    // add object property
    Vue.set(all,`user_${user.uid}`, user)
  }
};

const actions = {
  /**
   * userFormモジュールから修正後のユーザーを受け取る
   * @param commit
   * @param user
   */
  applyEdit ({ commit }, { user }) {
    commit(UPDATE_USER, { user });
  },
  /**
   * ユーザーを作成する
   * @param commit
   * @param user
   */
  create ({ commit }, { user }) {
    commit(CREATE_USER, { user });
  }
};

const getters = {
  /**
   * id指定でユーザーを返す
   * @param state
   * @returns {function(*)}
   */
  getById: state => uid => {
    return state.all[`user_${uid}`];
  },
  /**
   * 全ユーザー数
   * @param state
   * @returns {number}
   */
  allCount: state => {
    return Object.keys(state.all).length;
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
}

state.allに全ユーザー情報を持っているモジュールです。
actions.applyEditは後から作成するユーザーフォーム用のモジュールから来る編集後のユーザー情報を受け取るアクションとして作成しました。

ユーザーフォーム用の動的モジュール(userForm)の作成

  • ユーザー編集画面:Edit
  • ユーザー作成画面:Create

この2画面で動的に登録するモジュールの実装です。

./src/store/userForm/index.js
/**
 * stateは関数でオブジェクトを返すようにする。
 * 公式ドキュメント:
 * `モジュールの状態を宣言するために単純なオブジェクトを使用すると、
 * その状態オブジェクトは参照によって共有され、
 * 変更時にクロスストア/モジュールの状態汚染を引き起こします。`
 */
const state = () => {
  return {
    user: {
      uid: '',
      name: '',
      tel: '',
      email: ''
    }
  }
};

// mutation types
const SET_UID = 'set_uid';
const SET_INITIAL_DATA = 'set_initial_data';
const UPDATE_VALUE = 'update_value';

const mutations = {
  /**
   * ユーザーID
   * @param state
   * @param uid
   */
  [SET_UID] (state, { uid }) {
    state.user.uid = uid
  },
  /**
   * 初期データ
   * @param state
   * @param user
   */
  [SET_INITIAL_DATA] (state, { user }) {
    state.user = { ...state.user, ...user }
  },
  /**
   * 値の更新
   * @param state
   * @param prop
   * @param value
   */
  [UPDATE_VALUE] (state, { prop, value }) {
    state.user[prop] = value;
  }
};

const actions = {
  /**
   * 初期化
   * 編集時はuser情報代入、作成時はuid作成
   * @param commit
   * @param rootGetters
   * @param uid
   */
  initialize ({ commit, rootGetters }, { uid } = {}) {
    if (uid) {
      const user =  rootGetters['user/getById'](uid);
      commit(SET_INITIAL_DATA, { user })
    } else {
      const count = rootGetters['user/allCount'];
      commit(SET_UID, { uid: count + 1 })
    }
  },
  /**
   * 値の更新
   * @param commit
   * @param prop
   * @param value
   */
  updateValue ({ commit }, { prop, value }) {
    commit(UPDATE_VALUE, { prop, value })
  },
  /**
   * 編集アクション
   * @param state
   * @param dispatch
   */
  editSubmit ({ state, dispatch }) {
    dispatch('user/applyEdit', { user: state.user }, { root: true })
  },
  /**
   * 作成アクション
   * @param state
   * @param dispatch
   */
  createSubmit ({ state, dispatch }) {
    dispatch('user/create', { user: state.user }, { root: true })
  }
};

const getters = {
  /**
   * 編集しているかどうか
   * @param user
   * @param getters
   * @param rootState
   * @param rootGetters
   * @returns {boolean}
   */
  isEdited: ({ user }, getters, rootState, rootGetters) => {
    const beforeUser = rootGetters['user/getById'](user.uid);
    // 比較は簡易的
    return JSON.stringify(beforeUser) !== JSON.stringify(user);
  }
};

export default {
  namespaced: true,
  state,
  mutations,
  actions,
  getters
}

動的モジュールを使いまわす際は、公式にも書いてある通りstateを関数にしてオブジェクトを返しましょう。
(ちょっとここハマりました。)

コンポーネントの作成

続いてコンポーネントを作成して、どのようにモジュールを使うのか実装してみます。

./src/components/Home.vue
<template>
  <div class="home">
    <h1>Home</h1>
    <ul>
      <li v-for="user in all">
        name: {{ user.name }}<br>
        tel: {{ user.tel }}<br>
        email: {{ user.email }}<br>
        <button @click="onClickEdit(user.uid)">edit</button>
      </li>
    </ul>
    <button @click="onClickCreate">create</button>
  </div>
</template>

<script>
  import { mapState } from 'vuex';
  export default {
    name: 'Home',
    computed: {
      ...mapState('user', [
        'all'
      ])
    },
    methods: {
      onClickEdit (uid) {
        this.$router.push({
          name: 'Edit',
          query: { uid }
        });
      },
      onClickCreate () {
        this.$router.push({
          name: 'Create'
        });
      }
    }
  }
</script>

<style scoped>
  li {
    padding: 20px 50px;
    list-style: none;
    border-bottom: 1px solid #ccc;
  }
</style>

ホーム画面ではuserモジュールからユーザー一覧を注入し一覧表示しています。

また、ユーザー編集画面とユーザー作成画面への導線も用意しました。
ユーザー編集画面にはqueryへuidを代入しています。

./src/components/Edit.vue
<template>
  <div class="edit">
    <h1>Edit</h1>
    <user-form
      :user="user"
      @submit="onSubmit"
      @changeInput="onChangeInput"
    />
  </div>
</template>

<script>
  import { mapState, mapActions, mapGetters } from 'vuex';
  import UserForm from './common/UserForm';

  // モジュール読み込み
  import userFormModule from '@/store/userForm';

  // 使うモジュールの名前
  const moduleName = 'userForm';

  export default {
    name: 'Edit',
    components: {
      UserForm
    },
    computed: {
      ...mapState(moduleName, ['user']),
      ...mapGetters(moduleName, ['isEdited'])
    },
    created () {
      const store = this.$store;
      const { uid } = this.$route.query || {};
      // 動的にモジュールを登録
      store.registerModule(moduleName, userFormModule);
      // 登録したモジュールの初期化
      this.initialize({ uid });
    },
    beforeRouteLeave (to, from, next) {
      const store = this.$store;

      // 編集しているか確認
      if (this.isEdited) {
        if (window.confirm('編集してるけどよい?')) {
          store.unregisterModule(moduleName);
          next();
        }
      } else {
        store.unregisterModule(moduleName);
        next();
      }
    },
    methods: {
      ...mapActions(moduleName, [
        'initialize',
        'updateValue',
        'editSubmit'
      ]),
      onSubmit () {
        // 編集をサブミット
        this.editSubmit();
        this.$router.push({
          name: 'Home'
        });
      },
      onChangeInput ({ e, prop }) {
        this.updateValue({ prop, value: e.target.value })
      }
    }
  }
</script>

大まかな処理の流れ

  1. 画面作成(created)
  2. 動的にuserFormモジュールの登録
  3. userFormモジュールの初期化
  4. 編集作業: userFormモジュールのstateを書き換え
  5. サブミット: userFormモジュールからuserモジュールへ情報を投げる
  6. 離脱前の処理(beforeRouteLeave): userFormuserの中ののユーザー情報を比較
  7. ホーム画面へ遷移

ユーザー編集画面ではcreate時にstore.registerModule(moduleName, userFormModule)で動的にモジュールを登録します。
その後すぐにthis.initialize({ uid })でモジュールの初期化を行いました。
userFormモジュールでは、userモジュールから該当のユーザー情報を取得し、stateにセットします。

userFormモジュールのactions.initialize抜粋
  /**
   * 初期化
   * 編集時はuser情報代入、作成時はuid作成
   * @param commit
   * @param rootGetters
   * @param uid
   */
  initialize ({ commit, rootGetters }, { uid } = {}) {
    if (uid) {
      const user =  rootGetters['user/getById'](uid);
      commit(SET_INITIAL_DATA, { user })
    } else {
      const count = rootGetters['user/allCount'];
      commit(SET_UID, { uid: count + 1 })
    }
  },

値の変更時等はよくあるパターンなので説明は省略します。
次にonSubmit時では、this.editSubmit()で修正確定をuserFormモジュールへ伝えます。
userFormモジュールでは自身のstate.useruserモジュールのapplyEditへ投げ入れ、userモジュールのstate.allを書き換えさせます。

userモジュールのactions.applyEditとmutations.UPDATE_USER抜粋
const mutations = {
  /**
   * ユーザーの更新
   * @param all
   * @param user
   */
  [UPDATE_USER] ({ all } , { user }) {
    all[`user_${user.uid}`] = { ...all[`user_${user.uid}`], ...user };
  },
  ...
};

const actions = {
  /**
   * userFormモジュールから修正後のユーザーを受け取る
   * @param commit
   * @param user
   */
  applyEdit ({ commit }, { user }) {
    commit(UPDATE_USER, { user });
  },
  ...
};

この処理のあと、this.$router.pushでホーム画面へ戻ります。

beforeRouteLeaveでは、userFormモジュールのisEditedを参照して、
userモジュールのstate.all内の該当ユーザー情報と差分があるか確認しています。
差分がある状態 = 編集したがsubmitしていない状態ではwindow.confirmで離脱確認を行っています。

離脱確認オッケー or 差分がなければstore.unregisterModule(moduleName)で動的に登録したモジュールを削除しnext()を実行します。

これでホーム画面に戻ったときにはuserFormモジュールはstoreに存在しない状態になります。

./src/components/Create.vue
<template>
  <div class="create">
    <h1>Create</h1>
    <user-form
      :user="user"
      @submit="onSubmit"
      @changeInput="onChangeInput"
    />
  </div>
</template>

<script>
  import { mapState, mapActions, mapGetters } from 'vuex';
  import UserForm from './common/UserForm';

  // モジュール読み込み
  import userFormModule from '@/store/userForm';

  // 使うモジュールの名前
  const moduleName = 'userForm';

  export default {
    name: 'Create',
    components: {
      UserForm
    },
    computed: {
      ...mapState(moduleName, ['user']),
      ...mapGetters(moduleName, ['isEdited'])
    },
    created () {
      const store = this.$store;
      // 動的にモジュールを登録
      store.registerModule(moduleName, userFormModule);
      // 登録したモジュールの初期化
      this.initialize()
    },
    beforeRouteLeave (to, from, next) {
      const store = this.$store;
      store.unregisterModule(moduleName);
      next();
    },
    methods: {
      ...mapActions(moduleName, [
        'initialize',
        'updateValue',
        'createSubmit'
      ]),
      onSubmit () {
        this.createSubmit();
        this.$router.push({
          name: 'Home'
        });
      },
      onChangeInput ({ e, prop }) {
        this.updateValue({ prop, value: e.target.value })
      }
    }
  }
</script>

大まかな処理の流れ

  1. 画面作成(created)
  2. 動的にuserFormモジュールの登録
  3. userFormモジュールの初期化
  4. 編集作業: userFormモジュールのstateを書き換え
  5. サブミット: userFormモジュールからuserモジュールへ情報を投げる
  6. 離脱前の処理(beforeRouteLeave)
  7. ホーム画面へ遷移

ユーザー作成画面でもほとんど同じような実装です。(mixinにできるところはすべきですね。)
created時にユーザー編集画面ではthis.initialize(){ uid }を渡していましたが、
こちらは新規で作成するので何も引数に指定していません。

onSubmit時では、this.createSubmit()で作成確定をuserFormモジュールへ伝えます。
userFormモジュールでは自身のstate.useruserモジュールのcreateへ投げ入れ、userモジュールのstate.allに情報を追加させます。

userモジュールのactions.createとmutations.CREATE_USER抜粋
const mutations = {
  ...
  /**
   * ユーザーの作成
   * @param all
   * @param user
   */
  [CREATE_USER] ({ all }, { user }) {
    // add object property
    Vue.set(all,`user_${user.uid}`, user)
  }
};

const actions = {
  ...
  /**
   * ユーザーを作成する
   * @param commit
   * @param user
   */
  create ({ commit }, { user }) {
    commit(CREATE_USER, { user });
  }
};

beforeRouteLeave時には特に特別な処理をせずに、
store.unregisterModule(moduleName)で動的に登録したモジュールを削除しnext()を実行します。
ここで入力値に値が入っていたらユーザー編集画面のように確認を行っても良いかもしれません。

 さいごに

まだまだ動的モジュール活用できそうな気はしていますが、みなさんどうやって使っているのか知りたいですね。
よろしければコメントに使用例書いていただけると嬉しい限りです。