Vue/Vuexに静的に型を付ける

最近Vue/Vuexを触っている。

前々から欲しいと思っていたのもあって、習作としてelectronでYouTubeのデスクトップクライアントを作った。

github.com

僕は仕事中はだいたいYouTubeを再生している。映像を見ながらコードを書きたい欲求があった。とはいえ、そのために作業領域を侵食されるのはつらい。 ということで前面に固定する機能と透過率を設定できる機能をつけた。 こんな感じになって便利。 f:id:joe-re:20170726071526p:plain

Vueにおいて、TypeScriptを選ぶかFlowを選ぶか

Vueにおいて、楽をしたいならTypeScriptを選ぶ方が良い。Flowに比べて、公式のサポートが断然に厚い。 公式ページにもサポートについて1セクション割かれている。

TypeScript Support — Vue.js

また、TypeScriptチームがVueとTypeScriptのStarter Kitを公開している。

github.com

最初のセットアップはこれを参考にすると捗る。

注意点として、このPRのpackage.jsonのvueの依存は以下のPR内のコミットに向いている。

Improve Vue type declarations for more canonical usage by DanielRosenwasser · Pull Request #5887 · vuejs/vue · GitHub

現在最新であるv2.4.2においてはまだマージされていないが、これが入ると、VueのComponentのオプション内でthisが推論できるようになる。

詳しくは@ktsnさんの以下の発表資料を見るとわかりやすい。

speakerdeck.com

TypeScriptにおいてはfunctionのthisをパラメータとして受け取れるゆえこれができるんだけど、さらにFlowサポートは困難になりそうな気配を感じる。

https://www.typescriptlang.org/docs/handbook/functions.html#this-parameters

これがマージされていない現在においては、vue-class-componentを使って、Componentをクラスで定義するのが現実解だろう。

余談だが、vue-class-componentではPropとWatchはアノテーションでは表現できない。
これはBabelとTypeScriptのトランスパイルの挙動の違いによるようだ。
TypeScriptを使うなら、vue-property-decoratorがこれをサポートしている。

Vuexの提供する単一方向フローへの型付け

VuexはVueにFluxの単一方向フローによるデータ更新を導入するものだ。
Vuex自体はすごく使いやすく、Fluxに触れたことがあればわかりやすいんだけど、提供しているAPIは各レイヤーを跨ぐ時に型安全性を維持するのが難しい。(理由などはあとで細かく各レイヤーの型付けを見る時に説明します。)

既存のVuexの実装に無理やり型付けするのはかなり厳しかったので、Vuexの薄いラッパーを作った。

github.com

Actionの定義

Vuexの提供するactionの定義は以下のようになる。

  actions: {
    hoge (context, payload) {
      context.commit('hoge', payload)
    }
  }

第1引数にactionのcontext、第2引数viewから渡されたpayloadを受け取る。 actionのcontextは、ここからmutationへ渡すための関数であるcommitやstateやgetterなどのプロパティを含む。 (型定義を直接見た方が何が使えるか把握しやすい。)

ReduxにおけるActionCreatorは、以下のようにviewから直接呼べる形で定義するのでViewから呼び出す場面で型付けがしやすい。

function addTodo(text) {
  return {
    type: ADD_TODO,
    text
  }
}

Vuexの提供している形式では、ViewにはもちろんActionのcontextなどはないので、定義をそのまま使って型安全を保証するのは困難だ。

そこでactionの定義は改変することにした。 具体的には以下のようにした。

import { ActionCreatorHelper } from 'battle-ax';
import { Product } from '../types';
import { State } from './index';
import shop from '../api/shop';

export type ActionTypes = {
  ADD_TO_CART: { id: number },
  CHECKOUT_REQUEST: null,
  CHECKOUT_SUCCESS: null,
  CHECKOUT_FAILURE: { savedCartItems: Product[] },
  RECEIVE_PRODUCTS: { products: Product[] }
};

export const actions = ActionCreatorHelper<State, State, ActionTypes>()({
  addToCart (payload: { id: number }) {
    return ({ commit }) => {
      commit({ type: 'ADD_TO_CART', payload: { id: payload.id } });
    }
  },
  //...省略
}

Actionはpayloadを第1引数として受け取り、ActionContextは返り値の関数定義にinjectされるようにしている。 こうすることで、Viewから直接呼び出せる形のAPIになったので、Viewの呼び出し時に型付けすることができる。

BattleAXの提供するActionCreatorHelperは、<State, RootState, ActionTypes>をそれぞれgenericsとして受け取る。 ActionTypesはkeyがmutationのkey(fluxにおけるtype)、valueがpayloadになる。 こういう定義にしているのは、mutationで受け取る時にkeyの一致判定でpayloadの推論ができるようにしているためだ。(詳しくはmutationの説明で。)

BattleAXのAction関連の定義は以下のようになっている。

export interface ActionContext<S, R, A> {
  dispatch: Dispatch;
  commit: <K extends keyof A>(params: { type: K, payload?: A[K] }) => void,
  state: S;
  getters: any;
  rootState: R;
  rootGetters: any;
}

type Action<S, R, A, P> = (payload: P) =>
 (injectee: ActionContext<S, R, A>) => any;
export type ActionTree<S, R, A> = {
  [key: string]: (payload: any) => (ctx: ActionContext<S, R, A>) => any
}

export function ActionCreatorHelper<S, R, A>() {
  return <T extends ActionTree<S, R, A>>(ac: T): T => ac
}

TypeScriptのMapped Typesを使って、commitの型安全を保証している。

commitの引数が定義を満たさなければ以下のようにちゃんとエラーになる。 f:id:joe-re:20170726091904p:plain

ActionCreatorHelperは見ればわかる通り、何もせずに型付けしてそのまま引数を返しているだけ。 ActionCreatorHelperは自身が関数で、payloadを受け取る関数を返すという若干奇妙な感じになっている。

本当は ActionCreatorHelper<S, R, A, AC> (ACは引数で受け取るActionCreatorの関数群)という感じにしたかったんだけど、このACだけは推論を効かせたかった。

TypeSctiptのgenericsは、1部のみに推論を効かせることはできない。ヒントを与えるなら全てにヒントを与えなければいけない。 そこで事前に定義をしなければいけない要素を増やすのは冗長すぎるので、関数として分けることで対応した。 他に良い方法があれば知りたい。

Mutationの定義

Vuexの提供するMutationの定義は以下のようになる。

  mutations: {
    ['hoge'] (state, payload) {
      state.hoge = payload.hoge;
    }
  }

ActionCreatorでcommitされたkeyで定義した関数が実行される。この関数は、第1引数にstate、第2引数にcommitされたpayloadを受け取る。

Reduxでは、dispatchされたActionはreducerでそのまま受け取り、Actionのtype propertyで処理を分岐する。

function todoApp(state = initialState, action) {
  switch (action.type) {
    case SET_VISIBILITY_FILTER:
      return Object.assign({}, state, {
        visibilityFilter: action.filter
      })
    default:
      return state
  }
}

静的型付けの観点では、ActionTypeをunion typeとして定義しておき、type propertyの動的チェックにより絞り込みを効かせることで型安全を保証するのが一般的だ。

BattleAXでは、先ほど定義しActionTypesの定義(keyがmutationのkey、valueがpayload)をgenericsとして受け取る。

import { MutationTree } from 'battle-ax';
import { ActionTypes } from './actions';
import { State } from '../store';

export const mutations: MutationTree<State, ActionTypes> = {
  ['ADD_TO_CART'] (state, payload) {
    state.lastCheckout = null;
    const record = state.added.find(p => p.id === payload.id);
    if (!record) {
      state.added.push({ id: payload.id, quantity: 1 });
    } else {
      if (record.quantity) record.quantity++;
    }
    const target = state.all.find(p => p.id === payload.id);
    if (target && target.inventory) target.inventory--;
  },
  // ...省略
}

ここはVuexのAPIを変更せずに型づけすることができた。 BattleAXでのMutationの型定義は以下のようになっている。

export type Mutation<S, P> = (state: S, payload: P) => any;
export type MutationTree<S, A> = {
  [P in keyof A]?: Mutation<S, A[P]>;
};

ここでもMapped Typesを使っている。 受け取ったActionTypesのkeyとmutationのkeyが一致していること、payloadはActionのvalueになること、という定義にすることで型安全性を保証している。 定義を満たしていなければ、以下のようにちゃんとエラーになる。 f:id:joe-re:20170726092433p:plain

Getterの定義

Vuexの提供するGetterの定義は以下のようになる。

  getters: {
    doneTodos: state => {
      return state.todos.filter(todo => todo.done)
    }
  }

stateを第1引数で受け取り、そこから値を変換している。第2引数で他のgetterも受け取り、使用することもできる。

  doneTodosCount: (state, getters) => {
    return getters.doneTodos.length
  }

ここのAPIは変えずに型付けすることができた。

import { State } from './index';
import { FullItem } from '../types/Item';
import { GetterTree } from 'battle-ax';

export type Getters = {
  nextVideo: FullItem | null;
  nextVideoId: string | null;
  relatedVideos: FullItem[]
};

export const getters: GetterTree<State, State, Getters> = {
  nextVideo: (state, getters) => {
    const next = state.relatedVideos.filter((v) => !state.playedVedeoIds.includes(v.id.videoId));
    if (next.length === 0) {
      return null;
    }
    return next[0];
  },

  nextVideoId: (state, getters) => {
    return getters.nextVideo && getters.nextVideo.id.videoId;
  },

  relatedVideos: (state, getters) => {
    return state.relatedVideos.filter(v => v !== getters.nextVideo);
  }
}

BattleAXの提供するGetterTreeを利用するためには、genericsとしてGetterの定義を渡す必要がある。 Gettersはkeyがgetterのkey、valueが計算結果になるオブジェクトとして定義する。 BattleAXの型定義は以下のようになっている。

export type GetterResult = { [key: string]: any }
export type Getter<S, R, G, V> = (state: S, getters: G, rootState: R, rootGetters: any) => V;
export type GetterTree<S, R, G extends GetterResult> = {
  [P in keyof G]: Getter<S, R, G, G[P]>;
}

genericsとして受け取ったGettersは、ここでもMappedTypeを使い、他のレイヤーから呼ばれた時も型を維持できるようにmappingしている。 このGettersはなんとか推論で定義せずに済むかも?と思ったけど、ts力が足りなくてそこまでには至れなかった。

Storeの作成

BattleAXの提供するcreateStoreを使い、storeを作成する。

import { mutations } from './mutations'
import { actions } from './actions';
import { FullItem } from '../types/Item';
import getters from './getters';
import { createStore } from 'battle-ax';

export type State = {
  items: FullItem[],
  relatedVideos: FullItem[],
  playedVedeoIds: string[],
  miniPlayerMode: boolean,
  transparentRate: number,
  loading: boolean
}

const state: State = {
  items: [],
  relatedVideos: [],
  playedVedeoIds: [],
  miniPlayerMode: false,
  transparentRate: 0,
  loading: false
}

const store = createStore({
  strict: process.env.NODE_ENV !== 'production',
  state,
  actions,
  mutations,
  getters,
});

export default store;

Storeをラップすることで、先ほど改変したActionCreatorやMutationの帳尻を合わせてVuexに渡している。 ここでは特に特筆することはないので、気になる方は実装を見てください。

View -> Action

BattleAXの提供するstoreは型付けされているので、View側でimportした時に型安全性を保証できる。 以下のようにactionsの補完も効く。 f:id:joe-re:20170726094354p:plain

このままimportしたstoreを使うこともできるが、それではComponentがstoreに依存してしまう。 そうすると、テストをする時に非常に厄介になる。

Vuexでは、rootにstoreをinjectすることでComponentの全てからstoreを呼び出すことができるようになる。

State · Vuex

しかし、これでは全てのcomponentからstoreの値をどこでも呼べるようになってしまう。 PresentationalComponentとContainerComponentを分離する上で、これは都合が悪いと僕は思う。

性善説で言えば、再利用性を高めたいComponentではstoreの値を直接呼ばないようにすることで問題は起きないだろうが、人は善だけでは構成されていない。

BattleAXでは、injectという関数を用意した。これは第1引数にstore、第2引数にvue componentを受け取る関数で、これを通した関数はaction, getter, stateがinjectされる。

以下のように使う。

import Vue from 'vue';
import VueRouter from 'vue-router';
import Root from './containers/RootContainer.vue';
import Home from './containers/HomeContainer.vue';
import PlayVideo from './containers/PlayVideoContainer.vue';
import miniPlayer from './containers/miniPlayerContainer.vue';
import store from './store/index';
import { inject } from 'battle-ax'

Vue.use(VueRouter);

const routes = [
  { path: '/', component: inject(Root, store),
    children: [
      { path: '/mini-player/:id?', name: 'miniPlayer', component: inject(miniPlayer, store) },
      { path: '/', name: 'home', component: inject(Home, store) },
      { path: '/:id', name: 'player', component: inject(PlayVideo, store) },
    ]
  },
];

export default new VueRouter({ routes });

実装は以下の通り。

export function inject<S, G, A, AC extends ActionTree<S, S, A>>(container: any, store: BAStore<S, G, A, AC>) {
  const name = container.options.name || 'unknown-container';
  return {
    name: `injected-${name}`,
    components: {
      [name]: container
    },
    data: () => ({
      actions: store.actions,
      state: store.state,
      getters: store.getters
    }),
    template: `
      <${name}
       :actions="actions"
       :state="state"
       :getters="getters"
     />
    `
  }
}

HOCパターンを使って、actions、state、gettersをpropsとして注入しているだけ。VueのHOCの実装パターンがよくわからなかったんだけど、これで良かったんだろうか。 これを使うとPropsのバケツリレーからは逃れられないが、お行儀はよくなる。そこはトレードオフだ。

ちなみにViewのtemplateを使うと、その中でアクセスされる変数が本当にあるかどうかのチェックは現状ではできない。 このチェックができないとリファクタの段階においてかなり厳しいので、基本的にtemplateで使う値をstoreから取りたい時には、component内で全てcomputed propertyとして定義することにした。

以下のような感じ。

<script lang="ts">
import Player from './Player.vue';
import { State } from '../store';
import VideoList from './VideoList.vue';
import Thumbnail from './Thumbnail.vue';
import { actions } from '../store/actions';
import { Getters } from '../store/getters';
import { Component, Prop, Vue } from 'vue-property-decorator'

@Component({
  components: { Player, VideoList, Thumbnail },
})
export default class PlayVideoPage extends Vue {
  @Prop({ type: Object, required: true})
  actions: typeof actions;

  @Prop({ type: Object, required: true })
  state: State;

  @Prop({ type: String, required: true })
  id: string;

  @Prop({ type: Object, required: true })
  getters: Getters;

  get videoId() {
    return this.id;
  }

  get nextVideo() {
    return this.getters.nextVideo;
  }

  get nextVideoId() {
    return this.getters.nextVideoId;
  }

  get relatedVideos() {
    return this.getters.relatedVideos;
  }

  get miniPlayerMode() {
    return this.state.miniPlayerMode;
  }
}
</script>

めっちゃ冗長だけど、これで変更には強くなる。これもトレードオフだ。 ただここは、技術的にはtemplate内でのthisのアクセスをチェックすればいいだけだと思うので、そこまで苦労せずに事前チェックできそうな気がする。時間を見つけてトライしたい。

まとめ

こんな感じでVue/Vuexを型付けすることができた。 これでだいたい思う通りになったんだけど、SFCを使った時のtemplate内でのcomponentの呼び出し時のpropsの静的チェックができないのが一歩Reactに劣る。

ただこれもktsnさんが手をつけているので、そのうちできるようになるかもしれない。(期待!) github.com

Vueは書いていてすごく楽しかったし、使いやすいなーと思った。