JavaScript
VirtualDom
Maquette

5分でわかる、シンプルすぎる仮想DOMライブラリMaquetteの紹介

maquette.png

仮想DOMなUIライブラリMaquette(マケット)を紹介します。JavaScriptで仮想DOMといえばReact/Redux一択みたいな雰囲気ですが、あれはいろいろ複雑すぎ巨大すぎであんまり好きじゃないので、JavaScriptから手軽に使えるUIライブラリを探していました。ご存知のように仮想DOM実装は雨後の筍のように登場していて、要求を満たすライブラリは複数あったのですが、公式サイトのチュートリアルが可愛かったのでこれにしました。公式サイトはこっちです。

MaquetteはReactをはじめMithrilMercuryに影響を受けているそうです。でもMaquetteはこれらのライブラリよりもさらに機能が絞られたシンプルなもので、習得しやすくて軽いところが好きです。また、状態の扱いには一切関知しない、UIの描画だけが機能のライブラリなので、React/Reduxのようなややこしい規約はありません。また、AngularやVueのような専用のテンプレート構文を覚える必要もなく、JSXやWebpackのようなプリプロセスとも無縁です。

なお、Maquetteとはフランス語で『模型』を意味するんだとか。開発しているのはAFAS Softwareというオランダの会社のようです。ちなみにライセンスは、安心と信頼のMITライセンスです。


Maquetteの導入

インストールはCDNからが一番ラクでしょう。"Only weighs 3Kb gzipped"だそうです。軽いですね。"19kb min+gzip コンパクトなランタイム。"とか掲げてるVueが冗談みたいです。

<script src="//https://cdnjs.cloudflare.com/ajax/libs/maquette/2.5.3/maquette.min.js"></script>

これでグローバルにmaquetteオブジェクトが使えるようになります。


ページの描画

まずは、描画関数で便利なように、maquette.h関数を変数に束縛しておきましょう。

const h = maquette.h;

Maquetteでは、ReactのJSXやVue、Angularのような独自のテンプレートのようなものは使いません。ここで定義したh関数を使って、ただのJavaScriptコードとして仮想DOMノードを定義していきます。Maquetteのh関数はHyperScripthに相当するもので、

h("要素名#ID.クラス名", { 属性名:  }, 子のノード)

というような形式で呼び出すと、仮想DOMのノードを生成します。この関数の詳細については割愛しますので、APIドキュメントを参照してください。これを使って、例えば次のように描画関数を定義します。

function render(){

return h("body", ["Hello, World"]);
}

h("body", ["Hello, World"])はHTMLでいう<body>"Hello, World"</body>に相当する表現です。

あとは、maquette.createProjectorProjectorオブジェクトを作成します。これは仮想DOMから実際のDOMを構築するのに使われるオブジェクトです。あとはそのProjectormergeメソッドを呼んでHTMLの要素や先ほど定義した描画関数renderとを結びつけると、その要素に対してDOM描画のサイクルが動き出します。

const projector = maquette.createProjector();

projector.merge(document.body, render);

基本的にはこれだけです。シンプルですね。完全なコードはJSFiddleに置いておきました。


アプリケーションの状態

Reactだと、getInitialStateで初期状態を取得し、setStateで状態を更新するとかそういう状態管理の仕組みがありますが、Maquetteにそのような状態を扱うAPIはありません。なので、適当にstateとかそういう名前の変数を定義して、描画関数からそれを参照すればいいだけです。

var state = new Date();

function render(){
return h("body", [state.toISOString()]);
}

状態オブジェクトはJSONライクなオブジェクトにするべきとか、状態変更のときに状態オブジェクトを直接変更してはダメで必ずsetStateを通さなくてはならないとか、そういった規約はありません。シンプル・イズ・ベストです。


イベントハンドリングと状態の変更

イベントハンドラの書きかたはHyperScriptと同様で、h関数の引数に与えるプロパティのテーブルに、イベントに対応する属性としてイベントハンドラを与えればいいだけです。たとえばマウスクリックなら、その要素の仮想DOMノードの属性にonclickを次のように追加するだけです。

var state = false;

function onButtonClick(e){
state = ! state;
}

function render(){
return h("body", [
h("p", state),
h("button", { onclick: onButtonClick }, "Toggle")
]);
}

また、イベントハンドラでアプリケーションの状態を変更したいときは、単に状態を格納している変数の中身を書き換えるだけです。

state = ! state;

なんかもう説明する必要があるのかどうかもわからないほど単純なことですが、Reactのようなライブラリではいろいろと制約がやかましいので、Maquetteではそういったお約束は不要だということを強調する意味で説明を付け加えました。Reduxのようにアクション作れ純粋にしろなどといった規約も特にないので、あとは好きに書けばいいと思います。


ページの再描画

ReactでsetStateで状態を更新した時に再描画が行われますが、Maquetteではそういった仕組みはありません。ではどのタイミングで再描画が行われるかというと、イベントハンドラの実行が完了したら自動的にDOMが再描画されます。イベントが起きたからといって状態が変更されているとは限らないわけで、本当に再描画が必要なのかもわからなくてもイベントが起きたらとりあえず再描画しておく、というのはいかにも乱暴ですが、そういう雑なことをしてもちゃんと動くから便利なのが仮想DOMという技術です。

ただし、マウスクリックなどの仮想DOMノードに関連付けられるイベントでは自動的に再描画されるのですが、setTimeoutのような非同期な状態変化はMaquetteがそれを検知できないので、自動的には再描画されません。非同期処理に応じてUIを更新する場合は、次のように明示的にProjector#scheduleRenderメソッドを呼び出して再描画を指示する必要があります。

async function onButtonClick(e){

state = "Fetching...";
projector.scheduleRender();
const res = await fetch("https://api.github.com/users");
users = await res.json();
state = "Complete.";
projector.scheduleRender();
}

ここでは安直にも一番手軽でわかりやすいasync/awaitで非同期処理を書きましたが、もちろんPromiseでグダグダに書こうが、伝統的コールバック地獄でコテコテに書こうが、Redux/Sagaとかでヘロヘロに書こうが、好きな方法を選べます。projector.scheduleRenderを呼べば再描画されるというだけの話なので、どんな方法とでも組み合わせることができます。非同期処理に伴うアプリケーションの状態変化も、単に変数を書き換えるだけで十分です。


コンポーネント

Maquetteはフレームワークではないので、コンポーネントについての規約などは特にないそうです。というわけで好きに書いていいのですが、ひとつの方法としては、renderのような共通の名前のプロパティで描画関数を持つオブジェクトを作ればいいでしょう。

function createCounter(){

function onButtonClick(e){
count += 1;
}
var count = 0;
return {
render: function(){
return h("div", { key: this }, [
h("button", { onclick: onButtonClick }, "Increment"),
h("span", count)
]);
}
};
}

あとは、そのコンポーネントオブジェクトをそれぞれ作成し、描画関数内でそのコンポーネントのrender関数を呼び出して描画します。これで、それぞれのコンポーネントの実体を独立して描画し操作できます。

const comp1 = createCounter();

const comp2 = createCounter();
const comp3 = createCounter();

function render(){
return h("body", [
comp1.render(),
comp2.render(),
comp3.render()
]);
}


3つの注意事項

主に効率上の都合で、以下の3つの制約が設けられています(3つのルール)。以下のルールのうち、(2)は違反していていても実行時エラーが出ず、期待通りの動作にならないので、特に注意が必要です。(1)と(3)は違反すると実行時エラーになるので、エラーの意味がわかるように頭に入れておきましょう。


(1) 再描画の時にイベントハンドラを変更してはいけない

イベントハンドラとなる関数の定義は、先ほどのサンプルコードのように描画関数の外に置くようにします。イベントハンドラを次のように描画関数の中で定義すると、再描画のたびにイベントハンドラとなる関数オブジェクトが新たに作成されてしまいますが、これは効率を悪化させるのでエラーが出るようになっています。

// これはダメ!

h("button", { onclick: e => state = ! state }, "Toggle")

なお、この制限のため、連続する要素に操作を行なったときなどの判定がやりにくくなります。たとえば、次のように配列にボタンの名前が格納されていて、それぞれに対応するボタンを描画し、ボタンが押されたらそのボタンの名前を出力したいとしましょう。何も考えずに次のようにクロージャを通じて配列の要素を参照すると、描画のたびに関数オブジェクトが作成されることになるため、やはりこれもエラーになります。

// これもダメ

["a", "b", "c"].map(name => h("button", { onclick: e => console.log(`${name}ボタンが押されました`) }, name))

しかし、イベントハンドラの関数オブジェクトの定義を外に切り出すと、今度はクロージャを通じてボタンの名前nameを参照できなくなってしまいます。このようなときは、属性のオブジェクトのbindプロパティを利用します。

["a", "b", "c"].map(name => h("button", { bind: name, onclick: onButtonClick }, name))

このようにすると、イベントハンドラのonButtonClick関数で、bindに渡したデータをthisとして受け取ることができるので、イベントハンドラの関数オブジェクトを共有していても、押されたボタンによって動作を変えることができます。

function onButtonClick(e){

console.log(`${this}ボタンが押されました`);
}

ここは少し迷いやすいところかと思うので補足しておきました。


(2) プロパティのリストの構成を変えてはいけない

同じ仮想DOMノードに対して、再描画のたびにプロパティのテーブルの構成を変えてはいけません。たとえば、HTMLのdisabled属性は状態に応じてつけたり外したりすることがありますが、あるときのプロパティは{}、別の状態では{ disabled: "" }というように、状態に合わせてプロパティを増やしたり減らしたりはできません。あるときのプロパティは{ disabled: true }、別の状態では{ disabled: false }というように、プロパティの構成は同じままで、値のみが変わっていくように書きます。

具体的には、次のようにしてもうまくdisabled属性が切り替わりません。ずっとdisable属性がついたままになります。

state ? h("input", {}) : h("input", { disabled: "" }),

これは、再描画時にプロパティのテーブルとして{}を渡しても、既存のdisabled属性を検出してそれを削除したりはしないからです。プロパティの値としてfalseを渡すとその属性が無いことになるので、次のようにするといいようです。

h("input", { disabled: state })


(3) 複数の子が含まれる場合、互いに区別できるようにしなければならない

ある要素に複数の子のノードが含まれる場合、IDやクラス、あるいはキーで、それらが互いに区別できるようになっていなければなりません。たとえば、次のように単にli要素を並べると、最初の描画では成功したように見えますが、再描画したときにエラーになります。

h("ul",  new Array(state).fill(0).map((e, i) => h("li", i)))

これを避けるには、keyプロパティに一意な値を設定します。この場合は、インデックスの値を設定すればいいでしょう。

h("ul",  new Array(state).fill(0).map((e, i) => h("li", { key: i }, i)))


さいごに

Maquetteは最小限ながら必要なものは揃っているという感じで、学習コストも小さくて比較的扱いやすいライブラリだと思います。あとは、ブラウザ上でステージクリア形式で遊べるチュートリアルをやると楽しいんじゃないでしょうか。

HyperScript形式でのノードの定義は、慣れるまでは読みづらいと感じるかもしれません。でも、JSXなんて見かけだけHTMLに似せているだけで、別に便利な構文でもなんでもありません。独自テンプレートなんて、見た目がHTMLに似ているというだけで、学習コストが増えるし、ビルドのプロセスが複雑になるし、ソースコードと実行中のコードが異なってしまうし、もともとJavaScriptが持っている機能が制限されるしで、嬉しくもなんともないです。あれはHTMLに慣れきった体の生理的違和感を和らげるためだけのものであって、純粋に機能としてみれば、機能の強化どころか弱体化です。MquetteやMercuryのように、ただのJavaScriptだけで書いていくのが一番シンプルかつ機能的だと思います。

筆者は関数型プログラミング言語、特に純粋関数型な言語が好きなので、本気で書くとき&個人的に作るときはPureScriptなんかを使いたいです。でもどうしてもJavaScriptのような言語を直接使わなくてはならないときもありますし、そういう場合は関数型らしい概念を積極的に持ち込もうとは思わないです。ReduxはElm Architectureを参考にして作られていますが、あれは純粋関数型言語であるElmだから威力を発揮するアーキテクチャであって、JavaScriptに持ち込んでも辛いだけであまり旨味はないと思います。普通に書けば自動的に純粋になるElmに比べると、純粋でないJavaScriptでコードを純粋に保つのははるかに大変ですし、ホットスワッピングやタイムトラベリングを使わなければあんまりメリットにもなりません。普通にJavaScriptっぽいコードを書くのが一番です。

そう考えると、最小限の機能のみで構成されていて余計な制約や哲学を持ち込まないMaquetteは、あくまでJavaScriptのライブラリとしての立場を堅実に保った、合理的で地に足の着いたライブラリではないかと感じるところです。


参考文献