
コンポーネント指向のUI開発時に起きがちな密結合・複雑化問題をStoreパターンで解決する
Posted on
こんにちは。プレイドの藤川(@atsushiss15)です。
プレイドには学生時代よりインターンとして参加しており、今年の4月に新卒社員として正式に入社しました。現在に至るまで弊社の提供するKARTEの機能開発を担当しています。
今回はVue.jsを利用したコンポーネント指向のUI開発において、私たちが実際に遭遇した問題とその解決策として導入したStoreパターンについてご紹介したいと思います。
Vue.jsというフレームワークが題材にはなりますが、Storeパターンの考え方自体は他のフレームワークを使った開発にも転用できると思うので、ぜひ読んでみていただければと思います。
目次
- Vue.jsとは?
- アプリケーションの大規模化に伴って生じた問題
- 解決策としてのStoreパターン
- サンプルコード他
- Storeパターンに移行した感想
- 最後に
Vue.jsとは?
Vue.jsを一言で表現するならば、「シンプルさが売りのMVVMフレームワーク」です。
他の有名なMVVMフレームワークにはAngularJSやReactなどが挙げられますが、それらに比べてVue.jsはそのシンプルさ故に学習コストが低い上、あらゆる規模のアプリケーションで利用することができる設計になっています。
Vue.jsを知らない方でも、簡単なToDoアプリの実装例を見てみれば大体の使い方が理解できるのではないでしょうか。
上記のコードは、Vue.jsの公式サイトに掲載されているサンプルのコードを便宜上CoffeeScriptで書き直したものです。
簡単に解説をしてみます。
id属性を用いてVueインスタンスと仮想DOMの紐付けを行い、v-model
でデータの双方向バインディング、{{ }}
構文でテキストのレンダリング、v-for
でリストのレンダリング、v-on
で要素にイベントリスナのアタッチをそれぞれ行っています。
Vue.jsはこの辺りさえ抑えておけば、とりあえずコードを書き始めることができるので便利です。
アプリケーションの大規模化に伴って生じた問題
Vue.jsの最も大きな特徴の一つにコンポーネントシステムがあります。
この仕組みを利用して、アプリケーションのUIをコンポーネントレベルに分割し、組み合わせるように実装することで、コードの再利用性が向上し、ロジックも各コンポーネントの中にカプセル化することができます。
コンポーネントの状態は各コンポーネントがそれぞれ管理し、状態やイベントの共有はpropsや$broadcast/$dispatchといった仕組みを利用して行います。
下図はコンポーネント間でのデータやイベントの伝達をイメージしています。propsを用いてデータを子コンポーネントへ明示的に受け渡したり、$broadcastや$dispatchを使って他コンポーネントへイベントの通知を送ることができます。
しかし、これらの仕組みを利用すると、アプリケーションの大規模化に伴い状態管理が複雑になってきてしまいます。
状態が多くのコンポーネントに分散し、更にそれらが相互作用するようになるとpropsや$broadcastを使ったコンポーネント間でのデータやイベント伝達の仕組みが多用され、コンポーネント同士が密結合・複雑化し始めるのです。
実際にKARTEの開発においても、これらが原因でコード変更の影響範囲が分かりづらく改修が困難になり、コードの重複やサーバーサイドとの無駄な通信などが散見されるようになっていました。
解決策としてのStoreパターン
そこで私たちはStoreパターンによってアプリケーションの状態(State)・処理(Actions)・イベントの共有を行うことで、密結合しているコンポーネントの分離、コードの重複や無駄な通信の削減を目指しました。
私たちがStoreパターンと呼んでいるもの
Storeパターンは、Storeというグローバルな共有オブジェクトに複数のコンポーネントから使用される共通の状態・処理・イベントを管理させることで、コンポーネント同士を分離する考え方です。
下図のように、ユーザーから何らかの入力を受けたコンポーネントはStoreの共有処理(Shared Actions)を直接Callすることで共有状態(Shared State)を更新します。各コンポーネントはこの共有状態を監視することでそれぞれの表示を変化させます。
また、Storeには共有のイベントも管理させます。何らかのイベントをトリガにしてコンポーネントごとの処理を行いたいケースなどは、これを利用して各コンポーネントに通知を送信します。
「Fluxとは何が違うの?」
というご指摘が挙がってきそうなので、Fluxアーキテクチャとの差分についても少し触れておきたいと思います。
Vue.js公式ドキュメントでも言及されていますが、store patternは共有の状態・処理をグローバルなオブジェクトに管理させることでコンポーネント同士が密結合・複雑化してしまうのを防止することを目的としています。
そういった意味で、Fluxはstore patternの考え方をベースに、Event Dispatcherによる一方向(one-way)の状態更新フロー以外は許容しないという制約を加えたアーキテクチャであることが分かります。
対して、私たちがStoreパターンと呼んでいるものは、Fluxの思想自体は汲み取りつつ柔軟性とシンプルさを担保したものであるといえます。
具体的には、状態更新に関する制約を設けないことで柔軟性を確保し、Event Dispatcher等は介さず直接関数をCallして状態更新を行うことでシンプルさを保つようにしています。そのためTPOに応じてコンポーネントが直接Storeの状態を書き換えることも許容します。
このように私たちがFluxのように、制約によってキレイに整理された(しかし少し大げさな)アーキテクチャよりも、柔軟性やシンプルさといったものを求めたのは**「ルールをできるだけ作らずに属人的に判断する」**という企業文化を大切にしているからです。
また別の観点からみれば、KARTEの管理画面がサブアプリごとにページを分けるような構造になっており、そもそもフロントエンドが大規模化しすぎないように設計されているということも、このような方針を採る際の後押しとなりました。
サンプルコード他
何はともあれコードで説明するのが一番です。
せっかくなので、冒頭でご紹介したToDoアプリのコードをStoreパターン的に実装してみます。
この例ではStoreによって状態(state)と処理(addToDo, removeTodo)の共有を行っています。
一見コードが増えたようにも見えますが、複数コンポーネントで状態や処理の共有が行われるようになるとStoreパターンによる恩恵を享受できるはずです。
イベントの通知について
イベントの通知については幾つかの手段が考えられますが、次の2つをケースにより使い分けるのが良いのではないでしょうか。
1. StoreのEventEmitterを利用してEventの通知を行う
# Store側
{EventEmitter} = require 'events'
class XxxYyyStore extends EventEmitter
# 省略...
# コンポーネント側
# ...省略
methods: ->
hogeAction: ->
# 別vueや別componentに変更等を通知
xxxYyyStore.emit 'conditionChanged', {}
compiled: ->
# 別vueや別componentからStore経由でイベントハンドリング
xxxYyyStore.on 'conditionChanged', () ->
# handling
2. watchやcomputedを利用して間接的にイベントハンドリングを行う
``` # Store側 # ...省略 watch: 'xxxYyyStoreState.todos': -> # 共有状態に変更があった場合の処理 computed: isEmpty: -> @xxxYyyStoreState.todos.length is 0 ``` 複雑な変更と明示的なタイミングの制御が必要なものは **1.** の方法で個別にハンドリングし、そうでない場合は **2.** の方法でハンドリングするといった要領で使い分けてみると良さそうです。その他に留意すべき点
また、Vue.jsでStoreパターンを構築する際には以下の点にも留意するようにしてください。
1. XxxYyyStore.stateに余分なものを入れない
XxxYyy.stateは、Storeの外に公開されるオブジェクトです。Vueのデータ更新監視の対象になるので、あまり大きなものを入れないほうが良いです。2. XxxYyyStore.stateを置き換えない
一つのStoreインスタンスを複数箇所で使い回す形になります。 constructorで作ったstateを、複数のVueコンポーネントで監視するようになるので、state自体を置き換えるコードがあると、値の追跡が正常に行われなくなります。 ``` # まずい例 constructor: -> @state= value: '' updateXxx: (newState) -> @state = newState # <- @stateを置き換えているので、更新が拾えなくなる ```3. 基本的に$broadcast, $dispatch, $parent, $childは使用しない
これらの仕組みはほとんどStoreパターンで補うことができるので、コンポーネントの密結合・複雑化を防ぐためにも使うべきではありません。props(データを子コンポーネントに明示的に伝達する仕組み)に関しても使用を必要最小限に抑えてください。さて、簡単なサンプルコードを使って説明してきましたが、なんとなく感覚は掴んでいただけたかと思います。実際にはもう少し考えなければならない事があるのですが、基本的には上記のシンプルなパターンを拡張していくイメージになります。
Storeパターンに移行した感想
良かった点
- サーバーとの通信処理や複雑なロジックがStore側に集約されたことで、コンポーネント側はVueが本来担うべき表示系の処理に集中できるようになった。
- コンポーネント間の密結合が解消され、コンポーネントの再利用性が高まった。
- 全体的に見通しが良くなるのでロジックの改修等が楽に行えるようになった。
考えるべき点
- 重い初期化処理に対しては適宜遅延させるなどの工夫が必要になる
- Storeでの処理後にコンポーネント側で後処理を行う際は、コールバックで行うのか、状態の変更を監視して行うのか、イベントをハンドリングして行うのか、などを適宜使い分ける必要がある
- 状態の更新フローに関する制約をどこまで設けるかは再考する必要がある
所感としては、Storeパターンへの移行の目的としていた点はおおよそ満たせたのではないかと感じています。もちろんまだまだ試行錯誤が必要な点はあると思うので、より良いアーキテクチャを目指して日々改善していきたいと考えています。
最後に
ウェブ接客プラットフォーム「KARTE」を運営するプレイドでは、 KARTEを支える技術に興味を持つエンジニア(インターンも!)を募集しています。
詳しくは弊社採用ページまたはWantedlyをご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!