はじめに
こんにちは、モチベーションクラウドの開発にフリーのエンジニアとして参画している@HayatoKamonoです。
この記事は、「モチベーションクラウド Advent Calendar 2018」9日目の記事となります。
概要
モチベーションクラウドの開発チームでは2018年10月から改善期間と称して、開発に関するガイドラインやルール作りをはじめとする、様々な改善活動に取り組んでいます。
私が所属しているフロントエンド開発チームでは、すでに別記事でご紹介している「WebAPI設計ガイドライン」であったり、「コンポーネント設計・実装ガイドライン」を作成しました。
他にも作成したガイドラインはありますが、今回はモチベーションクラウドのフロントエンド開発で「状態管理パターン + ライブラリ」として導入しているVuexの利用に関するチーム内のガイドラインをサンプルコードや説明を交えながらご紹介出来ればと思います。
目次
- Storeの構造について
- 原則としてVuexで全ての状態を管理する
- Vuexのヘルパー関数を利用する
- ComponentからStoreのcommitを実行しない
- module間の依存関係を作らない
- moduleのactionをグローバルに登録しない
Storeの構造について
全モジュールにおいてnamespaceを切る
const modules = {
namespaced: true,
modules: {
moduleA: {
namespaced: true,
// 略
},
moduleB: {
namespaced: true,
// 略
}
}
}
元々、モチベーションクラウドのフロントエンドアプリケーションではVuexのnamespaceは切っておりませんでした。
そのため、コンポーネント側からVuexのStore側にdispatchするイベント名の重複、衝突を防ぐために、1つのファイルでイベント名を定数管理し、それらの定数化されたイベント名をコンポーネント側やStoreのmodule側で読み込んで利用する方法を採用しておりました。
しかし、開発が進むに連れて、今後、アプリケーションの規模が更に大きくなった場合を考えると、名前空間をこのような泥臭いやり方で管理し続けるのは「辛い」、「不安」と言った声がチーム内で挙がってくるようになりました。
そこで、今回、Vuexのガイドラインを作成するにあたって、「namespaceを全モジュールで切るべきか、部分的に切るべきか?」をチーム内で議論し、意見が割れるところもありましたが、最終的には全モジュールでnamespaceを切るという方針に固まりました。
それに伴い、今後はイベント名を定数で管理し、毎回、利用する箇所で定数を読み込むといったことは辞め、イベント名を直接文字列で扱って行くことにもなりました。
実際、毎回、新たにdispatchするイベント名が登場する度に、定数管理するファイルにそのイベント用の定数を追加し、そして、その定数を利用する箇所で読み込まなければいけないという作業は冗長でストレスを伴うものであったため、この苦行から解放されるのはとても良いことだと思います。
ドメインとUIで状態を分けて管理する
Store内のmoduleをまず、ドメインに関する状態を管理するmoduleと、UIに関する状態を管理するmoduleで大きく2つに分けることにしました。
そして、それら2つのmodule下に更に付随するmoduleを展開していくといったmoduleの管理方法です。
サンプルコード
const modules = {
namespaced: true,
modules: {
domain: {
namespaced: true,
modules: {
users: {
// 略
},
organizations: {
// 略
},
}
},
ui: {
namespaced: true,
modules: {
common: {
// 略
},
operations: {
// 略
}
}
}
}
}
APIを通してサーバーサイドから取得するデータは必要に応じて、加工や正規化をして、domain
以下のモジュールでそれぞれ管理します。
また、breadcrumbやmodalの状態のようなページ共通で持っておくことが好ましい状態は、ui
以下のcommon
モジュール内で管理し、、その他のページ固有のUIに関する状態はui
以下にそのページ用のモジュールを置いて、そちらで管理します。
原則としてVuexで全ての状態を管理する
これまでフロントエンド開発チームにおいて、Vuex側で管理する状態と管理しない状態についての共通の方針が存在していなかったため、各開発者によって状態を管理する場所がバラバラでした。
そこで、今回、Vuex利用に関するガイドラインを作成するにあたり、これについても、チーム内で議論しました。
普通に考えると、「ドメインに関する状態はVuexのStoreで持ち、また、ページ遷移後も保持しておきたいようなUIに関する状態をVuexのStoreで持つ」、そして、「それ以外のUIに関する状態をコンポーネント側で持つ」、というのが自然かなと思いますが、最終的にチーム内で合意に至ったのは「パフォーマンスが懸念されるなどの特別な理由がない限り、原則として全ての状態をVuexのStoreで管理する」という方針です。
この決定に至った理由としては以下が挙げられます。
- Time Travel Debuggingが可能になるため、デバッグがしやすくなる
- テストがしやすくなる
- 複数人による開発の中で状態管理に一貫性が生まれる
個人的には、このようにVuexやReduxなどの状態管理ライブラリに全ての状態を寄せるという開発は初めてなので、やってみないと分からないところもありますが、実際に運用していくうちに「パフォーマンスの懸念」以外の理由で他にも、コンポーネント側で状態を持たせた方が良いケースが出てくるかもしれません。
その際は随時、チーム内で議論して、柔軟にガイドラインに調整をかけて行ければ良いと思っています。
dispatchするaction payloadの型
Storeのdispatchメソッドの第2引数に渡す値の型に関しても、今回のガイドライン作成を機に以下のように方針を固めました。
interface ActionPayload = {
payload?: any,
meta?: object,
error?: boolean
}
元々、私自身がReact、Reduxを用いた開発の際には、Flux Standard Actionを取り入れているので、そのまま、Flux Standard Actionを参考にガイドラインに組み込みました。
サンプルコード
this.$store.dispatch('fetchUsers')
this.$store.dispatch('postUser', {
payload: { id: 1, name: 'foo' },
meta: { delay: 3000 }
})
this.$store.dispatch(validationFailed, {
payload: { message: 'hello' },
error: true
})
Vuexのヘルパー関数を利用する
Storeのmoduleのstateやactionを参照する方法は複数ありますが、それらを参照する際にはVuexのヘルパー関数を必ず利用するという制限を設けることにしました。
また、Vuexの各ヘルパー関数にも複数の使い方が存在しますが、それらの使い方に関しても、ガイドラインで決めた方法のみを利用するといった制限を設けることにしました。
狙いとしては、コードの一貫性を高めること、また、それによって、Vue未経験のフロントエンドエンジニアや、普段はサーバーサイドを担当している既存のメンバーがフロントエンド開発に加わるようになった時の学習コストを下げるところにあります。
createNamespacedHelpersの使用
Container ComponentでVuexのnamespaceが切られたmoduleを参照する場合は、必ず、ヘルパー関数のcreateNamespaceHelpersを使用するものとします。
import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
mapGetters: mapGettersOfUsers
} = createNamespacedHelpers('domain/users');
createNamespaceHelpersは引数に与えたmoduleのネームスペースがバインドされたVuexのヘルパー関数を返します。
命名規則
import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
mapGetters: mapGettersOfUsers
} = createNamespacedHelpers('domain/users');
const {
mapState: mapStateOfOrganizations,
mapActions: mapActionsOfOrganizations
mapGetters: mapGettersOfOrganizations
} = createNamespacedHelpers('domain/organizations');
Container Componentの中で複数のVuexのmoduleを参照することは良くあることです。
そのため、createNamespacedHelpersが返すmapState、mapActions、mapGettersには別名をつけて、名前の衝突を防ぎます。
その際の命名規則として、mapStateOfUsersのように、「ヘルバー関数名 + モジュール名」をlowerCamelCaseで名前を付けるものとします。
mapState関数の使用
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapState: mapStateOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
...mapStateOfUsers(['users, isLoading'])
},
render(createElement) {
return createElement(WrappedComponent);
}
};
};
export default connect(UsersPage);
export { UsersPage };
createNamespacedHelpersで返すnamespaceがバインドされたmapState関数を使用します。
参考までに、上記のコードのcomputed部分は以下と同じです。
computed: {
users: this.$store.state.domain.users.users,
isLoading: this.$store.state.domain.users.isLoading
},
mapActions関数の使用
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapActions: mapActionsOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
created () {
this.fetchUsers()
},
methods: {
...mapActionsOfUsers(['fetchUsers', 'postUser']),
handleUserSave(payload) {
this.postUser(payload)
}
},
render(createElement) {
return createElement(WrappedComponent, {
on: {
save: handleUserSave
}
});
}
};
};
export default connect(UsersPage);
export { UsersPage };
createNamespacedHelpersで返すnamespaceがバインドされたmapActions関数を使用します。
参考までに、上記のコードのcreatedとmethods部分は以下と同じです。
created () {
this.$store.dispatch('domain/users/fetchUsers')
},
methods: {
handleUserSave(payload) {
this.$store.dispatch('domain/users/postUsers', payload)
}
}
mapGetters関数の使用
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapGetters: mapGettersOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
computed: {
...mapGettersOfUsers(['highlyEngagedUsers', 'dormantUsers'])
},
render(createElement) {
return createElement(WrappedComponent);
}
};
};
export default connect(UsersPage);
export { UsersPage };
createNamespacedHelpersで返すnamespaceがバインドされたmapGetters関数を使用します。
参考までに、上記のコードのcomputed部分は以下と同じです。
computed: {
highlyEngagedUsers: this.$store.getters['domain/users/highlyEngagedUsers'],
dormantUsers: this.$store.getters['domain/users/dormantUsers']
}
mapMutations関数の使用禁止
Vuexのヘルパー関数にはmapMutations
というものもありますが、こちらのヘルパー関数には後述する「ComponentからStoreのcommitを実行しない」というチーム内の方針により、使用禁止としました。
Vuexへの参照はContainer Componentで行なう
別記事でご紹介した「コンポーネント設計・実装ガイドライン」の中でも触れていますが、
Vuexへの参照はPresentational Componentでは行わず、Container Componentで行ないます。
Bad
Presentational Component内でVuexのStoreを参照している例
<template>
<div class='users-page'>
<ul>
<li v-for='user in users' :key='user.id'>{{ user.name }}</li>
</ul>
</div>
</template>
<script>
import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
} = createNamespacedHelpers('domain/users');
export default {
name: 'UsersPage',
created () {
this.fetchUsers()
},
computed: {
...mapStateOfUsers(['users'])
},
methods: {
...mapActionsOfUsers(['fetchUsers'])
}
};
</script>
Good
Container Component内でVuexのStoreを参照している例
import { createNamespacedHelpers } from 'vuex';
import UsersPage from './UsersPage.vue';
const {
mapState: mapStateOfUsers,
mapActions: mapActionsOfUsers
} = createNamespacedHelpers('domain/users');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
created () {
this.fetchUsers()
},
computed: {
...mapStateOfUsers(['users'])
},
methods: {
...mapActionsOfUsers(['fetchUsers'])
},
render(createElement) {
return createElement(WrappedComponent, {
props: {
users: this.users
}
});
}
};
};
export default connect(UsersPage);
export { UsersPage };
<template>
<div class='users-page'>
<ul>
<li v-for='user in users' :key='user.id'>{{ user.name }}</li>
</ul>
</div>
</template>
<script>
export default {
name: 'UsersPage',
props: {
users: {
type: Array,
default: []
}
}
};
</script>
ComponentからStoreのcommitを実行しない
ComponentからStoreのcommitメソッドを実行しないというルールは、モチベーションクラウドの初期のフロンドエント開発メンバーによって以前決められた方針として既に存在していました。
このルールはどういったものかと言うと、ComponentからStoreのstateに変更を加える際に、例え、同期的な変更処理であったとしても、Storeのcommitメソッドは使わず、Storeのdispatchメソッドを必ず通して、Storeのstateに変更を加えるというものです。
正直、同期的に走る変更処理を行なう際に、わざわざ、dispatchメソッドを経由してから、commitメソッドを実行するというのは面倒に感じるところはあります。
しかし、以下の理由により、引き続き、今回作成したガイドラインに組み入れた方が良いということになりました。
理由
- 上記の図にあるVuexの単方向データフローに合わせるため
- Component側で通知したイベントをStore側が同期的に処理するのか、非同期的に処理するのかを知らなくても良くなるため
- Component側で常にstoreのdispatchメソッドのみを使うことで、コードに一貫性が生まれ可読性が向上するため
- 同期的処理、非同期的処理に関わらず、Component側でdispatchメソッドを通せば、storeのactionから複数のcommitメソッドをまとめて実行することも出来るため
Bad
import { createNamespacedHelpers } from 'vuex';
import UserPage from './UserPage.vue';
const {
mapMutations: mapMutationsOfUserPage,
} = createNamespacedHelpers('ui/userPage');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
methods: {
...mapMutationsOfUserPage(['clearForm']),
handleFormClear() {
this.clearForm();
}
},
render(createElement) {
return createElement(WrappedComponent, {
on: {
reset: this.handleFormClear
}
});
}
};
};
export default connect(UserPage);
export { UserPage };
Good
import { createNamespacedHelpers } from 'vuex';
import UserPage from './UserPage.vue';
const {
mapActions: mapActionsOfUserPage,
} = createNamespacedHelpers('ui/userPage');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
methods: {
...mapActionsOfUserPage(['clearForm']),
handleFormClear() {
this.clearForm();
}
},
render(createElement) {
return createElement(WrappedComponent, {
on: {
reset: this.handleFormClear
}
});
}
};
};
export default connect(UserPage);
export { UserPage };
module間の依存関係を作らない
あるmoduleから別のmoduleに対して、Storeのcommitメソッドを呼んだりしていると、コードを追いづらくなったりしますし、テスト容易性にも影響をしてきます。
そのため、module間では依存関係を作らないという制約を設けることにしました。
moduleの中でrootState、rootGettersを参照しない
Bad
modules: {
moduleA: {
namespaced: true,
getters: {
someGetterA (state, getters, rootState, rootGetters) {
return rootState.moduleB.foo
},
someGetterB (state, getters, rootState, rootGetters) {
rootGetters['moduleB/someGetterC']
}
},
actions: {
someAction ({ dispatch, commit, getters, rootGetters }) {
dispatch('someOtherAction', rootGetters.moduleB.someGetter)
},
someOtherAction (ctx, payload) { ... }
}
},
moduleB: {
namespaced: true,
state: { foo: null },
getters: {
someGetterC (state) {
return state.foo
}
}
}
}
moduleから他のmoduleに対して、dispatch、commitを実行しない
Bad
modules: {
moduleA: {
namespaced: true,
actions: {
someAction ({ dispatch, commit }) {
dispatch('moduleB/someOtherAction', null, { root: true }) // -> 'moduleB/someOtherAction'
commit('moduleB/someMutation', null, { root: true }) // -> 'moduleB/doSomethingElse'
}
}
},
moduleB: {
namespaced: true,
actions: {
someOtherAction (context, payload) { ... }
},
mutations: {
someMutation (state, payload) {...}
}
}
}
モジュール内から他のモジュールに対して、storeのdispatchやcommitを実行するのはNG。
Good
modules: {
moduleA: {
namespaced: true,
actions: {
someAction ({ dispatch, commit }) {
dispatch('someOtherAction') // -> 'moduleA/someOtherAction'
commit('someMutation') // -> 'moduleA/doSomethingElse'
},
someOtherAction({ dispatch, commit }) {...}
},
mutations: {
someMutation (state, payload) {...}
}
}
}
モジュール内から同じモジュールに対して、storeのdispatchやcommitを実行するのはOK。
モジュール内から他のモジュールにdispatchしたい場合
モジュール内から他のモジュールにdispatchしたい場合は、以下のサンプルコードのように、storeのmodule側でpromiseを返し、Container Component側でpromiseをchainします。
Store側
modules: {
moduleA: {
namespaced: true,
actions: {
someAction ({ dispatch, commit }) {
return api.fetchSomeData()
.then(response => {
return Promise.resolve(response)
})
.catch(error => {
return Promise.reject(error)
})
.finally(() => {
commit('someMutation')
})
},
},
mutations: {
someMutation (state, payload) { ... }
}
},
moduleB: {
namespaced: true,
actions: {
someOtherAction (context, payload) { ... }
}
}
}
Container Component側
import { createNamespacedHelpers } from 'vuex';
import SamplePage from './SamplePage.vue';
const {
mapActions: mapActionsOfModuleA
} = createNamespacedHelpers('moduleA');
const {
mapActions: mapActionsOfModuleB
} = createNamespacedHelpers('moduleB');
const connect = WrappedComponent => {
return {
name: `${WrappedComponent.name}Container`,
created () {
this.someAction()
.then((response) => this.someOtherAction())
.catch(console.error)
},
methods: {
...mapActionsOfModuleA(['someAction']),
...mapActionsOfModuleB(['someOtherAction']),
},
render(createElement) {
return createElement(WrappedComponent);
}
};
};
export default connect(UsersPage);
export { UsersPage };
moduleのactionをグローバルに登録しない
namespaceを切ったmoduleであったとしても、actionを以下のように、グローバルに登録することは可能ですが、actionをグローバルに登録することも禁止としました。
BAD
modules: {
moduleA: {
namespaced: true,
actions: {
someAction: {
root: true,
handler (namespacedContext, payload) { ... }
}
}
}
}
最後に
今回ご紹介した「Vuexの利用に関するガイドライン」には、この記事の中では触れていないものもありますが、主要な部分に関しては共有出来たと思います。
今後、こちらのガイドラインで決めたmodule構造に、既存のコードのmodule構造を変えて行く必要があります。
そのリファクタリングを安全に行えるように現在、フロントエンド開発チームでは、別記事「Cypressを使ったインテグレーションテストの導入」でも共有されている通り、Cypressによるインテグレーションテストを充実させているところです。
また、いろんなルールを決めたとしても、そらら全てをコードレビュー時にチェックするのは大変なので、独自のルールは独自のLintルールを作成して、コードレビューのコストを軽減することを検討中です。
関連記事
こちらの記事はモチベーションクラウド Advent Calendar 2018に投稿した記事です。
他にも、以下の記事をモチベーションクラウド Advent Calendar 2018に投稿しています。