TS、TSX、Vuex とかの雑感
- 昨今のコードが肥大化するようなフロントエンド開発では、コードのモジュール分割は必須。型情報ベースにモジュール間の参照・連携をし、品質や保守性を向上してくれる TypeScript や VS Code のようなエディタも必須と感じている
-
TypeScriptは過去最高のプログラミング言語だと思う - keroxpのScrapbox
- 「1 ファイルを超えるプログラムには型が必要だ」 <-- 同感
-
TypeScriptは過去最高のプログラミング言語だと思う - keroxpのScrapbox
- 3 大フレームワークの選定の際も、まずは TS の対応状況がどうなってるのかが気になる
- 作るモノによってはこれらは使わず、素の TSX だけ使えば良いのでは?とか思う時もある
- Angular とかだと HTML 周りで型がはずれちゃうことで辛い経験をすることもしばしあり、TSX が欲しくなる
- マークアップする人には TSX(JSX)は不人気な印象あり、、、仕事で扱う場合ではケースバイケースで素の HTML と使い分ける必要がありそう
- Vue で TSX 使うとなると「それなら React でいいじゃん」ってなりがちだけど、素の HTML でも TSX でも簡単に使い分けられる Vue はある意味優秀とも思う
- (ただ Vue の場合、他の 2 つと比べると昔ながらの AngularJS 的な匂いの機能も雑多に盛り込まれてる印象もあり、実装指針とか決めとかないと後のバグの原因になることも...)
- そういう意味では他の FW より選択肢のある Vue だけど Vuex の TS 対応が厳しい問題がある(公式の対応に期待したい)
Vuex の型問題
- Vuex のストアの定義については、普通に型付きで書けば良いだけなので問題ない(actions から呼ぶ dispatch や commit は別)
import { bar, barModulePath } from './modules/bar';
export const rootState = {
count: 5,
};
export type RootState = typeof rootState;
export const root = {
state: rootState,
getters: {
count(state: RootState) {
return state.count;
},
},
mutations: {
addCount(state: RootState, payload: { qty: number }) {
state.count += payload.qty;
},
},
modules: {
[barModulePath]: bar,
},
};
- ただ、これらを参照したり実行したりする側のコードでは、型の効かない辛い記述になってしまう
@Component
export default class Counter extends Vue {
get rootCount(): number {
return this.$store.getters.count; // 型が効かない
}
get barCount(): number {
return this.$store.getters['bar/count']; // 型が効かない
}
rootCountUp() {
this.$store.commit('addCount', { qty: 1 }); // 型が効かない
}
...
- TS で Vue 使うなら下手に Vuex は使わないで、独自のストアパターンで実装した方が良い気がする
- ただ、Vuex を使う場合、公式故に使い方も広く世に知られており、特定プロジェクトのための独自仕様に学習コストをかけなくても済むいというメリットもある
- Vuex になんとかして型付けしてくれるライブラリはいろいろあるようだけど、Vuex 本来の書き方と大きく変わっちゃてたら、上記のようなメリットはあまり感じられない
- ので、ストアの定義方法はそのままに、ストアを参照・実行する側のみにうまい具合に型付きで実行できるライブラリがあったらうれしいのだけど...
Vuex の型問題で解決したいところ
- たとえば、ストアの定義方法はそのままに、以下みたいな記述でタイプセーフに mutations を呼び出せたらうれしい(タイプセーフ前提でのこの書き方なら、VS Code の名前変更機能でプロパティ名を変更した場合、全ての参照箇所を自動修正できたりもするので、プロパティ名を引数渡しする形より良さそう)
const rootCommitters = getCommitters(store, root.mutations);
...
export default class Counter extends Vue {
rootCountUp() {
rootCommitters.addCount({ qty: 10 });
}
...
- とりあえず型を無視していいなら以下のコードで実現できる
export const getCommitters = (
context: any, // `Store<S> | ActionContext<S, R>`とかにしたほうが良いけどとりあえず any で
mutations: { [name: string]: (state: any, payload: any) => void },
options?: {
modulePath?: string;
root?: boolean;
},
) => {
return Object.keys(mutations).reduce((output: { [name: string]: any }, key: string) => {
output[key] = (payload: any) => {
const name = options && options.modulePath ? `${options.modulePath}/${key}` : key;
context.commit(name as string, payload, options || {});
};
return output;
}, {})
};
型パズルで型を後付けしてみる
- Conditional Types とか infer を使って型を後付けできないか考えてみる
- Conditional Types とか infer については、以下の記事の説明が親切で分かりやすい
- 公式ドキュメントを読んでも infer が理解できない人のための infer の説明 - Qiita
- mutations のメソッドをまとめたオブジェクトの型なら、次のように書けば良さそう
type Mutations = { [name: string]: (state: any, payload: any) => void };
type MutationsAdapter<M extends Mutations> = {
[P in keyof M]: (payload: any) => void;
};
const rootCommitters =
getCommitters(store, root.mutations) as MutationsAdapter<typeof root.mutations>
rootCommitters.addCount({ qty: 10 }); // メソッド名は補完されるけど、payload は any のまま
- これだと payload が any 固定になっちゃうので、Conditional Types と infer を使って payload の型を得るようにしてみる
// T が (state: any, payload: infer U) => void に代入可能なら U そうでないなら never
type MutationsPayload<T> = T extends (state: any, payload: infer U) => void ? U : never;
...
type MutationsAdapter<M extends Mutations> = {
[P in keyof M]: (payload: MutationsPayload<M[P]>) => void;
};
- payload 不要な mutations もありえるので、Conditional Types で出し分けてみる
type MutationsAdapter<M extends Mutations> = {
// mutations(`M[P]`)が`(state: any) => void`に代入可能なら A そうでないなら B
[P in keyof M]: M[P] extends (state: any) => void
? () => void // A
: (payload: MutationsPayload<M[P]>) => void; // B
};
- これで型が効いてコードも補完されるようになる、めでたし!(getters も actions も同じような方法で型付けすれば良さそう)
ライブラリ化してみた
- https://github.com/cyokodog/vuex-adapter
- https://github.com/cyokodog/vuex-adapter/tree/master/packages/demo
// ルートモジュール
const rootStore = new VuexAdapter(store, root);
rootStore.getters.count;
rootStore.committers.addCount({ qty: 1 });
// サブモジュール
const barStore = new VuexAdapter(store, bar, { modulePath: 'bar' });
barStore.committers.addCount({ qty: 10 });
barStore.dispatchers.passCountToRoot();
- 面倒なのでモジュール単位で指定できるようにしてみた
- actions から commit や dispatch をする場合も、上記と同じ方法で実行可能
- あるいは第一引数に store じゃなくて、actions が実行される都度 actionContext を渡しても OK
- https://github.com/cyokodog/vuex-adapter/blob/master/packages/demo/src/store/root/modules/bar/index.ts#L30-L45