Composition API + Typescript で Vuex をリアクティブに安全に使いたい

こんにちは、毎年アドカレの時期しかブログを更新しないたじまです。(今年は少し頑張ったかな?)

この記事は、 Aizu Advent Calender 2020 の14日目の記事です。 13日目は虚無さん、15日目は spookydokey さんです。

さて、 Composition API といえば今年の 9/18 に正式リリースされたVue.js v3.0 が記憶に新しいかと思います。

github.com

この記事では、 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.vuejs.org

Composition API で Vuex を使う上での困った点

Composition API の強みであるロジック部分のモジュール分けなどはこのブログが参考になりました。

medium.com

このブログでは Vuex 関わる部分だけ簡単に書いていきます。

Vuex + TypeScript を使うときは、 vuex-module-decorators などのライブラリを使うことが多いと思いますが、 Vue Class Module の書き方に依存していて Composition API での使い方がわからなかったり、そもそも自分に経験がなかったりなどの理由で導入を見送りました。

下記のコードは、ボタンをクリックしたら Vuex の action 経由で text を fetch するというコードの一部です。 onClickgetText を 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 をリアクティブに使いたい

qiita.com

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 }
  );

ActionMutation は、Vuex.ActionVuex.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 によって切り出したモジュールの中でも、 ActionTypesStateTypes を使うことで補完を効かせて 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 をもっと型安全に使えるプラクティスや助言などあったら是非コメントいただけるとうれしいです。

参考

Vue.js

【Vue3に備える】実務で使うComposition APIについて考える | by slont | Finatext | Medium

先取りVue 3.x !! Composition API を試してみる - Qiita

Vuexのstateをcomposition-apiでReactiveに使う - Qiita