RxJSでMVVMやってる
Rxは、すごくUIを書くのに向いているのではないだろうか。アプリケーションの状態を山盛りの変数で管理することから解放され、状態から状態へ変換する関数を書けばよくなるから。
非同期処理を同期っぽく書きたいならawait
でいいじゃん。UIイベントを宣言的に書きたければ 2-wayバインディングがあれば良いじゃん。という話では終わらず、その辺の問題解決に加えて、値の発生器を全て同じ宣言にまとめられ、状態変数がなくなるところが書いていて楽しいところです。
// たとえば、、 Observable.fromEvent(searchBox, 'input') // 検索窓に字が打ちこまれたら .debounce(500) // 0.5秒ごとに .map(e => e.target.value) // 入力されたテキストを .filter(q => q.length > 0) // 1文字以上の場合だけ .select(q => fetch(`https://kensaku.com?q=${q}`) // 検索リクエストをなげる .switch() // リクエストが複数なら最後だけ使う。残りはキャンセル .mergeMap(res => res.json()) // JSONをデシリアライズしといて .subscribe( ...) // よろしく
というようなのが、よく見るかっこいい例で、UIイベントとHTTPリクエストについての複雑な処理を、1本の連続したメソッドチェインで宣言できます。値を受けとって値を返す関数と、値を受けとって値生成器(Observable) を返す関数を書くというのがほぼやることの全てになり、見た目がかっこいい感じです。
さて、最近のjavascriptは、言語仕様がc#っぽくなり、フレームワークに頼らずともコードの分割がそこそこ統一できますし、 underscore や lodash がなくてもコレクションの操作がそこそこいける。ESはブラウザ実装状況を心配せずともbabelやbrowserifyを使えばたぶんOK。DOM操作は、サポートすべきブラウザの状況が変わりつつあるため、jQueryがなくとも素のAPIで意外とシンプルに書けます。あと足りないものってなんだっけ? 個人的には、Rxを使えば、標準のDOM APIだけでかなりうまくやれるのではないかと考えているところです。
MVVM
jsは、起動時にUIの参照をもっていないため、UIとそれ以外のコードの分離がとても難しいという独特な問題があり、アプリケーションが大きくなるとそのことを考えたくなります。その辺りをうまくやろうとするフレームワーク的なものが、これまでハイペースで生まれていました。その盛り上がりたるやすさまじく、ついに仮想DOMというすげえアイデアが登場するに至り、その都会的な新しいセンスに人気が集まっています。コードからDOMを支配するのではなく、コードから生成し完全に支配できるDOMもどきをつくっちゃえばいいじゃん。後で実DOMと同期させればいいじゃん。と彼らは言います。たしかに、これがあれば、UIのコードもそれ以外のコードもはじめからすべてjavascript管理下なので、分離がかんたんだ。
仮想DOMはたしかにすごいですが、よく見ると新しい問題も生まれています。アプリケーションのコードをうまく分割するためには、当然仮想DOMのコンポーネントをネストさせることになりますが、そんなことしてしまうと、子の仮想DOMの変更を、rootの仮想DOMにまで通知しなければいけなくなります。子の仮想DOMが勝手に自身の管理下のDOMを変更したとしても、親の仮想DOMもそのことを知らなければ、アプリケーション全体の差分レンダリングができない。Fluxは、データの流れが一方向できれいだと主張していていましたが、それはある程度マーケティング的な側面があり、仮想DOMを使う以上は、内部の細かな変更をもらさず全体に通知するアーキテクチャに行くのは当たり前なはずです。
仮想DOMのネストどうするか問題。もしも、Rxをつかえばどうなるでしょうか。UI部品の変更タイミングを、Observableを使って通知することを考えてみます。すべてのUIコンポーネントが、仮想DOMを返す変わりに、「仮想DOMを生成するObservable」を返すようにすれば、たくさんのコンポーネントの変更を束ねることができます。アプリケーション内のネストしたコンポーネントすべての変更を監視し、最新の仮想DOMを受け取ってレンダリングする。この流れをRxで一本にまとめることができそうです。
ちょうどそのような仕組みをもった、RxJSと仮想DOMをつかったライブラリに cycle.js というものがあります。 cycle.jsは、Rxでなんでも処理できるから、アプリケーション全体がただの Rxの通り道でいいじゃん。という考え方におもいっきりハンドルを切っています。 cycle.js では、アプリケーション全体が 1つの関数です。この関数は、入力に Observableを受けとって、出力に新しいObservableを返します。
図のように、cycle.jsのコンセプトは、アプリケーション全体がSinkになっていることがすごく特徴的です。そのことがすごく綺麗に見えて、目に映る全てのものが輝いてみえてしまう僕だったのですが、これを使ってアプリケーションを書いてみたところ、DOM、HTTP、他、全てのものがアプリケーション全体の入出力になっている点に意外と違和感を感じました。内部で発生する通信などのObservable生成は、すべてアプリケーションの外に投げてあげないといけないし、受けとるときも、誰が発行したかに関わらずアプリケーション全体への入力として渡ってきます。この仕様には、アプリケーションが内側に隠蔽したObservableがあっても別に良いのではないか? という疑問が出てきます。すべてのObservableを1本にまとめなくとも、こまぎれにして部分部分で生成/subscribeしたとしてもRxは充分強力に思えるからです。
上の図は、Rx でMVVMをするための ReactiveProperty というライブラリの図です。MVVMでは、UI と、それを表現した値オブジェクトであるViewModelをつくり、ViewModelのプロパティを変更すると自動でUIがレンダリングされるような設計です。ReactivePropertyでは、このViewModelのプロパティを、ただの値ではなく、プリミティブな値をラップした ReactivePropetyとして持たせておきます。 UI側の責任は、ViewModelの値をUIに反映させること、UIイベントを ViewModel にわかる形で通知することだけです。そこにはほとんどRxの姿はありません。 ViewModelに変更通知を送った結果、それがどのように加工され、フィルタされ、キャンセルされ、別のObservableとまぜられようが、UIはなにもしりません。(UI部分を完全に切り離せるのは、XAML という仕組みがあるから当然かもしれませんが) ViewModelの仕事は、UIイベントを受けて、自身のReactivePropetyを書き換えることで完結します。なぜそれだけでアプリケーションが書けるかというと、UIイベントも、ReactivePropertyも、Observableとして扱えるようになっているからです。
cycle.jsとの最大のちがいは、アプリケーションへの入出力がUIがらみのもにになり、それ以外の仕事はたとえRxで処理されようとそうでなかろうとViewModel以下に分離されていることです。それぞれの責務にゆらぎがなく、ViewModelのテストが書きやすいことが利点です。
Rxで素朴にMVVM
てかんじでシンプルにMVVMすればいいんじゃないかなー。というのが今の僕の考えです。 javascriptには、XAMLのような、UIとReactivePropetyを自動で結びつける機能はありませんが、Observableなインターフェイスをもった値オブジェクトをつくることは簡単です。
import { BehaviorSubject } from 'rxjs/Rx' class Variable { constructor(value) { this.subject = new BehaviorSubject(value) } get value() { return this.subject.value } set value(newValue) { this.subject.next(newValue) } get isUnsubscribed() { return this.subject.isUnsubscribed } get observable() { return this.subject } next(value) { this.subject.next(value) } error(error) { this.subject.error(error) } complete() { this.subject.complete() } } export default Variable
この Variable でラップした値を、ViewModel にもたせます。
class ViewModel { constructor(attrs) { this.keys = Object.keys(attrs) for (let key of this.keys) { this[key] = new Variable(attrs[key]) } } } let vm = new ViewModel({ name: 'a' }) vm.name.subscribe(console.log) //=> 'a' がコンソールにでる vm.name.value = 'b' //=> 'b' がコンソールにでる vm.name.value = 'c' //=> 'c' がコンソールにでる Observable.of('hoge', 'fuga', 'fugo') .map(v => `Mr. ${v}`) .subscribe(vm.name) //=> 'Mr. hoge' 'Mr. fuga' 'Mr. fugo' がコンソールにでる
Observable な値をもつ ViewModel ができました。 ためしにこれをつかって、TodoMVC的なやつを書いてみました。
javascriptには、XAMLのような、UIのプロパティをカスタムクラスにマッピングする機能がないので、まずはViewをレンダリングしたり、UIイベントをObservableに変換するものをつくります。
class TodoList extends Component { constructor({el}) { super({el}) this.vm = new TodoListViewModel({ todos: [] }) this.on('keypress', '.new-todo') .filter(e => e.which === 13) .map(e => e.target.value) .subscribe(this.vm.create()) this.on('keypress', '.edit') .filter(e => e.which === 13) .map(e => { const i = e.target.closest('li').dataset.index const title = e.target.value return {i, title} }) .subscribe(this.vm.update()) this.on('dblclick', '.todo') .map(e => e.target.closest('li').dataset.index) .subscribe(this.vm.editing(true)) this.on('blur', '.edit') .subscribe(this.vm.editing(false)) this.on('keydown', '.edit') .filter(e => e.which === 27) .subscribe(this.vm.editing(false)) this.on('change', '.toggle') .map(e => e.target.closest('li').dataset.index) .subscribe(this.vm.toggle()) this.on('click', '.destroy') .map(e => e.target.closest('li').dataset.index) .subscribe(this.vm.destroy()) this.bindDOM() } render() { return this.vm.todos.observable .map(todos => { console.log(todos) const remining = todos.filter(todo => !todo.completed).length const completed = todos.length > 0 && remining === 0 return h('div', [ h('section.todoapp', [ h('header.header', [ h('h1', 'todos'), h('input.new-todo', { placeholder: 'What needs to be done?', autofocus: true }) ]), h('sestion.main', [ h('input#toggle-all.toggle-all', { type: 'checkbox', checked: false }), h('label', { for: 'toggle-all' }), h('ul.todo-list', todos.map((todo, i) => h('li', { className: (todo.editing ? 'editing' : ''), attributes: { 'data-index': i } }, [ todo.editing ? h('input.edit', {value: todo.title }) : h('div.view', [ h('input.toggle', { type: 'checkbox', checked: todo.completed }), h('label.todo', todo.title), h('button.destroy') ]) ]))) ]), remining > 0 ? h('footer.footer', [ h('span.todo-count', [ h('string', `${remining}`), remining <= 1 ? ' Item' : ' Items' ]), h('ul.filters', [ h('li', h('a.selected', { href: '#' }, 'All')), h('li', h('a', { href: '#' }, 'Active')), h('li', h('a', { href: '#'}, 'Completed')), (completed ? h('button.clear-completed', 'Clear completed') : null) ]) ]) : null ]), h('footer.info', [ h('p', 'Double-click to edit a todo'), h('p', 'Writen by') ]) ]) }) } }
Reactのような機能があまり必要でないので、仮想DOMには https://github.com/Matt-Esch/virtual-dom をつかってみました。 このクラスは、仮想DOMの構築と、UIイベントをViewModel に通知することが仕事です。 ちなみに、Rxもvirtual-domも独立したコンポーネントなので、仮想DOMをつかわずに、サーバでレンダリングした実DOMをこのクラスでいじるケースでも同じ仕組みが使えるでしょう。
render()
は、仮想DOMオブジェクトを返すObservableを返します。これは基底クラスか、初期化する人がsubscribeして実DOMにぶちこむ想定です。
ViewModel の todos
プロパティは、上で定義した Variable
になっているため、変更を監視してレンダリングするまでをRxで処理できるようになっています。
気にいっているポイントは、
this.event('new-todo', 'keypress') .filter(e => e.which === 13) .map(e => e.target.value) .subscribe(this.vm.create())
という部分で、この例では、特定の要素の keypresイベントを、Enterキーだけフィルタし、テキスト入力を ViewModelに通知しています。
ViewModel の create()
メソッドは、Observer
を返します。ここでは、受けとった テキスト入力イベントを、なにか独自のやりかたで処理し、新しいTODOを追加して自身のVariableを更新するObserverですね。
class TodoListViewModel extends ViewModel { create() { return title => { if (title.length > 0) { let todos = this.todos.value todos.push({ title: title }) this.todos.value = todos } } } }
ここをObserverにしてしまうと、UIからのObservableの流れが寸断されちゃうのですが、UIとViewModelはできる限り分離したいのでむしろそのほうが良いんじゃないかなというかんじです。もし、HTTP通信が必要なら、
class TodoListViewModel extends ViewModel { create() { return title => { Observable.fromPromie(fetch(...)) .mergeMap(req => req.json()) .subscribe(todo => { let todos = this.todos.value todos.push({ title: title }) this.todos.value = todos }) } } } }
のように、ViewModelがObservbaleを生成するなり、他のモデルに生成させるなりすればいいんじゃねえかなと今のところ考えています。ViewModel には、UIに関わることが混じらないのでテストも書きやすいとおもいます。
とここまでやって思ったのは、subscribeしたものを適切にdispose(unsubscribe)する仕組みが必要だったりするので、その辺りもアレしていきたいです。