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の双方に紐付いてしまうため、それぞれのレイヤーの再利用や独立が難しい

Prepared Statementを少し使いやすくするeps(Extended Prepared Statement)

通常のPrepared Statementを少し使いやすくする処理系 eps を作りました。

epsはExtended Prepared Statementの略です。

github.com

簡単に言うと、こんな感じでPrepared Statementを記述したくて作ったものです。

prepare foo(age:int, name:text="no name!") as
select * from people where name={name} and age={age};

ようするに

  • ラベル付き引数を使いたい
  • デフォルト引数を使いたい
  • ついでに型を考慮した呼び出し側のコードが出力したい

わけでした。

使い方

上のSQLtest.sqlというファイルで保存したとして、

eps.exe -input test.sql -format sqlとすると

prepare foo(int, text) as
select * from people where name = $2 and age = $1;

が出力されます。また

eps.exe -input test.sql -format ocamlとすると

let prep_foo = 
  "prepare foo(int, text) as select * from people where age = $1 and name = $2;"

let exec_foo ~age ?(name="no name!") () = 
  Printf.sprintf "execute foo(%d, '%s');" age name

のように、デフォルト引数、ラベル付き引数、型制限が付いたコードが出力されます。

メリット?

  1. 通常のプレースホルダー($1,$2みたいなの)じゃなくて、ラベル名を参照したSQLが書ける => うっかり割り当てを間違えたSQLを作る危険性が減る。
  2. 型付きかつラベル引数のOCaml関数で読み出せる => 呼び出し方を間違える危険性が減る。
  3. ついでにデフォルト引数を宣言できる。

現在ターゲットに指定できる言語はOCamlSQLだけですが、そのうちサポート言語を付け足すかも?

Kefir.jsを使ってnehan.jsなビューアーをReactiveに作る

Kefir.jsを使ったのは、ドキュメントがわかりやすくて綺麗だったからです。

ビューアーの要件

  1. NEXTクリックでページを進める。
  2. PREVクリックでページを戻す。
  3. $(“#page-count”)に、現在の総ページ数を表示する(非同期で増える)。
  4. $(“#page-no”)に、現在のページ番号を表示する。
  5. ただしページ番号は、総ページ数の値を超えることはできない。

実装方針

  • 非同期で新しいページが出力されるたびに「現在の総ページ数」が出力されるストリームpage_count_streamを作る。
  • 現在のページ数とNEXT/PREVクリックのアクションを合成して、現在のページ位置を出力するストリームpage_index_streamをる。
  • page_count_streamを購読し、$(“#page-count”)を更新する。
  • page_index_streamを購読し、$(“#screen”)に該当ページを出力しつつ、$(“#page-no”)にページ番号を出力する。

page_count_streampage_index_streamを宣言的に(ステートを持たないように)表現できたら成功。

ページ数のストリームを作成

nehan.jsが作ったページ数を受け取るストリームをKefir.bus()で作成しておきます。

var page_count_stream = Kefir.bus();

ページ位置のストリームを作成

NEXT/PREVのクリックをそれぞれ+1-1を出力するストリームにし、それらを一つに束ねたものをclick_value_streamとして定義します。

var next_click_stream = $("#next").asKefirStream("click");
var prev_click_stream = $("#prev").asKefirStream("click");
var click_value_stream = Kefir.merge([
  next_click_stream.mapTo(1), 
  prev_click_stream.mapTo(-1)
]);

ここからは少し面倒です。

こういうnext/prevでカウントするサンプルって、大抵はストリームの値を合算したものを現在値みたいにするケースが殆どです。

// これだと使えない
var sum_stream = Kefir.scan(click_value_stream, function(acm, value){
  return acm + value;
});

しかしこれだと上手くいかないのです。

なぜならこちらが欲しいのは、下限と上限付きの値だからです。

言うまでもなく、下限は先頭ページ位置の0で、上限は「現在の」出力可能なページ数をマイナス1した値です。

ページ数が4(最大インデックス3)なのに、4とか5を出力されても困りますよね。

例えば2回だけ上限オーバーした数値は、PREVを2回押して戻さないと使えないってことになりますので。

だから単なる合算ではなく、現在のページ数を参照して合算するストリームが必要になります。

最初にミスったこと

これを実現するにあたって最初にミスったのはKefir.zipを使って、クリックのストリームとページ数のストリームを合成することでした。

しかしこれは上手く行きません。

なぜならクリックのストリームがclickイベントが発生する度に出力する無限ストリームであるのに対し、ページ数のストリームは最終ページを出力したらもう新しい値を出力しない有限サイズのストリームだからです。

有限のストリームと無限のストリームをzipすると、短い方に合わせて有限のストリームが出来上がります。

つまりKefir.zipを使って合成したストリームを購読しても、有限回しか操作できません。

こういう2つのストリームを一つの無限ストリームに束ねるにはどうしたらいいのでしょう。

解決策「Kefir.combine」

そこでドキュメントをざっと眺めて見つけたのがKefir.combineです。

この関数は、対象ストリームの「最新の値」を使ってストリームを合成できます。

つまり片方の長さが3しかなくても、もう片方が無限なら、有限な方の最後の値が無限に参照できます。

結果、こんな実装になりました。

var page_index_stream = Kefir.combine([
  click_value_stream, 
  page_count_stream
], function(value, count){
  return {value:value, count:count};
}).scan(function(acm, cur){
  return {
    // 0 ~ [cur.count - 1]
    value:Math.max(0, Math.min(cur.count - 1, acm.value + cur.value)), 
    count:cur.count
  };
}, {value:0, count:0}).map(function(obj){
  return obj.value;
});

それぞれのストリームを購読

さて、なんとかpage_count_streampage_index_streamを定義することができました。

後は、page_count_streampage_index_streamをそれぞれ購読し、必要なUIを更新する処理を定義するだけです。

ページ出力のタイミングでpage_count_streamに最新のページ数がemitされるので、それに合わせて、紐付いたUIが連動して変化するようになっています。

var paged_element = Nehan.createPagedElement();

// ページ数を購読
page_count_stream.onValue(function(count){
  $("#page-count").html(count);
});

// UI操作とページ数によって決まる現在のページ位置を購読
page_index_stream.onValue(function(index){
  $("#page-no").html(index + 1);
  paged_element.setPage(index); // 現在位置を更新
});

// ページ計算を開始
paged_element.setStyle("body", {
  "flow":"tb-rl", // or "lr-tb"
  "font-size":16,
  "width":500,
  "height":300
}).setContent($("#screen").html(), {
  onProgress:function(tree){
    // ページ出力のタイミングで現在のページ数を出力
    page_count_stream.emit(tree.pageNo + 1);
  }
});

// 画面上に組版結果を表示する
$("#screen").after(paged_element.getElement());

感想

色々なUI要素が絡むような複雑なアプリなら、注目している値を透過的に取得できるので、安全にUIを更新できそうな気がしますが、今回のような単純なケースでは少し面倒かもしれません。

単純なマッチで複数回replaceするのと、文字クラスを使って一回replaceするのでは、どちらが速いか

qiitaでこんな記事がありました。

innerText(textContent)/innerHTMLを使わずJavaScriptでHTMLエスケープ - Qiita

で、思い出したのですが「文字クラスでreplaceを一度で済ますより、単純なマッチを直列で繰り返したほうが速い」って話しをどこかで聞いた覚えがあるので、どのぐらい差があるのか、ちょっと試してみました。

念のため、元記事の関数でテーブル参照をしないバージョン(escapeCharClassEx)も用意。

3パターンのescape関数

// 元記事の関数
var escapeCharClass = function(content) {
  var TABLE_FOR_ESCAPE_HTML = {
    "&": "&amp;",
    "\"": "&quot;",
    "<": "&lt;",
    ">": "&gt;"
  };
  return content.replace(/[&"<>]/g, function(match) {
    return TABLE_FOR_ESCAPE_HTML[match];
  });
};

// 念のためテーブル参照しないバージョン
var escapeCharClassEx = function(content) {
  return content.replace(/[&"<>]/g, function(match) {
    switch(match){
    case "&": return "&amp;";
    case "\"": return "&quot;";
    case "<": return "&lt;";
    case ">": return "&gt;";
    }
  });
};

// 4回replaceバージョン
var escapeSerial = function(content){
  return content
    .replace(/&/g, "&amp;")
    .replace(/"/g, "&quote;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
};

検証プログラム

以下の様な感じで、入力文字が短い場合と長い場合の双方で速度を比較してみました。

var replicateString = function(text, times){
  var ret = [];
  for(var i = 0; i < times; i++){
    ret.push(text);
  }
  return ret.join("\n");
};

var testEscapeSpeed = function(){
  var text = "hoge&hage>hige<hoge";
  var text_short = replicateString(text, 10);
  var text_long = replicateString(text_short, 100);
  var replace_many = function(text, replace_fn, times){
    times = times || 1000;
    for(var i = 0; i < times; i++){
      replace_fn(text);
    }
  };

  console.time("char class short");
  replace_many(text_short, escapeCharClass);
  console.timeEnd("char class short");

  console.time("char class short ex");
  replace_many(text_short, escapeCharClassEx);
  console.timeEnd("char class short ex");

  console.time("serial short");
  replace_many(text_short, escapeSerial);
  console.timeEnd("serial short");

  console.time("char class long");
  replace_many(text_long, escapeCharClass);
  console.timeEnd("char class long");

  console.time("char class long ex");
  replace_many(text_long, escapeCharClassEx);
  console.timeEnd("char class long ex");

  console.time("serial long");
  replace_many(text_long, escapeSerial);
  console.timeEnd("serial long");
};

検証結果

Chrome40で走らせた結果、以下のような結果になりました。

test name time
char class short 7.573ms
char class short ex 4.213ms
serial short 1.972ms
char class long 296.516ms
char class long ex 264.621ms
serial long 128.274ms

単純なマッチで4回replaceしたバージョン(escapeSerial)が、短い文字列では4倍程度、長い文字列だと2倍ちょっとぐらい速いという結果でした。

いや、ちょっとおかしい

文字クラスを使った場合の関数は、単にマッチ後のコールバック関数呼び出しのオーバヘッドが入るだけではあるまいか、ということでescapeSerialにおける各replace処理を関数で置換するように変えたら、今度は4回呼び出しのほうがコールバックの回数が増えたぶん、少しだけ遅かったです。

test name time
char class short 5.004ms
serial short 5.218ms
char class long 2559.319ms
serial long 3020.997ms

ということで、

  1. 文字クラスで複数文字を一回でマッチ&置換させても、それぞれの文字で一回ずつ置換しても速度は殆ど変わらない
  2. しかしそれぞれの置換でコールバック関数を呼ぶとパフォーマンスは二倍ぐらい遅くなる
  3. 各関数の呼び出しの中でテーブル参照すると更に遅くなる

みたいです。

たぶん1については、4×1と1×4程度の違いなんだと思います。

jingoo v1.2.5 release

jingooのv1.2.5をリリースしました。

ひょんなことからjingoov1.2.4にバグ(割りとでかい)を見つけてしまい…緊急でリリースしました。

旧バージョンを使用中の方は、アップデートすることをおすすめします。

バグの詳細

具体的なバグは何かというと、オブジェクトをドットで展開する際に、対象が以下の様な入れ子のオブジェクトだったとき、evalが失敗して例外が投げられていたことです。

{{ user.image.filename }}

原因

原因はASTの評価処理で、ドットでプロパティにアクセスする評価式を、次のようなパターンマッチでのみ受け取っていたからでした。

| DotExpr(Ident(name), Ident(prop)) ->
  jg_obj_lookup_by_name env ctx name prop

オブジェクトのドット展開は左結合で、右にドットが2つ以上続く場合は左辺が再帰的にオブジェクトを返すはずですが、DotExprの評価式で左辺がIdentのみの評価しかしていなかったので、左辺がobjectを返すようなASTだったときにマッチするパターンがなく、SyntaxErrorがthrowされていたわけです。

今までこれに気づかなかったのは、単に入れ子のオブジェクトを扱っていなかったからなんですが、今回キャラクタ機能というのをリリースした際に、キャラクタオブジェクトの画像オブジェクト、という入れ子のオブジェクトを展開する必要があって、初めてバグに気づきました。

修正

対応自体は簡単で、パターンマッチのケースを一つ追加するだけで大丈夫でした。

| DotExpr(Ident(name), Ident(prop) ->
  jg_obj_lookup_by_name env ctx name prop

(** 左辺がオブジェクト *)
| DotExpr(left, Ident(prop)) ->
  jg_obj_lookup env ctx (eval_expr env ctx left) prop

おまけ

久しぶりのソースだったので「思い出せるかなあ」と憂鬱だったのですが、OCamlってコンパイラに型エラーで怒られているうちに、徐々に思い出すんですよね。

改めて型システムは偉大だなあ…などと思いました。