一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

VueコンポーネントのState管理を考える

この記事は一休.comアドベントカレンダー2018の3日目の記事です。

qiita.com


宇都宮です。宿泊事業本部で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のドキュメントでも詳しく説明されています。

jp.vuejs.org

グローバルな状態管理の必要性

ここまで説明してきたのは親子関係のあるコンポーネントの話です。しかし、実際のアプリケーションでは、親子関係のないコンポーネント間でデータを共有したい場合があります。これについても、Vue.jsおよびVuexのドキュメントに、Vuexを使うべき場面についての解説が載っています。

jp.vuejs.org

vuex.vuejs.org

一休.comの画面の例としては、「今すぐポイント割引後料金を表示」の切り替えボタンは、直接の親子関係のないコンポーネントがオン・オフの状態を共有するため、Vuexを使っています。

f:id:ryo-utsunomiya:20181202164300p:plain
「今すぐポイント割引後料金を表示」の切り替えボタン

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 内容結合 他のオブジェクトの内部を参照しているパターン

qiita.com

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という考え方です。

medium.com

ざっくりいうと、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