Web開発のクライアントサイドのフレームワークについては、これまで様々なものが流行っては廃れたりして、なかなか落ち着かない感じではありますが、今回ネタにとりあげる om
(https://github.com/swannodette/om)、どこまで盛り上がるのか興味深いところです。
om とは
端的に言えば「Facebook の Client Side Framework であるReactの ClojureScript による wrapper」になります。
React は、MVC で言うところの View レイヤーという位置づけのFrameworkで、「通常の DOM より軽量な Virtual DOM を操作、Virtual DOM の差分を DOM に反映(パッチ当て)することで 高速なViewの更新を実現」しています。
Virtual DOM という考え方がどのくらい昔からあるかはよく知らないのですが、今年に入ってから頻繁に耳にする様になりました(React の影響でしょうか...)。
当然のように ClojureScript による wrapper が登場するわけですが、今のところ(というか自分が知っているだけですが) 2種類の Framework が存在します。
Reagent
Reagent は、Virtual DOM の表現のために hiccup
形式の構文を標準装備しており、Clojure に慣れたプログラマには取っ付き易いように見えます。一時期開発が停滞しているのかな、と思っていましたが、Reagent 本体よりもその周辺(leiningen template とか tutorial) が充実してきており、馴染みやすさからすると Reagent から始めても良かったかもしれません(ちょっと後悔...)。
- Reagent Cookbook - いわゆるサンプル集、こういうのがあるととっかかりやすい印象を受けます。
-
Reagent Template - reagentを簡単に使える
leiningen template
。 -
Reagent Seed - こちらも
leiningen template
ですが、以下のものがごった煮で付いてきます。
om
om は、Virtual DOM生成部分は他のライブラリに任せるスタンスです。 コンポーネント を作成するために組み合わせて使えるような機能をコンパクトにまとめた、といった感じでしょうか。Virtual DOM生成機能は当然標準でも用意されていますが、hiccup のような使いやすい抽象化されたものではないので、やはり他の Framework を組み合わせて使うのが本筋なようです。知っている範囲では、
が組み合わせて使えるとされています。
Reagent と om、どちらが主流だとか優位性があるとかいう話ではないと思いますが、今回は om について try してみました。
om 入門
最速で Hello, world!
さて、ここからが本番です。leiningen
は導入済みであるとします(なるべく最新が良いと思います。
手っ取り早くom
のアプリケーションを作成するために、とてもシンプルな leiningen
テンプレートが提供されています(mies-om
)。
早速使ってみます。
bash$ lein new mies-om mies-om-example
Retrieving mies-om/lein-template/0.4.1/lein-template-0.4.1.pom from clojars
Retrieving mies-om/lein-template/0.4.1/lein-template-0.4.1.jar from clojars
bash$ cd mies-om-example/
bash$ lein deps
bash$ lein cljsbuild once
Compiling ClojureScript.
Compiling "mies_om_example.js" from ["src"]...
Successfully compiled "mies_om_example.js" in 9.231 seconds.
bash$
ここで mies-om-example
ディレクトリ直下にある index.html
をブラウザで開くと、以下のように表示されるはずです。
project.clj
には、cljs を開発用にコンパイルするための最小限の設定がされております(解説は省略)。まずは index.html
から。まさに最小限のコードですね。
<html>
<body>
<div id="app"></div>
<script src="http://fb.me/react-0.11.1.js"></script>
<script src="out/goog/base.js" type="text/javascript"></script>
<script src="mies_om_example.js" type="text/javascript"></script>
<script type="text/javascript">goog.require("mies_om_example.core");</script>
</body>
</html>
上記<div id="app"></div>
のところに、次に示すClojureScript
により(Virtualではない)DOMが生成され埋め込まれます。
でそのClojureScript
がこちらです(omのBasic Tutorialの最初のコードと同じです)。
;; src/mies_om_example/core.cljs
(ns mies-om-example.core
(:require [om.core :as om :include-macros true]
[om.dom :as dom :include-macros true]))
(enable-console-print!)
(def app-state (atom {:text "Hello world!"}))
(om/root
(fn [app owner]
(reify om/IRender
(render [_]
(dom/h1 nil (:text app)))))
app-state
{:target (. js/document (getElementById "app"))})
om では、アプリケーションの状態を atom
として管理します(app-state
)。atom
の中身はClojureScriptで扱えるimmutableなデータであれば何でも良いです。この例では:text
をキーにもつmap
になっています。
om/root
は、ざっくり言うと「アプリケーションの状態をみながら React 経由での描画(というかDOM構築・パッチ当て)を行う」イメージです。om/root
は、以下の引数を取ります。
(defn root
([f value options] ...))
-
f :
IRender
、IRenderState
をreify
したインスタンスを返す関数を指定します。IRender
、IRenderState
は om のプロトコルで、Virtual DOM を構築して返すようにします。つまりここが Viewの中核となるところです。 -
value : Virtual DOMに対応する ClojureScript の tree 構造をもつデータ、もしくは
atom
で wrap した tree 構造をもつデータを指定します。通常は動的な View を構成
するためにこのフレームワークを使うので、サンプルでも無い限りatom
でwrapしたデータ構造を指定することになります。 -
options :
om.core/build
関数に渡すオプションのmap
を指定します。:target
が必須でom/root
で構築されたコンポーネントを設定(mount) する先となる JavaScript の element を指定します。target
以外のオプションについては omのドキュメント を参照してください。
これだけでは動きが無いためよくわからないので、少し動きのあるものを作ってみます。
Next Step (contact list)
次に示す例は、omのチュートリアルを少しモディファイしたもの(Contact List)です。まずはイメージから。css framework
は個人的に時々使ってるInkを使っています(レイアウトがガタガタでカッコ悪いです...すみません)。
管理する状態
- 今回はatomに包まれたmapを管理します。
(def app-state
(atom
{:contacts
[{:name "Ben"}
{:name "Alyssa"}
{:name "Eva"}
{:name "Louis"}
{:name "Cy"}
{:name "Lem"}]}))
contact-view 関数 (Contact List の1行を表す View)
-
IRenderState
プロトコルの関数render-state
を実装します。今回はsablonoを使ってhiccup
構文で書いてみました。 -
Delete
ボタンを押下したタイミングでのVirtual DOMの更新時には、cljs.core.async/put!
を使っています。またそのための通知チャネルは後述するメインのviewのIWillMount
プロトコルで実装しています。
(defn contact-view [contact owner]
(reify
om/IRenderState
(render-state [this {:keys [delete-chan]}]
(html
[:li
[:span (:name contact)]
[:button {:onClick (fn [e] (put! delete-chan @contact))
:class "ink-button red"} "Delete"]]))))
contacts-view 関数 (Contacts List 全体の View)
(defn contacts-view [app owner]
(reify
om/IInitState ;; (1)
(init-state [_]
{:delete-chan (chan)
:text ""})
om/IWillMount ;; (2)
(will-mount [_]
(let [delete-chan (om/get-state owner :delete-chan)]
(go (loop []
(let [contact (<! delete-chan)]
(om/transact! app :contacts
(fn [xs] (vec (remove #(= contact %) xs))))
(recur))))))
om/IRenderState ;; (3)
(render-state [this state]
(html
[:div {:class "control-group"}
[:h2 "Contact list"]
(apply dom/ul nil (om/build-all contact-view (:contacts app) ;; (4)
{:init-state state}))
[:div
[:input {:type "text" :ref "new-contact" :value (:text state) ;; (5)
:onChange #(handle-change % owner state) ;; (6)
:placeholder "New Contact List Member"}]
[:button {:onClick #(add-contact app owner) ;; (7)
:class "ink-button blue"
:disabled (empty? (:text state))} "Add contact"]] ;; (8)
[:div (:text state)]])))) ;; (9)
- (1) IInitState : このView内部で使う状態を初期化します。ここでは Contact List に入力する文字列(
:text
)と、Deleteボタン押下時の通知に使うcljs.core.async
のチャネル(:delete-chan
)を設定しています。 - (2) IWillMount : このViewが mount される直前に一度だけ呼ばれるプロトコルです。
cljs.core.async/go
ループはここで実装します。この View では、子供の View であるcontact-view
からの通知が来たら、 状態の更新、つまりDeleteボタンを押下した行のデータを削除(remove
)しています。あくまで状態の更新、であって、DOMそのものは修正しない、ということがポイントです(ただしこの実装だと単純に名前が一致していたら消す、という動きなので、本当はもう少し考慮が必要です)。 - (3) IRenderState : Virtual DOM の構築を行うよう実装します。行の追加や削除等「操作に対する処理」は全く書かず「状態のあるがままを反映する」だけなので、記述は非常に楽になります。
- (4) contacts-view の各行は、
contact-view
関数で定義していたので、ここでは om/build-all 関数を呼び出すことでその内容を展開しています。contacts-view が親、contact-viewが子、になるわけです。 - (5)
:ref "new-contact"
というのは、後述する処理でHTMLinput
要素を参照する必要があるため、参照するための名前として定義してあります。 - (6)
:onChange
はinput
要素のonChange
イベント発火時に呼ばれる関数を定義します。ここは普通に ClojureScript の関数をシームレスに書けるので、コードがすっきりして良いです。 - (7) Add contact ボタン押下時には後述する
add-contact
関数を呼びます。 - (8) テキスト入力欄が空欄の時にはボタンを押下できないようにしてみました(disabled=true)。
- (9) テキスト入力に連動して画面が動いてくれるか、確認するために
:text
の内容を表示してみました。
add-contact 関数
(defn add-contact [app owner]
(let [new-contact (-> (om/get-node owner "new-contact") .-value)] ;; (1)
(when-not (empty? new-contact)
(om/transact! app :contacts #(conj % (assoc {} :name new-contact))) ;; (2)
(om/set-state! owner :text "")))) ;; (3)
- (1) 前述(contacts-view 関数の(5)) で定義した
:ref
をここで使っています。om/get-node
を使って実際のDOM要素にあるvalue
をnew-contact
として取得しています(つまり、管理している状態に反映される前に、画面から直接値を取得する、というわけです)。 - (2) contact-list にデータを追加します。ここでもあくまで 状態の更新 をしているだけであって、画面の更新は一切ここでは行っていないというところがポイントです。
- (3) 追加が終わったらテキスト入力欄をクリアするため、
:text
を空白にしています。
handle-change 関数
(defn handle-change [e owner {:keys [text]}]
(let [value (.. e -target -value)]
(if-not (re-find #"[0-9]" value)
(om/set-state! owner :text value)
(om/set-state! owner :text text))))
- 詳しく説明しませんが、数値は入力できないようにしてみました。
以下実際の動き
- テキスト入力欄に
a
と入力したところ。テキスト入力欄の下にも入力された文字が反映されています。また、一文字目が入力された時点でAdd contact
ボタンが enable になっています。
-
a
を入力後Add contact
ボタンを押下したところ。上のContact List
に行が追加されました。またテキスト入力欄はクリアされ、Add contact
ボタンは再び disable になりました。
-
a
の上の行にあった Lem のDelete
ボタンを押下したところ。Lemの行がなくなりました。
全部まとめたソースを github にあげておきました。git clone
してlein cljsbuild once
し、index.html
を開いて実際に動きを確かめてみていただければ、と思います。
まとめ
今回触れなかったことはいっぱいあります。
- om の用語としての
Cursor
とかLifecycle Protocol
とか.. - sablono のかわりに kioo 使ってみる、とか..
- React から SVG いじるとか普通にできるので、グラフィカルな感じのもやってみたい、とか..
- clientとserverを連携してみる、とか..
- もっと実用的な画面(画面遷移とか含め)でサンプル作ってみたり、とか..
- figwheel使うとClojureScriptの変更が即座にブラウザに反映されるので、開発が超ラクチンになるよ、とか..
- client side routing、とか..
やり残したことはいっぱいあるけど、今後のお楽しみということで、おいおい勉強していきたいと思います。
感想
- ClojureScript いい感じ。ちょっとずつ慣れてきたのでもっと勉強したくなった。
- 来年は React がもっと流行ると思う(Virtual DOM更新だけで良い、のはすごく楽)。
see also
- http://yogthos.net/posts/2014-12-1-State-of-Reagent.html(英語)
- minikomiさんの om 記事#0、#0.5、#1、#2。参考にさせていただきました。
- om についての InfoQの記事。
- Virtual DOM Advent Calendar 2014
- 一人React.js Advent Calendar 2014
-
WebFUI -
ClojureScript
でVirtual DOMと似たようなコンセプトで作られたクライアントサイドフレームワーク。残念ながら開発が止まっているようです。(id:ymbpc さんの記事が非常にわかりやすいです)
以上になります。