Save 37% off PRO during our Black Friday Sale! »

Typed Vuex Data Flow

B72422afc5f3ffc844f672b59122e16d?s=47 joe_re
August 24, 2017

Typed Vuex Data Flow

Meguro.es #11 @ oRo 発表資料
https://meguroes.connpass.com/event/61531/

B72422afc5f3ffc844f672b59122e16d?s=128

joe_re

August 24, 2017
Tweet

Transcript

  1. Typed Vuex Data Flow @joe_re

  2. Who am I? twitter: @joe_re github: @joe­re working in freee.K.K

  3. What is Vuex

  4. Q1: Vuex 使っている人 どれぐらいますか??

  5. Q2: その中でTypeScript やFlow を使っている人い ますか??

  6. Q3: その中で各レイヤー をまたいだ型付けまでし ている人いますか??

  7. この発表は僕がVuex を試す中で、 試行錯誤した結果です。 ベストな解決策はまだまだ余地が あると思います。 手を上げていただいた方、このあとぜひお話しましょう!

  8. DEMO https://github.com/joe­re/tubutler

  9. TypeScript or Flow? 現状Vue において楽をしたいならTypeScript を選ぶべき 公式にもサポートについて名言されている https://vuejs.org/v2/guide/typescript.html 実はVue の内部はFlow

    で実装されているが、flow では this type を扱えないのでサポートが厳しそう https://github.com/vuejs/vue/pull/5027
  10. 内部実装の方も例のApatch の決定 によりTS に置き換わるかも? https://github.com/vuejs/vue/issues/6411

  11. Typed Vue Component 現在(Vue v2.4.2) において、通常のVue インスタンスの生成 では推論が効かないため型安全性の保証が難しい Vue インスタンスに渡すオプションの中において、this

    type がany になってしまうため PR は出ているのでそのうちできるようになる模様 https://github.com/vuejs/vue/pull/5887 詳しくは@ktsn さんの発表資料をご参照ください Contextual ThisType and Vue.js https://speakerdeck.com/ktsn/contextual­thistype­and­ vue­dot­js
  12. Class­Style Vue Component 現在のところはvue­class­component を使って、Class とし てComponent を定義するのが現実解 Class で書くことによりthis

    の解決を可能にするアプローチ import Vue from 'vue' import Component from 'vue-class-component' // The @Component decorator indicates the class is a Vue component @Component({ // All component options are allowed in here template: '<button @click="onClick">Click!</button>' }) export default class MyComponent extends Vue { // Initial data can be declared as instance properties message: string = 'Hello!' // Component methods can be declared as instance methods onClick (): void { window.alert(this.message) } }
  13. Typed Vuex Data Flow ( ここからVuex の話)

  14. BattleAX https://github.com/joe­re/battle­ax そのままのVuex の定義では各レイヤーをまたいで 型安全性を保証するのが難しかった そこでVuex の薄いラッパーを作成した 以降の説明はこのライブラリの使用方法と実装アプローチ を元にお話しします

  15. Deifinition of Actions

  16. Vuex の提供するAction actions: { hoge (context, payload) { context.commit('hoge', payload)

    } } 第1 引数にaction のcontext 、第2 引数view から渡された payload を受け取る action のcontext には、ここからmutation へ渡すための関数で あるcommit やstate やgetter などのプロパティを含む (型定義を直接見た方が使える機能の把握は早い https://github.com/vuejs/vuex/blob/dev/types/index.d.ts)
  17. ところでRedux は function addTodo(text) { return { type: ADD_TODO, text

    } } 純粋関数として提供される 一切の副作用がない view から直接呼ばれる形式で定義するので、 そのままこの定義を使うだけで型安全性の保証ができる
  18. Vuex の難しいところ view 側には当然action のcontext などは存在しないので、こ の定義をview からはそのまま使えない

  19. 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, //... 省略 }; export const actions = ActionCreatorHelper<State, State, ActionTypes>()({ addToCart (payload: { id: number }) { return ({ commit }) => { commit({ type: 'ADD_TO_CART', payload: { id: payload.id } }); } }, //... 省略 } payload を第1 引数として受け取り、ActionContext は返り値 の関数定義にinject されるようにした ActionTypes の定義はMutation で使いやすい形にした( 後述)
  20. これでView から直接呼び出せる形 のAPI になったので、View の呼び 出し時に型付けすることができる

  21. 実装はこんな感じ 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 }
  22. ActionCreatorHelper の定義が奇妙な感じに ActionCreatorHelper<S, R, A>: () => <T extends ActionTree<S,

    R, A>>(ac: T): T => ac 本当は ActionCreatorHelper (AC は引数で受け取る ActionCreator の関数群) という感じにしたかった しかしこのAC だけは推論を効かせたい しかしTS のgenerics では一部にだけ推論を効かせることは できない( ヒントを与えるなら全部に与えないといけない) そこで高階関数にして、段階を分けることで対応した 他にいい方法をご存知の方は是非おしえてください!
  23. Deifinition of Mutations

  24. Vuex の提供するMutation mutations: { ['hoge'] (state, payload) { state.hoge =

    payload.hoge; } } ActionCreator でcommit されたkey で定義した関数が実行さ れる この関数は第1 引数にstate 、第2 引数にcommit された payload を受け取る
  25. ところでRedux では function todoApp(state = initialState, action) { switch (action.type)

    { case SET_VISIBILITY_FILTER: return Object.assign({}, state, { visibilityFilter: action.filter }) default: return state } } dispatch されたAction はreducer でそのまま受け取り、Action のtype property で処理を分岐する 静的型付けの観点では、ActionType をunion type として定義 しておき、type property の動的チェックにより絞り込みを効 かせることで型安全性を保証する
  26. Vuex の難しいところ Mutation が動作するタイミングではすでに振り分けが完了し ているので、union でAction を定義しても絞り込みを効かせ るタイミングはすでに過ぎている Mutation を定義した時点で受け取るpayload

    が決定されてい る必要がある
  27. 先ほど定義したActionTypes を 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--; }, // ... 省略 } Mutation を定義した時点でstatem 、 payload は決定できる
  28. 実装はこんな感じ export type Mutation<S, P> = (state: S, payload: P)

    => any; export type MutationTree<S, A> = { [P in keyof A]?: Mutation<S, A[P]>; };
  29. Definition of Getters

  30. Vuex の提供するGetter getters: { doneTodos: state => { return state.todos.filter(todo

    => todo.done) } } state や他のgetter などを引数として受け取り、 それを元に値を計算する関数 副作用はない
  31. Vuex の難しいところ 副作用がない純粋な関数なので特になし あえて言えば関数なのでview から直接呼べる形式の定義で はないので、ちょっと変換が必要

  32. 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); } } 先にGetter の計算結果を定義しておく(Getters) getters の返す値はgenerics として与えることで保証する
  33. 実装はこんな感じ 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]>; } 先にGetter の計算結果を定義する形にして解決したけど、 GetterTree の定義から推論できれば定義が要らなくなるか も? TS 力が足りなくていい方法は浮かばなかった TS glue の方、アドバイスください!
  34. Create a Store ここで今まで改変した定義の帳尻を合わせる 使い方はVuex とほぼ変わらないので、型付の観点で特筆す ることはありません

  35. こんな感じ 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;
  36. Using store from view

  37. ここまででstore は型付けできてい るので、直接import するだけで型 安全性は保証できる

  38. しかし直接import すると依存が 生まれてしまいテストに苦労する

  39. Vuex のアプローチ const app = new Vue({ el: '#app', //

    provide the store using the "store" option. // this will inject the store instance to all child components. store, components: { Counter }, template: ` <div class="app"> <counter></counter> </div> ` }) root にstore を渡すことで、呼び出されるすべての component の this.$store プロパティにstore の参照が入る (DI) これにより、すべてのcomponent から透過的にstore を呼び 出せるようになる
  40. 個人的に気に入らないところ これをやってしまうと、PresentationalComponent として 設計しても、store を呼び出すことができてしまう その結果store の構造に依存したcomponent になってしまう 性善説で言えば、再利用性を高めたいComponent ではstore

    の値を直接呼ばないようにすることで問題は起きない しかし人は善だけでは構成されていない
  41. 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 }); 第1 引数にstore 、第2 引数にvue component を受け取る 返り値としてactions, getters, state がinject されたcomponent を受け取る
  42. 実装はこんな感じ 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 のバケツリレーからは逃れられないが、 お行儀はよくなるというトレードオフ
  43. complete typed data flow on Vuex (for me)

  44. ありがとうございました!