元ネタはReactive MVC and the Virtual DOMなのですが、MVIという言葉がしっくりこなかったので、自分なりに消化した結果、こういう枠組みになりました、という話しです。
まず頭文字の意味についてですが、
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();
var model$ = new Model(initial_state).observe(action);
var vdom$ = new View(action).observe(model$);
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())
.bufferWithCount(2,1)
.subscribe(function(vdom2){
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();
var action = new Action();
var model$ = new Model({title:"no title"}).observe(action);
var vdom$ = new View(action).observe(model$);
Renderer.render(vdom$);
}
};
まとめ
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の双方に紐付いてしまうため、それぞれのレイヤーの再利用や独立が難しい