Virtual DOMを持つMV*ライブラリのmercuryについて
最近node-webkitアプリを書く時、何かしらのMV*やデータバインディングライブラリと言われるものを試しているのですが、floating-memo.appではRaynos/mercuryを使いました。
mercury は小さなモジュールを組み合わせたライブラリとも言えますが色々特徴的です。
- 完全にモジューラーな実装
- Virtual DOM
- FRP
- ファイルサイズが小さめ
モジューラーな実装とは何かというとmercuryのindex.jsを見ると面白い事が書かれています。
/*
Pro tip: Don't require `mercury` itself.
require and depend on all these modules directly!
*/
require("mercury")
しないで、直接それぞれのモジュールを読み込んで使えるという事が書かれています。
(これの意味する所は必要なモジュールだけrequireすればビルドも小さく出来るという所かもしれません)
また、index.jsはモジュールへのエイリアスとメインループの補助関数が定義してあるだけです。
まだまだmercuryについて把握できてないことも多いのですが、触っていて他のMV*的なライブラリとは違う感覚があって面白かったので中心となる仕組みについて紹介します。
Virtual DOM
MercuryではViewのレンダリングにVirtual DOMを使用することができます。Virtual DOMの実装ライブラリとしてMatt-Esch/virtual-domを使っています。
Virtual DOMは名前の通り仮想的なDOMを扱うライブラリで、何が良いかというとDOMの差分更新が効率的にできるという点があります。 本物のDOMを操作しながら高速な差分更新を実現するのは、現在のレンダリングの仕組み上かなり難しいです。
追記: 一応、補足しておきますがShadow DOMとは何も関係ありません。
- Blazing Fast HTML
- 【翻訳】爆速HTML – Elmでの仮想DOM | POSTD
- mercuryをベースとしている実装で、Virtual DOM仕組みついて等書かれています。
- javascript - Why is React's concept of Virtual DOM said to be more performant than dirty model checking? - Stack Overflow
- Matt-Esch/virtual-domの作者さんが回答してますね
Virtual DOMを持つライブラリといえば、FacebookのReactが有名ですね。 (*Virtual DOMというのは概念的なものなので、APIや実装に標準的なものがあるというわけではありません。なので相互に使えるとかではない)
実際にどのようにDOMを組み立てるかを見ていきます。
Matt-Esch/virtual-domを使って以下のようなHTMLを作りたい場合、それに対応するVirtual DOMをコードで組み立てる必要があります。
<div class="greeting">Hello Bob!</div>
この場合は、以下のようなDSLを使ってVirtual DOMを組み立てる感じですね。 (mercuryではこれをもう少し簡単にするRaynos/virtual-hyperscriptというのを持っています)
new VNode('div', {
className: "greeting"
}, [
new VText("Hello" + name + "!");
]);
これによって組み立ててたVirtual DOMは前回のstateとの差分をみてから本物のDOMを組み立てるので、実際のDOMの変更が最小になるためレンダリングのコストがかなり削減出来ます。
ReactではこのようなDSLの代わりにJSXというXMLシンタックスでHTMLを直接書いて、それをコードに変換するコンパイラを持つことで面倒なDSLでの組み立てをなくしています。
Matt-Esch/virtual-dom はmercuryが使っているだけであるので、単独で使うことも出来ます。これをベースにしたテンプレートエンジンとかあると結構面白いのかもしれません。
State
先ほどのVirtual DOMはViewのレイヤーでしたね。
mercuryでのモデルのレイヤーは先程もちょろと出てきましたが、State modulesと言われるもので表現されています。
Stateはいわゆる監視できる値と考えればいいので、文字列や数値、オブジェクトを包んだラッパーオブジェクトみたいなものになっています。
このStateを作る or 更新は、Knockout等知られるようなラッパーオブジェクト経由で更新メソッドを叩くタイプの手法ですね。
プリミティブな値はRaynos/observ、オブジェクトはRaynos/observ-struct、配列はRaynos/observ-arrayとよくある感じのStateが用意されています。
observ等のサンプルをみてもらうとわかりますが、observは関数を返す関数となっていることがわかると思います。
var Observable = require("observ")
var v = Observable("initial value")
v(function onchange(newValue) {
assert.equal(newValue, "new value")
})
v.set("new value")
モジュールを組み合わせたり、immutableなデータを扱ったり、関数型的なスタイルが色々な場面でみられるのもこのmercuryの特徴だと思います。
JavaScriptで関数型プログラミングはO&Reilly Japan - JavaScriptで学ぶ関数型プログラミングが面白いのでオススメです。
Controller
コントローラーはイベントを受け取って処理したりやStateを更新する役割です。
mercuryでは簡易EventEmitter的なイベントのデータ構造を提供するRaynos/geval、click
やkeydown
等のDOMイベントを管理するRaynos/dom-delegatorやRaynos/value-event等が用意されています。
Raynos/gevalはEventEmitterと違って1つのイベントを表すものですが、 他にも幾つか違いがあります。(というか用途が違う気がする)
EventEmitterはイベント名に文字列ベースで処理したいイベント名を設定していきます。
var EventEmitter = require('events').EventEmitter
var stream = new EventEmitter()
stream.on('data', onData)
stream.on('end', onEnd)
stream.on('close', onClose)
これに対して、gevalでは高階関数(関数を受け取る関数 | 関数を返す関数)となっているため、以下のように文字列によるキーの指定がなくなったり、それぞれのイベントが分離されています。
var Event = require('geval')
var stream = {
ondata: Event(function () { ... }),
onend: Event(function () { ... }),
onclose: Event(function () { ... })
}
stream.ondata(onData)
stream.onend(onEnd)
stream.onclose(onClose)
使い方自体はStateで紹介していたRaynos/observとかと似ていますが、geval
は状態(state)を持ってないですね。
この辺のモジュールもmercuryと分離されているため、単独で使うこともできるので面白いかもしれません。
メインループ
ここまで見ると、mercuryでデータバインディングをやる方法が何となく見えてきたかもしれません。
- View = Virtual DOMを組み立てる
- Virtual DOMはStateを受け取る
- Model = State
- Constroller = DOMイベントの管理
つまり、Stateが変更されたらViewにStateを渡す仕組みがあれば一方向なデータバインディングができますね。
これをどのようにおこなうかというとRaynos/main-loopというモジュールを使うと行えます。
stateとrender関数(Virtual DOMを組み立てる関数)を受け取って、その結果できたDOMオブジェクトを取得したり、stateを渡して更新することが出来ます。
var mainLoop = require("main-loop")
var loop = mainLoop(initState, render)
// stateによる作ったDOMオブジェクトがloop.targetに入る
// それを一度つっこむ
document.body.appendChild(loop.target)
// Stateで更新するとloop.targetも更新される
loop.update({
fruits: ["apple", "banana", "cherry"],
name: "Steve"
})
上記の例では手動でappendChildしてますが、そこを含めた小さなラッパがmarcury.app
というメソッドで定義されているので、
document.bodyにrenderの結果を追加して、stateが変化したらレンダリングも更新される
というよくある感じのは以下のように書くことが出来ます。
mercury.app(document.body, state, render)
Raynos/main-loopは別に必須ではなく、stateとviewを組み立てる関数(redner)を組み合わせて上手く回すことができるモジュールというだけなので、ここを別の実装で行うことができるのも面白い。
まとめ
Virtual DOM、State、Event、main-loop等について書きましたが、色々と面倒な構造に見えるかもしれません。
しかし、考えてみるとmercuryではいわゆるデータバインディング的な事をするのに本物のDOMが直接は絡んで来ないのもあって、 殆どのエラーがJavaScriptに落ちてきたり、テストが書きやすかったりします。 (何が言いたいのかよく分からなくなってますが、ブラウザ内部処理のような見えにくい処理が少ない事やモジュール化によって見通しがいいですねという話)
Vue.jsやAngularやKnockoutなどのHTMLの属性にそのまま宣言的に書いていくタイプは便利なのですが、バインディングが失敗した時や記述を間違えた時のエラーがおきると、何が原因なのかがものすごく分かりにくいという問題があります。(DOM APIがまざるとさらに…)
この辺のDOMとJSの粗密なのはどっちがいいか(宣言的と命令的とか)という問題は色々あると思いますが、mercuryは意外と動いてて面白いという感想です。
また、それぞれのモジュールに直接的な依存があるケースの方が少ないので、他のモジュールと組み合わせたり等がし易い作りになってると思います。
ただ、JavaScriptではメインストリームの手法という感じではないと思うので、習熟度の差とかうんぬんとかいう話は別の誰かが書いてくれるといいですね。(mercuryで登場するモジュールは全体的に小さいのでソースコードそのまま読んでも理解できると思います)
最近の自分のテーマとしてはデータバインディングでどうやったら分かりやすいエラーを表示できるかなので、その点でVirtual DOMや関数型プログラミングのアプローチを使うmercuryは興味深いのでもう少し試してみたいと思います。
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。