Composition API + Typescript で Vuex をリアクティブに安全に使いたい
こんにちは、毎年アドカレの時期しかブログを更新しないたじまです。(今年は少し頑張ったかな?)
この記事は、 Aizu Advent Calender 2020 の14日目の記事です。 13日目は虚無さん、15日目は spookydokey さんです。
さて、 Composition API といえば今年の 9/18 に正式リリースされたVue.js v3.0 が記憶に新しいかと思います。
この記事では、 Vue.js 2系 で先取り Composition API と、Vuex を使っているプロジェクトでなんとか型安全に Vuex を使えないかと足掻き苦しんだ末に私なりに出した解決方法をまとめてみました。私がこのプロジェクトを受け継いだ当時はまだ Composition API 周りのライブラリが発展しておらず、ベストプラクティスから程遠いかもしれないので、こうしたほうがいいよという助言をいただけるととても嬉しいです。
Composition API とは
簡単に紹介します。
Introducing the Composition API: a set of additive, function-based APIs that allow flexible composition of component logic. 1
Vue 2系 では宣言的な Vue インスタンスと HTML like なテンプレートにより、デザイナや経験の浅い開発者も簡単に使いこなすことができ、小規模から中規模なプロジェクトを簡単に構築することができます。 しかしながら大規模なプロジェクトでは、複雑なコンポーネントの推論の難しさや複数のコンポーネント間でロジックを抽出し再利用する方法の難しさ、TypeScript との相性の悪さから敬遠されがちです。
Composition API を含め Vue 3系ではコンポーネントを作る際の柔軟性と型推論を念頭に置いた設計により、複雑なコードもよりスマートに書くことができるようになりました。 RFC 詳しく書いてあるので気になる方はそちらを読んでください。
Composition API で Vuex を使う上での困った点
Composition API の強みであるロジック部分のモジュール分けなどはこのブログが参考になりました。
このブログでは Vuex 関わる部分だけ簡単に書いていきます。
Vuex + TypeScript を使うときは、 vuex-module-decorators などのライブラリを使うことが多いと思いますが、 Vue Class Module の書き方に依存していて Composition API での使い方がわからなかったり、そもそも自分に経験がなかったりなどの理由で導入を見送りました。
下記のコードは、ボタンをクリックしたら Vuex の action 経由で text を fetch するというコードの一部です。
onClick
で getText
を dispatch しています。Vuex でいつもお世話になっている mapState などのヘルパー2 は this に依存するためつかえません。
そのため、action を下記のように文字列で指定する必要がありました。しかしそうすると少しの誤字でエラーになったりコードジャンプができないなど開発する上で不便に感じる点が多々ありました。
setup(props: Props, ctx: SetupContext){ const text = computed(() => ctx.root.$store.state.text); const onClick = () => { ctx.root.$store.dispatch("getText"); } return { text, onClick } }
Composition APIで書いた Vue のコード全体
<template> <div> <p>{{ text }}</p> <button type="button">Push!</button> </div> </template> <script lang="ts"> import {computed, defineComponent, SetupContext} from "@vue/composition-api"; type Props = { } export default defineComponent({ setup(props: Props, ctx: SetupContext){ const text = computed(() => ctx.root.$store.state.text); const onClick = () => { ctx.root.$store.dispatch("getText"); } return { text, onClick } } }) </script>
改善前の vuex のコード
import Vue from 'vue'; import Vuex, { ActionTree, MutationTree, StoreOptions } from 'vuex'; import { RootState } from './type'; import API from './../api'; Vue.use(Vuex); const mutations: MutationTree<RootState> = { setText(state, payload: { text: string }) { state.text = payload.text; } } const actions: ActionTree<RootState, RootState> = { async getText({ commit }) { try { const { text } = API.getText(); commit('setText', { text }); } catch (e) { console.error(e); } }, } const store: StoreOptions<RootState> = { state: { text: '' }, mutations, actions } export default new Vuex.Store<RootState>(store);
足掻き苦しんだ末の解決方法(仮)
このコード群をなんとかして読みやすくしたい(せめて補完がほしい)と悩んだ末に、参考にしたのがこの記事と Vuex+TS を使っていたプロジェクトのコードでした。
1. Vuex の state をリアクティブに使いたい
computed
で逐一参照すれば問題ない話ではあるのですが、ひっぱってきた Vuex の state を reactive でひとまとめにしたい気持ちや階層化された値の監視に対応したいなどなどあったのでカスタムフックスにして切り分けました。(カスタムフックスは参考にした記事と全く同じです。)
ロジック部分のみに切り分けたモジュール
import { reactive, SetupContext } from "@vue/composition-api"; import { useVuexRef } from "./useVuex"; export default (ctx: SetupContext) => { const state = reactive({ text: useVuexRef<string>(ctx, "text"), }) const getText = async () => { await ctx.root.$store.dispatch("getText"); }; return { state, getText, };
Vuex の state をリアクティブにつかうためのカスタムフックス
import { onUnmounted, ref, Ref, SetupContext } from "@vue/composition-api"; export function useVuexRef<T>(context: SetupContext, key: string): Ref<T> { // 最後に as Ref<T> をしておかないと data.value の更新の部分で型エラーが起きる const data = ref<T>(context.root.$store.state[key] as T) as Ref<T>; const unwatch = context.root.$store.watch<T>( (vuexState: any) => { // 階層化された値の監視には対応してない return vuexState[key] as T; }, (newVal: T) => { // refのvalueを更新すればコンポーネントが反応する data.value = newVal; } ); onUnmounted(() => { unwatch(); }); // Refを返す return data; }
さて、こうしてカスタムフックスに分けて書くことができましたが dispatch の部分や state の呼び出し部分が文字列で指定していてまだ不安があります。まだこの時点ではいいかもしれないのですが state の階層が深くなったりしていくと闇が見えるのでなんとかしたいです。
2. dispatch や state の呼び出しに補完を効かせたい
ほんとは TypeScript で方安全にしたいのはやまやまなのですが、知識不足&経験不足により方法が皆目見当がつかなかったため見送りました。
これを一言で表すと Vuex の action や mutation を定数として使うためのヘルパー関数です。以前 Vuex + TS でやっていたプロジェクトのコードを参考にしました。 (作成者は @uzimaru 君です。公開しよっかな言ってましたがまだっぽいです。)
type.ts
export type Action<A extends string, S, R = S> = { [key in A]: Vuex.Action<S, R>; }; export type Mutation<M extends string, S> = { [key in M]: Vuex.Mutation<S> }; export const typesEnumeration = <T extends string>( obj: { [key in T]: any }, prefix?: string ) => Object.keys(obj).reduce( (acc, x) => ({ ...acc, [x]: prefix ? `${prefix}/${x}` : x }), {} as { [key in T]: string } );
Action
と Mutation
は、Vuex.Action
と Vuex.Mutation
型のラッパーです。
Action や Mutation の関数一覧を union 型で宣言し、 Action
に渡してあげることで関数の宣言忘れを防いだり誤字を防ぐことができます。
その上で、宣言した Action らを typesEnumeration
を使い、関数名: それを文字列化したもの
のオブジェクトに変換します。
mutation.ts
type MutationTypes = 'setText' | 'clearText'; const mutations: Mutation<MutationTypes, RootState> = { setText(state, payload: { text: string }) { state.text = payload.text; }, clearText(state) { state.text = ''; } }; export const MutationTypes = typesEnumeration(mutations); export default mutations;
actions.ts
type ActionTypes = 'getText' | 'clearText'; const actions: Action<ActionTypes, RootState> = { async getText({ commit }) { try { const { text } = API.getText(); commit(MutationTypes.setText, { text }); } catch (e) { console.error(e); } }, clearText({ commit }) { commit(MutationTypes.clearText); } } export const ActionTypes = typesEnumeration(actions); export default actions;
こうして、action.ts
のアクションの中で mutation を MutationTypes.setText
のように指定してコミットできます。
Composition API によって切り出したモジュールの中でも、 ActionTypes
や StateTypes
を使うことで補完を効かせて Action や State を指定することができます。
import { reactive, SetupContext } from "@vue/composition-api"; import { useVuexRef } from "./useVuex"; import { ActionTypes } from "./../store/action.ts"; import { StoreTypes } from "./../store/index.ts"; export default (ctx: SetupContext) => { const state = reactive({ text: useVuexRef<string>(ctx, StoreTypes.text), }) const getText = async () => { await ctx.root.$store.dispatch(ActionTypes.getText); }; return { state, getText, };
こんな具合で、開発をする上で最低限の環境を整えることに成功しました。
終わりに
Compotision API + Vuex のプロジェクト開発は現在私1人で行っていて、引き継いだ当初はあまり知見もなく不安だったのですが沢山の先駆者のブログや RFC の助けを借りなんとか1人で歩いて行けるコードになりました。 もし、 Vuex をもっと型安全に使えるプラクティスや助言などあったら是非コメントいただけるとうれしいです。
参考
【Vue3に備える】実務で使うComposition APIについて考える | by slont | Finatext | Medium