読者です 読者をやめる 読者になる 読者になる

anti scroll

ブラウザと小説の新しい関係を模索する

Rx(Reactive Extensions)とVirtual DOMで作るリアクティブなUI、およびMVARアーキテクチャについて

元ネタは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つのレイヤーにまとめても問題なさそう、というかそっちのほうが良いかも?

大雑把な処理の流れ

全体的な処理の流れとしては、

  1. Actionはユーザー操作などで発生したネイティブ・イベント(クリックとか)をイベントストリームに変換する。
  2. Modelはイベントストリームを、モデル状態のストリームへと合成変換する。
  3. Viewはモデル状態のストリームを、VirtualDOMのストリームに変換する。
  4. 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の双方に紐付いてしまうため、それぞれのレイヤーの再利用や独立が難しい