【JavaScript】【Vue.js】Vuex でも型補完がほしい
前書き
最近お仕事で Vue.js + Vuex をモリモリやっているんですが、せっかく VSCode を使っているのに公式ドキュメントに書いてある方法をそのまま使うと型補完が効かないのでしんどい気持ちになってきました。
commit
/ dispatch
で指定する名前とかペイロードとか、state
から参照するメンバ名とその型とか、さくさく補完して欲しくないですか?IntelliSense に出てこないものは存在しないものと同義じゃないですか?
そういった辛いお気持ちをどうにかするため、色々やってみたメモです。
前提・環境
- Vue CLI
- Webpack を使って単一ファイルコンポーネントをいじり回せる環境が前提。
- Visual Studio CodeとVue.js Extension Pack
- その他のエディタでは書いたことないので…。
- 型補完は欲しいが JavaScript(ES6)で頑張る。
- TypeScript が使えればもっと楽な箇所がいっぱいあると思う。
仕組みの概要
- コンポーネント内では
$vm.store
を参照するのではなく、import
でストアのインスタンスを取得する。 - マッピング用のヘルパー関数(
mapState
とか)は使わない。*1 - ステート用のクラスを作る。
- ゲッターはステート用のクラスに関数として定義する。
commit
/dispatch
に指定する名前とペイロードを持つクラスを作る。modules.namespaced
による名前空間の管理を行わない。- 全部グローバル空間に突っ込むことになるが、
commit
/dispatch
に指定する名前が一意になっていれば問題ない。やり方は後述。 - ちなみに
modules.namespaced
がtrue
になっていなくても、ステートは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]
からid
やname
が補完されるようになりました。
ステートのエクスポート 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
を呼び出すことが可能になります。
さらにtype
でnamespace
を含めて返すようにすることで、実質namespaced
をtrue
にしたのと同じ効果を得ることができます。
さらにさらに、今まで作ったクラスを組み合わせることで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
のエクスポート
後はこれらをエクスポートしてあげればコンポーネントからも自由に使うことができます。
今はモジュールも一つ、MutationType
もActionType
も一つずつしか定義されていませんが、これらがどんどん増えていくとインポートが大変なので、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 で型補完をバリバリ活用することができます。
そこそこ面倒な感じがするかもしれませんが、これぐらいやっておかないと大規模なアプリを作るときにかなりしんどいです。かなりしんどいです。今。ナウ。