【JavaScript】【Vue.js】Vuex でも型補完がほしい

前書き

最近お仕事で Vue.js + Vuex をモリモリやっているんですが、せっかく VSCode を使っているのに公式ドキュメントに書いてある方法をそのまま使うと型補完が効かないのでしんどい気持ちになってきました。

commit / dispatchで指定する名前とかペイロードとか、stateから参照するメンバ名とその型とか、さくさく補完して欲しくないですか?IntelliSense に出てこないものは存在しないものと同義じゃないですか?

そういった辛いお気持ちをどうにかするため、色々やってみたメモです。

前提・環境

仕組みの概要

  • コンポーネント内では$vm.storeを参照するのではなく、importでストアのインスタンスを取得する。
  • マッピング用のヘルパー関数(mapStateとか)は使わない。*1
  • ステート用のクラスを作る。
  • ゲッターはステート用のクラスに関数として定義する。
  • commit / dispatchに指定する名前とペイロードを持つクラスを作る。
  • modules.namespacedによる名前空間の管理を行わない。
    • 全部グローバル空間に突っ込むことになるが、commit / dispatchに指定する名前が一意になっていれば問題ない。やり方は後述。
    • ちなみにmodules.namespacedtrueになっていなくても、ステートはstore[${モジュール名}]でアクセスできる。
  • 型情報が失われてしまう箇所はJSDocを書いて情報を補完する。*2

サンプルコード

サンプルとなるコードがないと説明しづらいので適当に例を載せておきます。

また、Githubに今回の動作検証用プロジェクトをあげてあります。今回の論旨に関係ない部分はめちゃめちゃ適当に作ったので、そのまま流用すると痛い目に合うと思います。

ストアの定義

要点は以下の通りです。

  • 検索処理用のモジュール
  • ステートとして検索結果を持つ
  • アクションに検索処理が定義されている
import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    search: {
      state: {
        result: []
      },
      mutations: {
        setResult(state, payload) {
          state.result = payload;
        }
      },
      actions: {
        async search({ commit }, payload) {
          const body = JSON.stringify(payload);
          const resp = await fetch("/search", {
            method: "POST",
            headers: {
              Accept: "application/json",
              "Content-Type": "application/json"
            },
            body
          });
          commit("setResult", resp.json());
        }
      }
    }
  }
});

export default store;

コンポーネントの定義

先程のストアがマッピングされている検索用フォーム(src/components/SearchForm.vue)と検索結果(src/components/SearchResult.vue)のコンポーネントです。

<template>
  <div>
    <label> ID: <input type="text" @input="id = $event.target.value" /> </label>

    <button @click.prevent="onSearch">Search</button>
  </div>
</template>

<script>
  export default {
    name: "SearchForm",
    data() {
      return {
        id: ""
      };
    },
    methods: {
      async onSearch() {
        await this.$store.dispatch("search", { id: this.id });
      }
    }
  };
</script>
<template>
  <table v-if="result.length > 0">
    <thead>
      <th>ID</th>
      <th>Name</th>
    </thead>
    <tbody>
      <tr v-for="item in result" :key="item.id">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
      </tr>
    </tbody>
  </table>
</template>

<script>
  export default {
    name: "SearchResult",
    computed: {
      result() {
        return this.$store.state.search.result;
      }
    }
  };
</script>

モジュールを別ファイルに分割する 9dafad2

とりあえずモジュールを分割しましょう。こんな感じのディレクトリ構成にします。

src
└─store
    │  index.js
    └─modules
        └─search
                actions.js
                index.js
                mutations.js
                namespace.js
                state.js

store/modules/search/index.jsでモジュールと名前空間をエクスポートするようにします。

// store/modules/search/index.js
import state from "./state";
import mutations from "./mutations";
import actions from "./actions";

const module = {
  state,
  mutations,
  actions
};

export default module;
export { default as nsSearch } from "./namespace";
// store/modules/search/namespace.js
export default "search";

state.js / mutations.js / actions.jsは先程のストア定義からそのまま切り出すだけです。

// store/modules/search/state.js
const state = {
  result: []
};

export default state;
// store/modules/search/mutations.js
const mutations = {
  setResult(state, payload) {
    state.result = payload;
  }
};

export default mutations;
// store/modules/search/actions.js
const actions = {
  async search({ commit }, payload) {
    const body = JSON.stringify(payload);
    const resp = await fetch("/search", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json"
      },
      body
    });
    commit("setResult", resp.json());
  }
};

export default actions;

store/index.jsでモジュールをインポートし、Vuex.Storeインスタンスを作るようにします。

// store/index.js
import Vue from "vue";
import Vuex from "vuex";
import searchModule, { nsSearch } from "./modules/search/index";

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    [nsSearch]: searchModule
  }
});

export default store;

これでモジュールが増えてもそれなりに管理しやすくなりました。

ステートのクラス化 8287dd

まずはステートをクラスにしてしまいましょう。何にも考えずにやると、こんな感じでしょうか。

/**
 * 検索用ステート
 */
class SearchState {
  /**
   * 検索用ステート
   */
  constructor() {
    /** 検索結果 */
    this.result = [];
  }
}

export { SearchState };

export default new SearchState();

クラスの定義(export { SearchState })とシングルトンインスタンスexport default new SearchState())は別々にエクスポートしておきます。理由は後で定義だけ参照したくなるからです。

ステートに定義されたオブジェクトのクラス化

このままだとSearchState#resultの型はany[]となってしまいます。これでは片手落ちなので、ちゃんと検索結果用のクラスも作ってあげます。

// store/modules/search/models.js
/**
 * 検索結果
 */
class SearchResult {
  /**
   * 検索結果
   */
  constructor() {
    /** ID */
    this.id = "";
    /** 名前 */
    this.name = "";
  }
}

export { SearchResult };

state.js側では@typeを指定して型情報を補完してあげます。

import * as Models from "./models";

/**
 * 検索用ステート
 */
class SearchState {
  /**
   * 検索用ステート
   */
  constructor() {
    /**
     * 検索結果
     * @type {Models.SearchResult[]}
     */
    this.result = [];
  }
}

export { SearchState };

export default new SearchState();

これでSearchModule#result[n]からidnameが補完されるようになりました。

ステートのエクスポート 461a29

とはいえ、今のままではコンポーネント側で参照するときに補完が効きません。意味ないですね。

そこでちゃんと型情報を付与してあげつつ、ストアとは別にステートをエクスポートするようにします。

// store/modules/search/index.js
import state from "./state";
import mutations from "./mutations";
import actions from "./actions";

const module = {
  state,
  mutations,
  actions
};

export default module;
// 型定義だけエクスポート
export { SearchState } from "./state";
export { default as nsSearch } from "./namespace";
// store/index.js
import Vue from "vue";
import Vuex from "vuex";
// 型定義 (SearchState) をインポートに追加
import searchModule, { nsSearch, SearchState } from "./modules/search/index";

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    [nsSearch]: searchModule
  }
});

export default store;
// エクスポートする時に @type を指定してあげるとインポート先で型情報が補完される
/** @type {SearchState} */
export const searchState = store.state[nsSearch];

後はコンポーネントsearchStateをインポートしてやれば OK です。

<template>
  <table v-if="result.length > 0">
    <thead>
      <th>ID</th>
      <th>Name</th>
    </thead>
    <tbody>
      <tr v-for="item in result" :key="item.id">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
      </tr>
    </tbody>
  </table>
</template>

<script>
  // searchState をインポート
  import { searchState } from "../store/index.js";

  export default {
    name: "SearchResult",
    computed: {
      result() {
        // $vm.store ではなくインポートしたステートを直接参照
        // return this.$store.state.search.result;
        return searchState.result;
      }
    }
  };
</script>

副次効果として、コンポーネント内のmethodsなどからthis.resultを参照するとちゃんとSearchResult[]として扱われます。最高ですね。

MutationType / ActionTypeの作成 37fa2d

次はcommit / dispatchに指定する名前とペイロードがあやふやすぎる問題をどうにかしていきましょう。

commit / dispatchともに、第一引数に名前を渡す代わりにtypeを持つオブジェクトを渡すことが許されているので、これを活用します。

// 以下はどちらも mutations.setResult に対し { result: [] } を渡す
commit("setResult", { result: [] });
commit({ type: "setResult", result: [] });

// 以下はどちらも actions.search に対し { param: {} } を渡す
dispatch("search", { param: {} });
dispatch({ type: "search", param: {} });

具体的には、typeを参照するとcommit / dispatchで呼び出すべき名前が返ってくるクラスを作成します。

MutationTypeの定義

まずはミューテーションから。

// store/modules/search/mutationTypes.js
import namespace from "./namespace";

/**
 * 検索結果設定
 */
export class SetResult {
  get type() {
    return SetResult.type;
  }

  static get type() {
    return `${namespace}/SetResult`;
  }
}

そしてこのクラスのコンストラクタにペイロードを指定するようにすれば完璧です。

// store/modules/search/mutationTypes.js
import namespace from "./namespace";
import { SearchResult } from "./models";

/**
 * 検索結果設定
 */
export class SetResult {
  /**
   * 検索結果設定
   * @param {SearchResult[]} result 検索結果
   */
  constructor(result) {
    /**
     * 検索結果
     * @type {SearchResult[]}
     */
    this.result = result;
  }

  get type() {
    return SetResult.type;
  }

  static get type() {
    return `${namespace}/SetResult`;
  }
}

これでnew SetResult(result)という形でインスタンスを作れば{type: "search/SetResult", result: result}というオブジェクトを得られるようになりました。

また、@paramを記述することでペイロードとして渡すべき型の情報も明示されるようになったため、より安全にcommitを呼び出すことが可能になります。

さらにtypenamespaceを含めて返すようにすることで、実質namespacedtrueにしたのと同じ効果を得ることができます。

さらにさらに、今まで作ったクラスを組み合わせることでmutations内の関数でも型補完を効かせることができるようになります。

// store/modules/search/mutations.js
import { SearchState } from "./state";
import * as types from "./mutationTypes";

const mutations = {
  /**
   * 検索結果設定
   * @param {SearchState} state
   * @param {types.SetResult} payload
   */
  [types.SetResult.type](state, { result }) {
    state.result = result;
  }
};

export default mutations;

ActionTypeの定義

アクションも同じようなものを作れば OK です。

// store/modules/search/actionTypes.js
import namespace from "./namespace";

/**
 * 検索処理
 */
export class Search {
  /**
   * 検索処理
   * @param {string} id ID
   */
  constructor(id) {
    /**
     * ID
     * @type {string}
     */
    this.id;
  }

  get type() {
    return Search.type;
  }

  static get type() {
    return `${namespace}/Search`;
  }
}
// store/modules/search/actions.js
import { ActionContext } from "vuex";
import * as types from "./actionTypes";
import * as mutationTypes from "./mutationTypes";

const actions = {
  /**
   * 検索処理
   * @param {ActionContext} context
   * @param {types.Search} payload
   */
  async [types.Search.type]({ commit }, { id }) {
    const body = JSON.stringify({
      id
    });
    const resp = await fetch("/search", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json"
      },
      body
    });
    // action 内で commit を呼ぶ時も当然 MutationType を使える
    commit(new mutationTypes.SetResult(resp.json()));
  }
};

export default actions;

MutationType / ActionTypeのエクスポート

後はこれらをエクスポートしてあげればコンポーネントからも自由に使うことができます。

今はモジュールも一つ、MutationTypeActionTypeも一つずつしか定義されていませんが、これらがどんどん増えていくとインポートが大変なので、store/modules/search/index.jsでは* as SearchMutation / SearchActionとして一度インポートしておき、そのままエクスポートしておくと良いです。

// store/modules/search/index.js
import state from "./state";
import mutations from "./mutations";
// MutationType
import * as SearchMutation from "./mutationTypes";
import actions from "./actions";
// ActionType
import * as SearchAction from "./actionTypes";

const module = {
  state,
  mutations,
  actions
};

export default module;
export { SearchState } from "./state";
export { default as nsSearch } from "./namespace";
// MutationType / ActionType をエクスポート
export { SearchMutation, SearchAction };
// store/index.js
import Vue from "vue";
import Vuex from "vuex";
import searchModule, { nsSearch, SearchState } from "./modules/search/index";

Vue.use(Vuex);

const store = new Vuex.Store({
  modules: {
    [nsSearch]: searchModule
  }
});

export default store;
/** @type {SearchState} */
export const searchState = store.state[nsSearch];
// MutationType / ActionType をエクスポート
export { SearchMutation, SearchAction } from "./modules/search/index";

コンポーネントからは以下のように呼び出します。

<template>
  <div>
    <label> ID: <input type="text" @input="id = $event.target.value" /> </label>

    <button @click.prevent="onSearch">Search</button>
  </div>
</template>

<script>
  // SearchAction をインポート
  import store, { SearchAction } from "../store/index.js";
  export default {
    name: "SearchForm",
    data() {
      return {
        id: ""
      };
    },
    methods: {
      async onSearch() {
        // dispatch の引数を SearchAction.Search のインスタンスに変更
        // await this.$store.dispatch("search", { id: this.id });
        await store.dispatch(new SearchAction.Search(this.id));
      }
    }
  };
</script>

ゲッターの扱いについて 279266

後 Vuex の機能として残っているのはゲッターぐらいなんですが、実はこれ、stateのクラスに関数として定義してしまっても特に問題ありません。

というか、store.getters経由だとどう頑張っても型補完は無理です。*3

大人しくstate内に定義しましょう。

// store/modules/search/state.js
import * as Models from "./models";

/**
 * 検索用ステート
 */
class SearchState {
  /**
   * 検索用ステート
   */
  constructor() {
    /**
     * 検索結果
     * @type {Models.SearchResult[]}
     */
    this.result = [];
  }

  // これをゲッターの代わりにする
  get sortedByIdResult() {
    return this.result.sort((a, b) => {
      const idA = a.id.toUpperCase();
      const idB = b.id.toUpperCase();
      if (idA < idB) {
        return -1;
      }
      if (idA > idB) {
        return 1;
      }

      return 0;
    });
  }
}

export { SearchState };

export default new SearchState();

まとめ

と言うわけで、ここまでやると Vuex で型補完をバリバリ活用することができます。

そこそこ面倒な感じがするかもしれませんが、これぐらいやっておかないと大規模なアプリを作るときにかなりしんどいです。かなりしんどいです。今。ナウ。

*1:この辺を使おうとすると型補完は不可能。

*2:TypeScript を使うなら不要。

*3:store.getters["search/foo"] で返ってくる型をどこでどう定義しろと。