🎉 mapXXX系に型が来た
筆者の開発した vuex-aggregate が v4 になりました。標準的な SFC をTypeScriptで強化できます。以下が最小利用例で、「これのどこが TypeScript?」と感じるかもしれません。import している Root は型付け済みで、定義元の rootState.name を名称変更すると、SFC の mapヘルパー文字列参照までもエラーが発生します。vuex-aggregate が守っているのは「リファクタによる不整合事故」です。型付けられたmapState は count
と name
の文字列しか受け入れなくなります。
<script lang='ts'>
import Vue from 'vue'
import * as Root from '../store/root'
const computed = {
...Root.mapState(['name']),
...Root.mapState({ rootName: 'name' }),
...Root.mapState({ rootNameLabel: state => `root is ${state.name}` }),
}
3つ目の書き方でバインドされる state引数 にも、 RootState の型がついています。Rootはこの様な定義です。
import { fromState } from 'vuex-aggregate'
interface State {
count: number
name: string
}
const state: State = {
count: 0,
name: 'unknown'
}
export const namespace = ''
export const { mapState } = fromState(state, namespace)
fromState
で RootStateのみを知っている Vuex.mapState を生成します。Vuex が提供しているそれにプロキシを通しつつ、名前空間を解決し「型」をかけています。これだけで型がつくので、TypeScript 導入のハードルも下がるかと思います。
以下はリポジトリのexampleです。まずは vscode 等で example SFC を適当に壊してみてください。開発サーバーを立てるまでもなく、怒られると思います。また、定義したメソッドや state が即座にコードヒントに出てくるので、DX がかなり上がるかと思います。
<template>
<div>
<p>rootName = {{rootName}}</p>
<p>rootNameLabel = {{rootNameLabel}}</p>
<p>nestedModuleName = {{nestedModuleName}}</p>
<p>nestedModuleNameLabel = {{nestedModuleNameLabel}}</p>
<p>count = {{count}}</p>
<p>double = {{double}}</p>
<p>expo2 = {{expo2}}</p>
<p>countLabel = {{countLabel}}</p>
<p>isRunningAutoIncrement = {{autoIncrementLabel}}</p>
<p>name = {{nameLabel}}</p>
<div>
<button @click="increment()">+1</button>
<button @click="decrement()">-1</button>
<button @click="asyncIncrement()">asyncIncrement</button>
<button @click="toggleAutoIncrement(100)">toggleAutoIncrement</button>
</div>
<div>
<input @change="e => setName(e.target.value)" />
</div>
</div>
</template>
<script lang='ts'>
import Vue from 'vue'
import * as Root from '../store/root'
import * as Counter from '../store/modules/counter'
import * as NestedModule from '../store/modules/nested/module'
const computed = {
...Root.mapState({ rootName: 'name' }),
...Root.mapGetters({ rootNameLabel: 'nameLabel' }),
...NestedModule.mapState({ nestedModuleName: 'name' }),
...NestedModule.mapGetters({ nestedModuleNameLabel: 'nameLabel' }),
...Counter.mapState(['count']),
...Counter.mapState({ double: state => state.count * 2 }),
...Counter.mapGetters(['nameLabel', 'autoIncrementLabel']),
countLabel() {
return Counter.getters.countLabel('pt')
},
expo2() {
return Counter.getters.expo(2)
}
}
const methods = {
...Counter.mapMutations(['increment', 'decrement']),
...Counter.mapActions(['asyncIncrement']),
setName(value: string) {
Counter.commits.setName(value)
},
toggleAutoIncrement(duration: number) {
Counter.dispatches.toggleAutoIncrement({ duration })
}
}
export default Vue.extend({ computed, methods })
</script>
🤔 何をやっているのか
Vuex は Flux と mvvm の良いところ取りをしている訳ですが、モジュールが分散することにより、それぞれの存在や構造を知ることは出来ないため、型定義は今まで困難でした。
vuex-aggregate では、TypeScript が型解析出来る体系を module 実装に要求していますが、今までの実装とさほど差異はありません。定義例は以下の通りです。
import {
fromState,
fromMutations,
fromActions,
fromGetters,
Injects,
StateFactory
} from 'vuex-aggregate'
import { wait } from '../../utils/promise'
import * as Root from '../root'
// ____________________________________
//
// @ State
const namespace = 'counter'
interface State {
count: number
name: string
isRunningAutoIncrement: boolean
}
const state: State = {
count: 0,
name: 'unknown',
isRunningAutoIncrement: false
}
const stateFactory: StateFactory<State> = injects => ({ ...state, ...injects })
const { mapState } = fromState(state, namespace)
// ____________________________________
//
// @ Getters
const _getters = {
nameLabel(
state: State,
getters: any,
rootState = Root.state, // initial props for InferredType
rootGetters = Root.getters // initial props for InferredType
): string {
console.log(`root name is ${rootState.name}. rootGetters nameLabel = ${rootGetters.nameLabel}`)
return `my name is ${state.name}`
},
autoIncrementLabel(state: State): string {
const flag = state.isRunningAutoIncrement
return flag ? 'true' : 'false'
},
countLabel(state: State): (unit: string) => string {
return unit => {
return `${state.count} ${unit}`
}
},
expo(state: State): (amount: number) => number {
return amount => {
return state.count ** amount
}
}
}
const { getters, mapGetters } = fromGetters(_getters, namespace)
// ____________________________________
//
// @ Mutations
const mutations = {
increment(state: State): void {
state.count++
},
decrement(state: State): void {
state.count--
},
setCount(state: State, count: number): void {
state.count = count
},
setName(state: State, name: string): void {
state.name = name
},
setRunningAutoIncrement(state: State, flag: boolean): void {
state.isRunningAutoIncrement = flag
}
}
const { commits, mutationTypes, mapMutations } = fromMutations(mutations, namespace)
// ____________________________________
//
// @ Actions
const actions = {
async asyncIncrement() {
await wait(1000)
commits.increment()
},
async toggleAutoIncrement(
{ state }: { state: State },
{ duration }: { duration: number }
) {
const flag = !state.isRunningAutoIncrement
commits.setRunningAutoIncrement(flag)
while (true) {
if (!state.isRunningAutoIncrement) break
await wait(duration)
commits.increment()
}
}
}
const { dispatches, actionTypes, mapActions } = fromActions(actions, namespace)
// ____________________________________
//
// @ ModuleFactory
const moduleFactory = (injects?: Injects<State>) => ({
namespaced: true, // Required
state: stateFactory(injects),
getters: _getters,
mutations,
actions,
// modules: Don't use nested modules. if you need them, resolve in file namespace.
})
export {
State,
namespace,
moduleFactory,
mutationTypes,
actionTypes,
getters,
commits,
dispatches,
mapState,
mapGetters,
mapMutations,
mapActions
}
純粋な javascript 実装で言うと、各々コールした from 関数内部の clojur にエイリアス文字列を確保したり、vuex-aggregate 内部にアサインした store インスタンス・mapHelper 各種を叩いているだけです。VuexAggregate.use(store)
で、store 定義直後にインスタンスをアサインします。
Vue.use(Vuex)
const store = new Vuex.Store({
...rootFactory({ name: 'ROOT' }),
modules: {
[Counter.namespace]: Counter.moduleFactory({ name: 'COUNTER' }),
[NestedModule.namespace]: NestedModule.moduleFactory({ name: 'NESTED_MODULE' })
}
})
VuexAggregate.use(store) // Required
export { store }
📝 gettersメソッドの引数ついて
getters の関数は第4引数までとれますが、example は以下の様になっています。
nameLabel(
state: State,
getters: any,
rootState = Root.state
rootGetters = Root.getters
): string {
第2引数は関数が属する getters を指しますが、ここの推論は不可能です。もしここに型が必要な場合は、interface Getters を定義し、それに沿った _getters を定義してください。第3・第4引数は、default option に Root を当てることで、引数から推論型を得ることが出来ます。
終わりに
vuex-aggregate は TypeScript2.8 の新機能で出来る様になったところが随所にあり、現状で基本的な機能は型で守れてると思います。引数が必要な関数は、定義元の引数型変更を追従出来ないので、mapヘルパー利用は避けましょう。
scripts と template の間で型の疎通はもちろん出来ないので、そこまでシビアになりたい人は、もう React 使った方が要件にあっていると思います。
また、これは外から型をつけるタイプのモジュールであり、ドキュメントをなぞって推論導出する様に型定義を書いています。筆者は Vuex の内部実装に詳しい訳ではありませんので、最新の Vuex と乖離する可能性も把握した上で利用してください。
Nuxt などではまだ試していないので、create module がもしかしたら影響を与えるかもしれませんが、その時はまた考えます。