元ネタはReactive MVC and the Virtual DOMなのですが、MVIという言葉がしっくりこなかったので、自分なりに消化した結果、こういう枠組みになりました、という話しです。
MVARアーキテクチャ概要
まず頭文字の意味についてですが、
M = Model V = View A = Action R = Renderer
です。ついでに各レイヤーの役割に大雑把な型らしきものを付けると、
Action :: native event -> event Stream Model :: event Stream -> model Stream View :: model stream -> virtual-dom Stream Renderer :: virtual-dom Stream -> html
のような感じになります。
ちなみにActionはViewとModelの双方に参照されます。
ViewとModelが直接つながると、ViewがModelを参照し、ModelがViewを参照する循環参照になってしまうからです。
追記:後からわかりましたが、ActionとModelを1つのレイヤーにまとめても問題なさそう、というかそっちのほうが良いかも?
大雑把な処理の流れ
全体的な処理の流れとしては、
- Actionはユーザー操作などで発生したネイティブ・イベント(クリックとか)をイベントストリームに変換する。
- Modelはイベントストリームを、モデル状態のストリームへと合成変換する。
- Viewはモデル状態のストリームを、VirtualDOMのストリームに変換する。
- Rendererは、VirtualDOMストリームを購読して、直近2世代を比較した上で差分だけを画面更新する。
概念コードを書くとこんな感じ(変数の末尾に付く「$」は「ストリーム」の「S」だと思って下さい)。
var initial_state = {title:"no title"}; var action = new Action(); // action -> model$ var model$ = new Model(initial_state).observe(action); // model$ -> vdom$ var vdom$ = new View(action).observe(model$); // vdom$ -> html Renderer.render(vdom$);
Action
ActionはUIから発生したネイティブなイベントを受け取り、それをストリームとして保持するレイヤーです。
var Rx = require("rx"); function Action(){ this.keyupTitle$ = new Rx.Subject(); }; Action.prototype.onKeyupTitle = function(ev){ this.keyupTitle$.onNext(ev); };
細かくは述べませんが、実はRxのSubjectやbacon.jsのBusを使うのは良くないマナーとされています。上のkeyUpTitle$も、本当はEventEmitterとRx.Observable.fromEventを使って作るのが良いのですが、ここでは簡単のためSubjectを使ってます。
Model
Modelは、Actionが保持するイベントストリームを合成し、現在状態を表すストリームを出力するレイヤーです。
var Rx = require("rx"); function Model(initial_state){ this.initialState = initial_state || {}; } Model.prototype.observe = function(action){ var title$ = action.keyupTitle$.map(function(ev){ return ev.target.value; }).startWith(this.initialState.title); return new Rx.BehaviorSubject(this.initialState) .combineLatest(title$, function(model, title){ return {title:title}; }); };
View
Viewは、Modelが合成した状態ストリームを、virtual domのストリームに変換するレイヤーです。
var h = require("virtual-dom/h"); function View(action){ this.action = action; } View.prototype.renderTitle = function(title){ return h("input", { type:"text", value:title, "ev-keyup":function(ev){ this.action.onKeyupTitle(ev); }.bind(this) }); }; View.prototype.renderLabel = function(title){ return h("label", title); }; View.prototype.observe = function(model$){ return model$.map(function(model){ return h("div.output", [ this.renderTitle(model.title), this.renderLabel(model.title) ]); }.bind(this)); };
Renderer
Rendererは、Viewが出力したVirtualDOMストリームを購読し、最新のHTMLを出力するレイヤーです。
var h = require("virtual-dom/h"); var patch = require("virtual-dom/patch"); var diff = require("virtual-dom/diff"); var Renderer = { render:function(vdom$){ var $app = document.querySelector("#app"); var $root = document.createElement("div"); $app.innerHTML = ""; $app.appendChild($root); $vdom .startWith(h()) // 最初は<div></div>だけ .bufferWithCount(2,1) // 二世代分をバッファ(1,2) -> (2,3) -> (3,4) etc .subscribe(function(vdom2){ // vdom2.length は2 var patch_obj = diff(vdom2[0], vdom2[1]); // 差分を取る $root = patch($root, patch_obj); // 差分だけ更新 }); } };
AppMain
AppMainはアプリケーションのエントリポイントです。
ここでMVARの各レイヤーを初期化し、ストリーム同士を結びつけてあげます。
あとはユーザーが操作してイベントが発生すると、自動的に差分だけが画面更新される、という運びです。
var DOMDelegator = require("dom-delegator"); var App = { start:function(){ var delegator = new DOMDelegator(); // おまじない。virtual-domの'ev-***'を動作させるために必要 var action = new Action(); // actionはviewとmodelの双方に参照される(view -> action -> modelなので) var model$ = new Model({title:"no title"}).observe(action); // (action, initial state) -> model$ var vdom$ = new View(action).observe(model$); // (action, model$) -> vdom$ Renderer.render(vdom$);// vdom$ -> html } };
まとめ
FluxではDispatcherがグローバルなインスタンスのような形になりますが、MVARではActionがそれに該当します。
Fluxだと、View -> Action -> Dispatcher -> Model(Store) -> View
MVARだと、View -> Action -> Model -> View
ようするに、Dispatcherのレイヤがなくなってる状態ですね。
以上を踏まえた上で、長所と短所ですが…
長所
- virtual-domはreact.jsより高速(らしい)
- viewが状態を持たない(
setState
とかgetInitialState
とかがない) - Dispatcherのレイヤがなくなり、全体の構造がより簡素化されている
- 状態とビューがストリーム処理で閉じられているため、リアクティブなUIが作りやすい
短所
- 元記事に指摘されている通り、ActionがModelとViewの双方に紐付いてしまうため、それぞれのレイヤーの再利用や独立が難しい