Edited at

2018年Vue.jsとVuexを使ってる人には必ず知っていてほしい開発やメンテナンスの際に役立つ設計とTipsとサンプルコード


初めに

※ 全てのコメントに返事しません。

※ 記事の内容に意見がありましたら直接編集リクエストをください。


ゴール

ReduxやVuex、Fluxのパワーワードに負けずになんとなくではなくちゃんとFluxを理解し、実践して恩恵を受ける。

役立つVuexのTipsを身につけコード品質を向上させる。

VuexとVueRouterの落とし穴の把握。

リファクタリングの障害を減らしコードを追いやすくする。


Vuexの穴


1. Vuex提供のmap系の辛み

VuexのmapActionsとか、createNamespacedHelpersめっちゃ便利ですよね。

import { createNamespacedHelpers } from 'Vuex';

const { mapGetters } = createNamespacedHelpers('hoge/fuga/yeah');

export default {
computed: mapGetters([
'helloWorld',
'user',
]),
};

これらはmap系はthis.$storeなんたらの糖衣構文です。つまり、アクションが実行されるVueコンポーネントにstoreがインストールされている必要があります。

例えばよくあるmodalとかdialogとかの実装を見ると、new Vue()して、それでモーダルコンポーネント内に自分たちが書いたHTML等を飛ばしてマウントするという手法を取る実装があります。これはVueの仮想DOMのTreeを破壊しないVueフレンドリーな実装です。

しかし、new Vueをするという事は、そこでstoreを与える必要がありますが、大抵のライブラリはそれには対応していないでしょう。もちろんthis.$routeなどのvue-routerへのアクセスも同じです。

new Vue({

- store,
- router,
});

一応こちらがElementUIの参考実装です。

https://github.com/ElemeFE/element/blob/dev/packages/tooltip/src/main.js

そしてこちらが自分が書いた参考実装です。

https://www.npmjs.com/package/vue-proxy-Component

これらの問題から回避するためにできるだけstorerouterを直接importしたいです。


今度こそFluxを見通しよく成功させる

その他にもthisスコープがズレたりする場合にとても困ることになります。

しかもVueインスタンスが作成される前のライフサイクルフックでも使えません。

そもそもVuexは単一状態木でsingle source of truthを思想としてもっているので基本singletonのはずです。

なのにVueインスタンスへインストールされたstoreを頼りにstoreへアクセスするのは微妙だなぁと思っています。

しかも型がとてもつけづらいですし、no-unused-varsとかが効かないです。

しかもactionはvue-routerのミドルウェアとかから叩かれることもありますし、storeへのアクセスへのシンタックスが統一されないのはとてもよくないです。

これを回避するためにFluxの生みの親、FacebookのFlux/utilsや、Reactでよく使われるReduxやAngularで登場するseriviceやrx製store、ngrx/storeを参考にそもそものVuexの設計と使い方を見つめ直し、Fluxをして問題を解決していきたいと思います。


Fluxの説明

※1(基本)ComponentがActionを実行します。

ActionはStoreが要求する形式のObjectを構築し、送出します。

Storeは受け取ったObjectのタイプを確認し、それと対応する処理があれば処理を実行します。

 ※2その処理の結果stateが変更された場合storeは購読者に対してstateが変更された事を購読者へ※3通知する必要があります。

その通知を受け取ったらComponentは自身のstateを書き換えます。

※1 middlewareやwebsocket等もactionを発火します。

※2 基本observableで実装される事が多い。

※3 Vuexはstateに変更があったら自動で最適化された仕組みでvue Componentに通知する仕組みがあります。この仕組みがとても便利なのでvue.js開発だとredux等その他storeライブラリよりVuexが基本一枚上手を取ることになります。しかし現状型がつけづらく、その他列挙した問題があり現状だとまだまだ改善点があります。しかしVuexをTypescriptで書き換えて型を付けるプロジェクトが進んでるっぽいのでまだまだ期待できます。


Fluxを利用した高度な実装パターン

Fluxに高度なアーキテクチャを適用して、redux-sagaライクに完全なイベント駆動開発で開発することもできます。

Vuexのdispatch => actionでも似たような事ができますが、Vuexのstoreはactionとmutationも含めて1つのモジュールとして扱おうという傾向がありactionとmutationが疎結合になっていないのでイケてません。ここは分離したいところです。が、今回ここの話は本題ではないのであまり詳しくしません。


Flux生みの親のfacebookが開発しているFlux/utilsを参考にVuexで型安全で見通しの良いFlux実装パターン

まずfacebookのFlux/utilsはtypescriptではありません。ですので、これをtypescriptでゆるふわ型安全にして開発しようという試みです。別に型安全の部分を使わなくてもメリットはあると思います。


制限

まずこれらの制限を設けます。


  • Vuexのactionsを使わない.

  • VuexのcreateNamespaceHelpersを使わない。

  • Vuexのmap系を使わない。

  • Componentでthis.$storeを使わないでstoreインスタンスをimport`する


サンプルディレクトリ構成

これのroot.tsが単一状態木のルートです。

src/store/

    - root.ts

    - auth/

       - actionPayloads.ts

       - actionTypes.ts

       - index.ts

コードはこんな感じにしてシングルトンにします。


src/store/root.ts

import Vuex from 'vuex';

import auth, {State as AuthState} from './auth/index';

type RootState = {
auth: AuthState;
};

export default new Vuex.Store<RootState>({
modules: {
auth,
},
});


しかもstateの型を与えている為、import store from '@/store/root';で参照した場合、これは型安全になります!

補完も聞きますし、存在しないパラメータ等を参照したらエラーになります!素晴らしいですね!


1. ActionTypeを作成

まずActionTypeを書きます。

これはreduxでよく登場するパターンです。

これでnamespace付きでtypeを定義しておくと、createNamespaceHelpersが必要なくなります。

actionsTypesはよくactionsTypes.tsファイルに纏められたりしますが、私は次に登場するActionクラスを定義しているファイルに一緒に纏めています。

その理由は次の項目で説明します。


src/store/auth/actions.ts

const namespace = 'auth/';

const LOGIN = namespace + 'LOGIN';
const AUTHENTICATION_FAILED = namespace + 'AUTHENTICATION_FAILED';



感想

個人的にはactionTypeはシンボルやuuidにして管理すればnamespaceすら定義しないで意図しない重複バグを完全に消せます。

しかし、vuexはsymbolをtypeに許可しませんし、uuidだとvue debug toolで見た際に何のイベントが起こったか全くわかりません。悲しす(´;ω;`)


2. Actionを作成 (別名: ActionObject, Action


前置き

Actionの形式には非公式のFlux Standard Actionと言うActionのインターフェイスに関する規約が存在します。それについては後ほど登場します。

このクラス自体は正確にはActionCreatorと呼ばれたりします。ActionCreatorはActionを作るのを責務としてます。

つまりインスタンス化したこのクラスは、Actionというオブジェクトに変化します


本題

これはthis.$store.commit({このオブジェクト})です。

このオブジェクトはtypeのキーを必ず持っている必要があります。それが先程作成したactionTypeです。

また、これを必ずイン使えば、存在しないmutationを実行してしまうバグを消せます。

このActionを作成することにより、このアクションを発火したかったけど、typeが間違えた、引数の型を間違えた、payloadの構築方法間違えたなどから来るバグを全て潰せます。これはとても便利で価値のあるclassです。


src/store/auth/actions.ts

const namespace = 'auth/';

const LOGIN = namespace + 'LOGIN';
const AUTHENTICATION_FAILED = namespace + 'AUTHENTICATION_FAILED';

export class LoginActionPayload {
public static type = LOGIN;
public readonly type = LOGIN;

constructor(readonly user: User) {}
}

export class AuthFailedPayload {
public static readonly type = AUTHENTICATION_FAILED;
public readonly type = AUTHENTICATION_FAILED;
}



使われ方

このActionPayloadはこの様に使われるのを想定しています。

import store from '@/store/root';

store.commit(new AuthFailedPayload());

ひと目でなにがしたいかがわかります。また、これならtypeやpayloadを間違えようがありません。


Flux Standard Action

一応今回自分がActionと呼称しているオブジェクトには非公式ながらも規約があり、その規約はFlux Standard Actionと呼ばれています(結構認知度高いっぽい)。

FluxStandardActionのActionObjectのフォーマットは以下の通りです。

interface Action<Payload> {

type: string;
payload: Payload;
error?: boolean;
meta?: Object;
}

このフォーマットの方がより柔軟性があり、データの境界が増え、又割と広まっているActionのフォーマットの恩恵を受けることができます。

errorとmeta項目によりmutationの中で分岐を発生させることができます。

しかしerrorとmetaが存在するかどうかでmutation内で分岐が発生し、しかもpayloadの状態にも影響を与えます。

条件分岐が増えるだけバグの原因は増えますし、errorならerrorで新たなアクションを作成した方がシンプルでわかりやすいと思います。

ですので表現の自由度を減らし、制限を設ける事を自分はしています。

また、ActionPayloadはtypeが含まれていますが、vuexライクで十分シンプルで開発からfluxの要件を満たす程には十分表現力がある思います。


解消したバグ

import store from '@/store/root';

store.commit('helloworldd', 'message');

helloworldというmutationを実行したいのにhelloworlddとタイポしてしまいました。しかしこれはなかなか気づけません。しかも第2引数も本当はNumberを要求していましたが、文字列を突っ込んでしまいました。これはとても困りますし、バグの温床になりえます。


3. Stateと対応するMutationを作成

Mutationは受け取ったActionPayloadを利用してstateへ変化を加える処理をします。

MutationのキーはActionPayloadのtype以外利用してはいけません。これならバインドミスとかも発生しませんし、型安全にMutationを作成できるでしょう。


src/store/index.ts

import {LoginActionPayload, AuthFailedPayload} from '@/store/auth/actionPayloads';

type State = {
isInitialized: boolean;
loggedIn: boolean;
user?: User;
};

export const state: State = {
isInitialized: false,
loggedIn: false,
user: undefined,
};

const mutations = {
[LoginActionPayload.type](state: State, payload: LoginActionPayload) {
state.isInitialized = state.loggedIn = true;
state.user = payload.user;
},
[AuthFailedPayload.type](state: State) {
state.isInitialized = true;
},
};

export default {
state,
mutations,
};



4. gettersを作成

vuexのstateはcomputed内なら例え関数にラッピングしていてもリアクティブです。

vuexのgetterを使っていませんがこんな感じです。 デモ

ですのでもしgetterが欲しい場合は好きなように作りましょう!ちなみに型安全です!


5. Serviceを作成

Serviceはアプリケーションロジックをカプセル化したものです。

これはAngularのService、Vuexのactionsのようなものです。

storeをcommit(reduxで言うならdispatch)していいのはcontainer Componentとserviceだけです。

また、serviceは必ずstoreへcommitしなければいけないわけではありません。外部へ通信する処理をするだけしてstoreへコミットしなくても良いです。あくまでもビジネスロジックをカプセル化したものです。


src/services/user/login.ts

import store from '@/store/root';

import {LoginActionPayload, AuthFailedPayload} from '@/store/auth/actionPayloads';
import {getUserByLocalCacheKey} from './common';

export async function loginWithLocalCache(): Promise<void> {
try {
const user = await getUserByLocalCacheKey();
store.commit(new LoginActionPayload(user));
} catch (e) {
store.commit(new AuthFailedPayload());
}
}



注釈

serviceを作成した場合、User InteractionとAction Creatorの間に1つの層が出来てしまいます。

つまり、Fluxの以下の有名な図に完全には従っていないということです。



これはAngularの提唱するMV Whateverパターンに該当します。

ServiceをContainerじゃないと発火できないのではなく、mutationに発火させた場合は純粋なFluxに従う設計を手に入れられます。

以下がFacebook公式のサンプルコードです。

https://github.com/facebook/flux/blob/2ab02c6e61618da270509dca7a8a40c852570f1b/examples/flux-async/src/stores/TodoListStore.js

mutationsでTodoDataManagerを発火させています。

また、TodoDataManagerのがactionを発火させています。

https://github.com/facebook/flux/blob/2ab02c6e61618da270509dca7a8a40c852570f1b/examples/flux-async/src/data_managers/TodoDataManager.js

このTodoDataManagerはサービスと呼ばれるものです。

以下がその大まかなデータフローです(手書きで雑ですが)。



action creatorをコンポーネントが作ってstoreに自分でcommitしてるのでaction creator => storeのデータフローが正しく表現できてるかはめちゃくちゃ怪しいですが(編集リクエスト待ってます!)、重要なのはmutationからservice、action creatorです。これで完全なイベント駆動のFluxになりました!

ちなみにいちいちserviceをmutation内に置くのかよ、何も変化しないよ!って思った場合はとりあえずisLoadingとかをtrueにすればいいんじゃないでしょうか。

通信状態なども含め全ての状態をアプリケーションは表現し伝えた方が、よりユーザーフレンドリーになりますしね。


まとめ

如何でしょうか。

以上の方法で独自でVuexの独自型定義を一切書かず、尚且Vuexへの深い知識も必要なく、見通しよく型安全に開発することができます。

Vuexはとても便利な反面制限が少なく、それの影響でリファクタリングや機能開発、変更に弱いです。

Typescriptパワーを駆使してVuexの型を拡張してガッチガチに固める凄腕もいますが、それはあまり現実的ではなく、Vuexのバージョンアップに追従できない原因にもなってきます。もしガッチガチに固められるだけの手腕があれば最早Vuexにプルリクエストを投げてコミットして一緒にメンテした方が現実的でしょう。

ですので何かしらの手段で型安全にしてバグを減らしたい所です。

今回はその手段の内の1つを提案しました。


参考資料

その他沢山