この記事は一休.comアドベントカレンダー2018の3日目の記事です。
宇都宮です。宿泊事業本部でWebフロントエンドの開発をしています。
一休.comにVue.jsを導入して、約1年が経ちました。スマートフォン版の予約入力画面から始まり、PCとスマートフォン版のホテルページほか、さまざまなUIコンポーネントがVue.jsで実装されるようになってきています。
また、予約入力画面のような複雑な状態管理を伴う画面の実装のため、Vuexを導入しています。
ここ1年ほどVue.js + Vuexというスタックで開発を行ってきて、アプリケーションの設計について色々と思うところがあったので、今回は現状でどういう構成が最適と考えているか、紹介します。
Vue.jsアプリケーションのState
Vueコンポーネントには、親から受け取るpropsと、自分自身で保持するstate(data)という2つの概念があります。また、子コンポーネントから親コンポーネントに何かしらの値を戻したい場合、eventの仕組みを利用することができます。
propsとdataについては説明を省きますが、eventについてはサンプルコードを載せておきます。
KeywordSearch.vueというコンポーネントがあって、このコンポーネントは検索キーワードの入力とバリデーションを受け持つとします。一方、検索処理の実行はAPI経由なのか画面遷移なのかが画面によって変わるため、親コンポーネントに委譲します。
このような場合、以下のようにeventの仕組みを利用することで、子から親へ値を受け渡すことができます。
// KeywordSearch.vue <template> <div> <label> 検索キーワード <input type="text" name="query" v-model="keyword" /> </label> <button type="button" @click="search">検索</button> </div> </template> <script> export default { name: "keyword-search", data() { return { keyword: "", }; }, methods: { search() { // search イベントを発火し、keywordを引数として渡す this.$emit("search", this.keyword); } } }; </script> // KeywordSearch.vueの親コンポーネント <template> <div><keyword-search @search="handleSearch" /></div> </template> <script> import KeywordSearch from "./KeywordSearch.vue"; export default { name: "App", components: { KeywordSearch }, methods: { // KeywordSearchのsearchイベントのハンドラ handleSearch(keyword) { // 検索APIに問い合わせたり、画面遷移したり } } }; </script>
まとめると、Vueコンポーネントにおいて、データの流れは以下のようになっています。
- 自分自身で保持: data
- 親 => 子: props
- 子 => 親: event
このように、コンポーネント間のデータの流れがpropsとeventによって行われることをとらえて、「Props Down, Event Up」と呼ぶことがあります(もともとはReact.jsのコミュニティ発祥のフレーズだと思います)。
この辺の話はVue.jsのドキュメントでも詳しく説明されています。
グローバルな状態管理の必要性
ここまで説明してきたのは親子関係のあるコンポーネントの話です。しかし、実際のアプリケーションでは、親子関係のないコンポーネント間でデータを共有したい場合があります。これについても、Vue.jsおよびVuexのドキュメントに、Vuexを使うべき場面についての解説が載っています。
一休.comの画面の例としては、「今すぐポイント割引後料金を表示」の切り替えボタンは、直接の親子関係のないコンポーネントがオン・オフの状態を共有するため、Vuexを使っています。
Vuexのバッドプラクティス
Vuexは便利なのですが、何でもかんでもVuexを使うのは良いやり方とはいえません。Vuexを使っていると、以下のようなコンポーネントを書いてしまうことがあります。
<template> <div>{{ someState }}</div> </template> <script> import { mapState } from "vuex"; export default { computed: { ...mapState(["someState"]) } }; </script>
このコンポーネントの作りがイマイチなのは、単に値を受け取って表示するだけのコンポーネントなのに、propsではなくStoreに依存している点です。この設計が良くないことを説明するために、モジュール結合度という概念を紹介します。
モジュール結合度
モジュール結合度とは、あるモジュールと別のモジュールの結合度を表す度合いです。低いほど疎結合、高いほど密結合となります。見た目を制御するコンポーネントの場合、様々な場所での使用が想定されるため、結合度は低い方が望ましいです。
モジュール結合度 | 説明 | |
---|---|---|
1 | データ結合 | 引数で単純なデータを渡すパターン |
2 | スタンプ結合 | 引数で構造体などのオブジェクトを渡すパターン |
3 | 制御結合 | 引数の種類によって、メソッドの内の処理が変わるパターン |
4 | 外部結合 | 単一のグローバルデータを参照しているパターン |
5 | 共通結合 | 複数のグローバルデータを参照しているパターン |
6 | 内容結合 | 他のオブジェクトの内部を参照しているパターン |
Vueコンポーネントでいうと、propsは「データ結合」ないし「スタンプ結合」なので、結合度は1〜2です。これに対して、storeは概念としてはグローバルデータの参照に当たり、複数のデータを参照することが多いため、「共通結合」の5、gettersやactionsに依存している場合は「内容結合」の6になります。
つまり、storeを参照するコンポーネントはstoreと密結合し、storeなしでは利用できなくなってしまうのです。
このような密結合が常に問題になるわけではありませんが、コンポーネントツリーの末端に近いようなコンポーネントがstoreを参照しているのは、コンポーネントの設計が歪になっていることのシグナルかもしれません。
Vue + Vuexアプリケーションの状態管理の指針
Vuexがその設計の参考としているFluxアーキテクチャでは、状態が一カ所に集約されていて、Single Source of Truthになると安心できる、という考え方があります。この考え方を適用すると、データは基本的にVuexのstoreから引いてくるのが正しいように思えます。
こうしたSingle Source of Truthの考え方と、疎結合なコンポーネント設計は、対立する概念ではありません。ここで参考になるのは、Presentational ComponentとContainer Componentという考え方です。
ざっくりいうと、Presentational Componentは、propsを受け取って、表示をするだけのコンポーネント、Container ComponentはPresentational Componentを管理し、値を受け渡すコンポーネントです。
- Container Componentはstoreを参照し、必要に応じてPresentational Componentにデータを渡す
- Presentational Componentはstoreは参照しない
このようにコンポーネントを分類することで、storeと密結合する部分をContainer Componentに限定し、Presentational Componentは疎結合に保つことができます。
また、もう1つの設計指針として、「Vuexはグローバルな状態のストアである」という点を強調しておきたいと思います。modulesに区切ることで細かいstoreを定義することは可能ですが、実装上どこからでも参照できるVuex storeは、概念的にはグローバル変数と同じです。コンポーネントローカルな状態(要素の表示・非表示等)は、Vuex Storeで管理する状態ではありません。一方、ローディングの表示等、画面全体に影響するような状態はVuexで管理すると便利です。
まとめ
以下のような指針で設計を行うことで、Vueコンポーネント設計の基本を守りつつ、Vuexを適材適所で活用できます。
- 親子関係のあるコンポーネント間のデータの受け渡しは「Event Up, Props down」で
- 親子関係のないコンポーネント間のデータの共有はVuexで
- コンポーネントの固有のデータはコンポーネントのローカルステート(data)で
- グローバルなデータはVuexで
参考文献
jp.vuejs.org jp.vuejs.org vuex.vuejs.org qiita.com medium.com