Vue+VuexでMVVMなWebApplicationを設計するときに考えたいこと
まえがき
ここ最近、Vueを使って実装されたWebアプリが随分と増えてきたように感じます。自分も何度となく実装してきました。すごく小さなデモを作るときにも使えるし、中規模以上のWebアプリを作るときにも使えるし、扱いやすいライブラリでとても好きです。
ある程度の規模になってくると「複数の画面でデータを共有したい」「こっちのComponentの状態をあっちのComponentに伝えたい」といったような問題にぶち当たり、アーキテクチャを導入することでそれらを解決するというのもお馴染みな感じです。特にVueでは双方向データバインディングの特性上、MVVMアーキテクチャが使われることが多いと思います。
今回は、VueでMVVMを実現する際に起き得る設計上の問題について、現時点での私の解決方針をまとめてみました😌
Vue+MVVMとはどんなものか
一般的なMVVMを理解する
元来WPFやSilverlightで使われていたMVVMの登場人物の役割・依存関係についてさらっと理解しておくと、Vueを使ってMVVMを作るときにも役立ちそうです。
View
- UIテンプレートとUIロジック(コードビハインド)を持つ
- ViewModelに依存する
- ある操作を実行するときはViewModelから公開されるCommandを呼び出す
ViewModel
- PresentationロジックとStateを持つ
- Modelに依存する
- Viewから受け取った操作を必要に応じてModelへDispatchする
- Modelを監視する
- Viewと双方向データバインディングする
- Viewに揮発性のあるデータを送るときはMessengerを発行する
Model
- BusinessロジックとDomainを持つ
- ViewやViewModelに依存しない
- 自身を更新するためのfunctionを公開する
- 自身が更新された場合にはイベントを発火する
Vueで実現するMVVMはここが違う
VueComponentはView+ViewModelを表現してくれるので、ViewとViewModelのつなぎ込みを意識することなく実装できます。その代わり、どのようにVueComponentをつくるかを考えないと親子関係が思うようにいかなかったり、データの受け渡しに苦労したりする羽目になります(過去にやらかしました😰)。
反面、Modelについての仕組みやルールは提供していません。自前で用意するか、Vuexなどの状態管理ライブラリを利用する必要があります。
ここで本来のMVVMを正しく理解していないとオレオレModelを作ってしまったり(過去にやらかしました👿)、fluxをVueに組み込めると妙な理解をして下手にVuexを導入しデータ管理が闇に包まれたりします(過去にやらかしました😇🔨)
そんなわけで、VueでMVVMを設計するときは以下のような観点に注意すると良さそうな気がしています。
- どのようにComponentを分けるか
- Modelはどうあるべきか
- Vuexをどのように利用すべきか
どのようにComponentを分けるか
Componentを利用することでページを要素に分解でき、影響範囲を分けられたり再利用できたりと嬉しい感じになります。更にComponentは親子関係を持たせProps
とEvent
を使いデータを受け渡すことができるようになっているため、より構造的にComponentを組み合わせることができます。
ここで問題なのが、Componentをどの単位で区切るかです。
本来MVVMではViewはViewModelからデータを受け取るため、View同士(もしくはViewModel同士)でデータを受け渡すことを想定していません。そのため、VueComponentならではのComponentによる構造化を、MVVMにどう組み込むかを意識しComponent設計をする必要があります。
そのため、以下のようなルールと分類をもとにComponentを分けるようにしています。
Component切り分けのルール
- ComponentはView+ViewModelなので、基本的にはそれ単体でデータのやり取りや振る舞いが完結する
- 親子関係を持ち得るComponentは以下の分類のうちどれに該当するかを明確にし、分類ごとのルールに沿って実装する
Componentの分類
1. 完全自己完結型
親からは何も受け取らず自身のView+ViewModelで全て完結するもの。propsを受け付けない。
1Page=1templateだとtemplateが膨れてしまう場合に、要素ごとに分割された結果作られるものがほとんど。そのため再利用されず、ほとんどのケースで「あくまでtemplateを分割しただけのもの」となる。
TabコンポーネントやScrollViewコンポーネントなどのように、要素をラップする一番外側のコンポーネントである場合もある。
2. 半自己完結型
必須パラメータのみを親から受け取り、決まった処理を行う共通パーツ。
Share用URLを受け取って動作するシェアボタンや、メッセージを受け取って表示するアラートダイアログなど。
3. ループアイテム型
ListItemのようにループされて使うことを前提としているコンポーネント。
ステートレスで親から全ての要素を受け取る。必要なfunctionも親からprops経由で受け取る。ReactComponentと同じ感じ。VueComponentとして実装されるものの役割はViewのみで、ViewModelの役割を持たせない。
もしかしたら今後分類は増えるかもしれませんが、今のところこれで運用しています。
Modelはどうあるべきか
APIを叩いてサーバから値を受け取ったり、それを加工してDomainとして保持したり、計算をしたり、Validationをかけたり、アプリケーション全体の状態(State)を管理したりするのがModelの役割です。色々ですね。
役割は多いのですが、Modelの実装上満たさなければならない要件は2つです。
- ViewModelに公開するための、返り値のないModel操作functionを実装する
- Modelの更新をEventとして発火する
これさえきちんと満たしていればModelとしての役割を果たせるので、VueComponentと組み合わせて正しくMVVMを成立させることができます。Modelを階層化させたり、SingleStateにしたりと工夫をしても問題ありません。ViewやViewModelに依存しないため柔軟に実装できるとも言えるんですかね。
ただし、getter
を実装するのはNGです。MVVMはイベント駆動であるため、ViewModelがModelの値を知るにはModelを監視し変更を検知するという方法をとることがルールとなります。
Vuexをどのように利用すべきか
Modelは要件さえ満たせばどう工夫しても良いと書いた通り、Modelの部分をVuexを使って実装するのも選択肢のひとつです。SingleStateでWebアプリケーション全体のState管理をしたいときは役立ちますよね。
また、VuexはVueの中に組み込まれていることを自身が知っているため、VueComponentとのつなぎ込みがスムーズにできる利点があります。とは言えその利点を闇雲に使うとMVVMを破綻させることになり兼ねないので、ModelとしてVuexを使うことを念頭に置いて、Vuexの基本機能を理解していくといいと思います。
ステート
VuexStoreの値をVueComponentに反映する一番シンプルな方法はcomputed
プロパティで待ち受ける方法だと公式に書かれています。
const Counter = {
computed: {
counter() {
return store.state.count
}
}
}
これは内部的にはViewModel(VueComponent)がModel(VuexStore)を監視し、変更を検知し値を受け取りViewに反映する、といったことをやっているのでMVVMをシンプルに実装できています。
また、公式には
コンポーネントはまだローカルステートを持つことができる Vuexを使うということは、全ての状態を Vuexの中に置くべき、というわけではありません。
と書かれています。この点について、個人的にはViewModelが持つべきもの(=Viewが表示するために使うデータ)をローカルステートに持たせ、Modelが持つべきもの(=アプリケーションのDomainとしてのデータ)をVuexStoreに持たせるようにしたほうがいいかなと思っています。
ミューテーション、アクション
VuexStoreを更新する唯一の方法として用意されているミューテーション。typeとpayloadを受け取ってstoreを更新するあたり、fluxに似ていると言われる所以なのでしょうか?
そしてVuexStoreの更新(ミューテーション)を非同期処理に組み込めるアクション。これがModelの要件のひとつである返り値のないModel操作functionを実装するに該当しています。
つまり、Vuexを使うことでModelの2つの要件を自然に満たせるということになりました。さすが。
ゲッター
Modelにgetter
は書くなというルールがあるはずなのに、Vuexにはゲッターの概念があります。ゲッターについて公式は以下のように言及しています。
もしこの関数を複数のコンポーネントで利用したくなったら、関数をコピーするか、あるいは関数を共用のヘルパーに切り出して複数の場所でインポートする必要があります。しかし、どちらも理想的とはいえません。
つまり、VuexStoreの値を加工して使う場合に、加工する処理をあちこちに書いたり、importして使い回したりするのはナンセンスだと言っています。
本来MVVMにおいて、Modelから受け取った値をViewで利用する値に加工するのはViewModelの役割です。ただし、複数のViewModelに同じような加工処理が実装されてしまうくらいなら、Model側で用意したほうがいいんじゃない? という思想なんですね。
この扱いには注意が必要だと感じています。
言われるがままに何でもゲッターに実装してしまうとゲッターが膨れてしまったり、とはいえ結局VueComponentの中でもごにょごにょ加工してて処理がModelとViewModelに散ってしまったりと、暗黒面に落ちることが目に見えています。
そのため、ゲッターはVuexStoreの中身をfilterするために使うとルール付けています。
SingleStoreであるためにひとつのModelが大きく、ViewModelが取得するときにはModelの一部のみを受け取れれば十分、というケースで、Modelの値をfilterするためにゲッターを使うというわけです。
例えば「UsersStore
にUserが追加された場合に、UsersStore
丸ごとViewModelへ伝えるのではなく、該当Userオブジェクトのみを伝える」といった感じです。
そして、そのUserオブジェクトからfirstName
とfamilyName
とage
をつなげて${familyName} ${firstName} (${age})
という文字列に加工するのはViewModelに任せるというわけです。
まとめ
Vuexの基本機能を使えば自然とModelの要件を満たせました。ただし、ModelとViewModelの間にある曖昧な機能もあるので、使い方にルールを持つと良さそう、という話でした😌
おまけ Vue+fluxは成立するのか
これはfluxという言葉の定義次第なので、あくまでおまけの話で個人の解釈です。あんまり大きい声で言える話でもありません。
さて、fluxの定義の大きな要素は、単一フローを強制することにあると思います。
MVVMも適切に作ればデータは決まった方向に流れますが、MVVMをMVVMたらしめる要素は双方向バインディングです。そのためView⇔ViewModelは単一フローになり得ません。
ViewModel⇔Modelの部分だけを見れば、Dispatcher→Store→Viewと似たようなデータフローになるので、部分的にfluxに似ると言えるかもしれません。逆に言えば、こういうところが「fluxはMV*を再発明した」と言われる所以かもしれませんね。
と言うわけで、Vueを使う以上双方向バインディングは活かしたいですし、そうなるとMVVMになるのは妥当ですし、厳密にはMVVM≠fluxですので、Vue+flux成立はしないと言ってもいいんじゃないかなと思っています。fluxにしたいならReactを使おうよ。
あとがき
Vueを半年くらい触ってきたので現在地を書き残しておく意味も兼ねて記事にしました。半年後の自分が見たらダメダメな設計方針かもしれませんが今はこんな感じでVueを使っています。
設計って考えだしたらキリがなくて、経験を積めば積むほどブラッシュアップできるものだと思うので、恥ずかしがらずにディスカッションしていいんじゃないかなと思って書きました。きっともっと優れた設計をするエンジニアもいるでしょうし、人はルールを守れないからこんな設計は不要だと考えるエンジニアもいると思います。それぞれの意見にその人が経験してきた背景があると思うので、いろんな意見が聞けたらいいなと思います。ぜひ@aloerina_まで。