Vue.js Tokyo v-meetup="#1"でLTしてきた、と発表からカットしたMVVMのあれこれ

Vue.js Tokyo v-meetup="#1" - connpassでLTしてきました。発表資料はこちら。

speakerdeck.com

イベント全体を通した内容や感想については以下の記事があります。

発表内容

MVVMで設計・実装するにあたって、M・V・VMの各責務を意識してコーディングしないと、ViewModelが肥大化する。というのは、Angular.jsのコントローラ、Vue.jsのVueインスタンスが気がついたら大きくなっていたという経験がある方はピンとくるのではないでしょうか。

ViewModelが肥大化する原因はいくつかあると思っていて、ざっと思いつく限りでは以下のものがあります。

  • ViewModelが揮発性の現象によって一時的に必要な状態や振る舞いを保持し続けている
  • ViewModelがModelが本来担うべきロジックを持っている
  • ViewModelがViewを参照して、その操作をおこなっている(ここでのViewはDOM要素と定義しておきます)

これら全てについて、紹介したかったのですが、時間が8分だったので、揮発性の現象とそれをMessengerパターンで解決する方法についてのみ紹介しました。ViewModelの肥大化は起こりがちな問題なので、発表後の交流のときに共感するお言葉をいただけたのがうれしかったです。

Messengerパターンとデータバインディング

スライドに書いていなくて、発表の時に話したこと。Messengerパターン、一見、変なことをやっているようにもみえますが、本質的にはデータバインディングと同じです。データバインディングはライブラリ利用者からすると、「ViewModelの状態が変わると、それがViewに反映される」ように表面では見えますが、裏では「ViewModelの状態が変わると、変更されたことを示すイベントが発火して、それをViewがハンドリングして、ViewModelの状態を取得して、表示を更新する」ということをおこなっています。Messengerパターンは、それと同じことを揮発性の現象に対しておこなっているだけです。

以下、発表で本来話すつもりだったのですが、時間の都合でカットした内容について書きます。Vue.jsとMVVMのあれこれです。

発表でカットした内容

Modelとデータフロー、設計

サーバーサイドのMVCのファットコントローラ問題でも同じですが、Modelが本来持つべきロジックをViewModelが持つことで肥大化する場合もあります。プロトタイプやサンプル、小さいアプリケーションを開発するときはよいような気がしますが、Web APIの呼び出しやエラーハンドリング、UIや通信と関係しないドメインのロジックはModelが本来担うべきです。普段のコーディングにおいて、ViewModelは「Viewからのイベントに応じて、Modelのメソッドをただ呼び出す(返り値は扱わない)」、「Modelの変更を検知して、Modelの状態を取得して、自身の状態を更新する(= データバインディングでViewに反映する)」を意識するようにしています。データの流れはV → VM → M → VM → V で一方向です。

MVVMの批判として(どこでそれを聞いたか忘れてしまったんですが)、 「MVVMは双方向データバインディングでデータの流れが一方向でなくわかりづらい。データの変更が意図しないところに反映されてつらい」といった意見を耳にします。おそらく双方向データバインディングをView・ViewModel間だけでなく、適用する範囲を広げて、ViewModelの親子間や、ViewModel・Model間、Model同士の間に適用した結果、そう感じたのではないか、と思います。
データバインディングは、View・ViewModel間では双方向(あるいは単方向)、ViewModelの親子間は親から子への単方向に留め、ViewModelとModelは上に書いた内容を意識すれば、MVVM全体として、データの流れは一方向でわかりやすいですし、意図しないところに反映されることもなくなります。

また、ロジックが移されたModelの設計をどのようにおこなうか。これは作るもの次第だとは思いますが、自分が業務でよく作る小~中規模のSPAの場合は、通信などの非同期処理をおこなうオブジェクト、その結果を格納して変更のイベントを発火するオブジェクト、各オブジェクトのエラーイベントをハンドリングして集約するオブジェクトの3つの役割に分けています。あくまで一つの例です。場合によってはFluxを適用するべきかもしれませんし、Clean Architectureを適用するべきかもしれません。
いかなるケースでもこのやり方を適用すれば良いというものはおそらくありません(当然、MVVM自体についてもそれは言えます)。どのように設計するかは、作るアプリケーションの規模や内容、関わる人数、アップデートする頻度を考慮して判断すべきことだと思います(それを考えるのがクライアントサイドのアプリケーションを作る醍醐味、やりがいのひとつのように個人的に感じています)。

ViewModelとDOM

テスタビリティやメンテナビリティの観点から、MVVMのルールでは、ViewModelがViewを参照したり、Viewの特定の実装に依存してはいけませんが、Vue.jsでViewModelに相当するVueインスタンスはView(DOM要素)への参照を持っています。

var vm = new Vue({
  el: '#example'
});
vm.$el // id属性値がexampleの要素の参照

が、Vueインスタンス自体はDOMがなくても生成でき、ViewModelが担うべき状態の保持、プレゼンテーション・ロジックや振る舞いを動かすことが可能です。例えば、以下のコードはブラウザでないJavaScript実行環境、Node.jsで動きます。

var Vue = require('vue');
var vm = new Vue({
  data: {a: 1},
  watch: {
    'a': function(val) {
      console.log(val);
    }
  }
});
vm.a = 3;

基本的にデータバインディングによって、ライブラリ利用者が記述するVueインスタンスに関係したコードはDOM要素を参照する必要はありませんが、それでもDOM要素を操作したくなるときがあります。自分の場合は以下の様なケースです。

  • 画像の遅延ロード
  • 無限スクロール
  • DOM要素でブラウザの機能検出
  • ...

これをViewModelでおこなうと、ViewModelの肥大化に繋がります。またDOM要素に依存することでテスタビリティも下がります。DOM要素に依存していても、Phantom.jsのようなへッドレスブラウザやjsdomのようなDOMエミュレートライブラリを利用してユニットテストを記述・実行することは可能ですが、ヘッドレスブラウザは起動に時間がかかるので実行コストが高く、タイミング次第でテストが通ったり通らなかったりするなど不安定です。jsdomに関しても、エミュレートライブラリなので、本来のDOM APIと異なる振る舞いをして、ハマることもあるでしょう。ということでViewModel(Vueインスタンス)のユニットテストとして考えた場合は、やはりViewModel(Vueインスタンス)は原則、DOM要素に依存しないほうがよいと思います。

解決策のひとつとして、以下のようにカスタムディレクティブを定義してDOM要素への参照やDOM操作を逃す方法があります。

<div v-foo></div>
Vue.directive(‘foo’, {
  bind: function() {
    this.el // DOM要素への参照
    this.vm.$emit // ViewModelへイベントを送る
    this.vm.$on // ViewModelからイベントを受け取る
  },
  unbind: function() {
  }
});

まとめ

Vue.js Tokyo v-meetup="#1" でLTした内容と発表でカットした内容について紹介しました。

これまで、Vue.jsを利用していて、その手軽さ、シンプルさ、他のライブラリ/フレームワークにインスパイアされた洗練されたAPIに満足していましたが、ひとつ気になっていた点が継続性でした。他のライブラリ/フレームワーク(Angular.jsやReact.js)のように企業(Google, Facebook)がバックにいるわけではなく、個人が開発しており、コミュニティの規模も他と比べれば相対的に小さいものでした。
それが現在、PHPフレームワークLaravelで採用されたことをきっかけに、コミュニティが加速度的に大きくなってきています。また、それに伴って、Vue.js本体や周辺ライブラリの開発チームの体制が整い、スポンサーに名乗りをあげる企業も出てきています。他のライブラリ/フレームワークと比較すると、どうしても相対的に規模は小さいと言わざるおえないですが、ひとつのJavaScriptライブラリの継続性としては、現在の状態でほぼ保証されているように思います。 今回のmeetupの目玉、Vue.jsの作者EvanさんとのQAで、それを再認識できたのがよかったですね。