LoginSignup
3

More than 5 years have passed since last update.

vuex-aggregate で map系ヘルパーも守る

Last updated at Posted at 2018-07-26

🎉 mapXXX系に型が来た

筆者の開発した vuex-aggregate が v4 になりました。標準的な SFC をTypeScriptで強化できます。以下が最小利用例で、「これのどこが TypeScript?」と感じるかもしれません。import している Root は型付け済みで、定義元の rootState.name を名称変更すると、SFC の mapヘルパー文字列参照までもエラーが発生します。vuex-aggregate が守っているのは「リファクタによる不整合事故」です。型付けられたmapState は countname の文字列しか受け入れなくなります。

app.vue
<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はこの様な定義です。

root.ts
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 がかなり上がるかと思います。

app.vue
<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 実装に要求していますが、今までの実装とさほど差異はありません。定義例は以下の通りです。

counter.ts
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 定義直後にインスタンスをアサインします。

store.ts
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 は以下の様になっています。

counter.ts
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 がもしかしたら影響を与えるかもしれませんが、その時はまた考えます。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3