最新の Om Next はまだ alpha 版ですが、公式ドキュメントも充実してきたので少し触ってみようと思います。
Om Next とは?
まず、Om についてですが、Om は React の ClojureScript ラッパーです。ClojureScript の開発者でもある David Nolen を中心に開発しています。David は Twitter や Clojure の Slack でよく発言しており、いつ開発しているのか不思議ですが、ClojureScript, Om 共に細かくバージョンが上がっていきます。
さて、Om Next ですが上記でも触れたように現状はalpha版(1.0.0-alpha25)です。現行の Om との違いはいろいろとあるのですが、一番の違いは Cursors をなくしたことでしょうか。Cursors はアプリケーション状態と Component 間の連携を担うのですが、Component とアプリケーション状態の管理が密に連携してしまう問題がありました。
Om Next では、 reconciler という仕組みを用意し、Component とアプリケーション状態管理の分離を計っているようです。
TODOリストを作る
クライアントサイドで完結する簡単なTODOリストを作成しながら、Om Next の使い方を確認していきたいと思います。
Clojure そのものや環境構築については説明しないので、想定読者は 既に Clojure を利用してる人 になります。ゴメンナサイ。
仕様
作成するTODOリストの仕様は以下となります。
- TODO を表示することができる
- TODO を追加することができる
- TODO を完了/取消しすることができる
- TODO を削除することができる
最終的に完成したコードは github に置いてあり、以下の方法で動作を確認することができます。
$ git clone https://github.com/snufkon/om-next-todolist
$ cd om-next-todolist
$ lein run -m clojure.main script/figwheel.clj
コンパイルが完了後に、http://localhost:3449
にアクセス。
プロジェクト作成
github に置いたリポジトリに tag を作成してあるのでそれを利用してください。
$ git clone https://github.com/snufkon/om-next-todolist
$ cd om-next-todolist
$ git checkout -b step0 step0
構成としては、Quick Start(om.next) のSetting Up
, Markup
, Checkpoint
で行っているのと同様の構成にしてあります。
$ rlwrap lein run -m clojure.main script/figwheel.clj
を実行すると ClojureScript のコンパイル完了後、figwheel の repl が立ち上がります。
repl が起動したら http://localhost:3449
にアクセスしてみてください。
特に問題がなければ、ブラウザ(Chrome推奨)のコンソールにHello world!
が出力されると思います。
以下、TODOリストを作成するためのコードはsrc/om_next_todolist/core.cljs
のみを編集していくことになります。
TODOリストを表示
最初に、ハードコーディングされたTODOリストを表示してみたいと思います。
こちらも tag を作成してあるので
$ git checkout -b step1 step1
で先に動作確認ができます。
figwheel が起動したままの状態であれば、チェックアウトした際、ブラウザにTODOリストが表示されると思います。
起動していない場合はもう一度起動し、http://localhost:3449
にアクセスしてみてください。
TODOリスト表示に必要なコードは以下となります。
;; src/om_next_todolist/core.cljs
(ns om-next-todolist.core
(:require [goog.dom :as gdom]
[om.next :as om :refer-macros [defui]]
[om.dom :as dom]))
(enable-console-print!)
;; (1)
(def app-state
(atom {:todos [{:id 1 :title "豚肉を買ってくる"}
{:id 2 :title "たまねぎを買ってくる"}
{:id 3 :title "にんじんを買ってくる"}
{:id 4 :title "じゃがいもを買ってくる"}
{:id 5 :title "カレーを作る"}]}))
;; (2)
(defui TodoItem
Object
(render [this] ;; (3)
(let [props (om/props this) ;; (4)
title (:title props)]
(dom/li nil title))))
;; (5)
(def todo-item (om/factory TodoItem))
(defui TodoList ;; (6)
Object
(render [this]
(let [props (om/props this)
todos (:todos props)]
(apply dom/ul nil
(map todo-item todos)))))
;; (7)
(def reconciler
(om/reconciler {:state app-state}))
;; (8)
(om/add-root! reconciler TodoList (gdom/getElement "app"))
reconciler
Om Next では reconciler(平和をもたらそうとする人?) が アプリケーション状態の管理 を行います。
Component で発生するアプリケーション状態の読込み(read)、変更(mutate)は reconciler にリクエストすることで実施されます。
Om Next ではこの reconciler を使い Component から状態管理に関連するロジックを分離したコードを書いていくことになります。
コード解説
要点のみ解説していきます。
(1)
アプリケーションの状態を定義します。普通、TODOリストの初期状態は空ですが、空だとわかりにくいので初期状態でいくつかTODO項目を設定しています。
atom で定義しないと後で説明する reconciler が Normalize されていないデータと判断して Normalize をかけるので注意してください。
Normalization については今回は不要のため説明しません。興味がある方はComponents, Identity & Normalizationに記載されているのでそちらを参照してください。
(2)
Om Next では defui
マクロで Component を定義します。ここでは1つのTODOを表現する TodoItem コンポーネントを定義しています。
(3)
render メソッドでは1つの Component を返す必要があります。ここではTODOのタイトルを表示する<li>
Component を返しています。
(4)
props
関数を利用しアプリケーションの状態を取得します。ここではタイトルを取得しています。
(5)
定義した TodoItem を TodoList で利用するため、factory
関数を利用し TodoItem コンポーネントの Element を作成します。
(6)
TodoList 全体の表示を行うコンポーネントを定義します。(5)で作成した todo-item
を利用し個別のTODOの表示を行っています。
(7)
reconciler
関数でアプリケーション状態を管理する reconciler を作成します。
(8)
add-root!
関数で、実際にDOMの描画を行います。第一引数に reconciler、第二引数にルートとなるComponent、第三引数にターゲットとなるDOMノードを指定します。これにより resources/public/index.html
の <div id="app"></div>
に TodoList が表示されます。
TODO項目を追加
TODOリストの上部に入力欄を配置し、テキスト入力後、Enterキーを押すことでTODO項目を追加できるようにします。
$ git checkout -b step2 step2
で動作確認できます。
新規に追加したコード、変更があったコードは以下となります。
;; src/om_next_todolist/core.cljs
;; -----------------------------------------------------------------------------
;; Parsing
(defmulti read om/dispatch) ;; (1)
(defmethod read :todos ;; (2)
[env key params]
(let [state (:state env)]
{:value (:todos @state)}))
(defmulti mutate om/dispatch) ;; (3)
(defn- gen-id
[todos]
(->> (map :id todos)
(apply max)
inc))
(defmethod mutate 'todos/add ;; (4)
[env key params]
(let [state (:state env)
id (gen-id (:todos @state))
new-todo (assoc params :id id)]
{:action
(fn []
(swap! state update :todos conj new-todo))}))
;; -----------------------------------------------------------------------------
;; Components
(defui TodoItem
static om/IQuery ;; (5)
(query [this]
[:id :title])
Object
(render [this]
(let [props (om/props this)
title (:title props)]
(dom/li nil title))))
(defn- handle-key-down
[component e]
(when (= (.-keyCode e) ENTER_KEY)
(let [new-field (.-target e)
title (.-value new-field)]
(om/transact! component `[(todos/add ~{:title title})]) ;; (6)
(set! (.-value new-field) ""))))
(defui TodoList
static om/IQuery
(query [this]
(let [subquery (om/get-query TodoItem)] ;; (7)
[{:todos subquery}]))
Object
(render [this]
(let [props (om/props this)
todos (:todos props)]
(dom/div nil
(dom/input #js {:className "new-todo"
:placeholder "What needs to be done?"
:onKeyDown #(handle-key-down this %)}) ;; (8)
(apply dom/ul nil
(map todo-item todos))))))
(def reconciler
(om/reconciler {:state app-state
:parser (om/parser {:read read :mutate mutate})})) ;; (9)
Parsing
TODO項目を追加するためには、 Parsing について理解しておく必要があります。
Parsing 処理は parser に設定した read 関数群、 mutate 関数群により実行されます。parser は query expression(後述) を受け取り、受け取った query expression に対応する read 関数 もしくは mutate 関数の呼び出しを行います。この parser を reconciler に設定することで、 Component から要求があったアプリケーション状態の 読込み(read)、 変更(mutate) を行います。
Read 関数
1つの read 関数は Component から要求のあったアプリケーション状態を返します(返すように作成)します。
read 関数には[env key params]
が引数として渡されてきます。
- env: hash マップ。read 処理を行うために必要なコンテキスト(アプリケーション状態等)が格納されています。
- key: keyword。読込み対象のアプリケーション状態のキーが格納されています。
- params: hash マップ。 read 処理をカスタマイズする際に必要なパラメータが格納されています。
read 関数は Component に返す値を:value
キーに格納したマップを返す必要があります。
Mutation 関数
1つの mutate 関数は Component から要求のあったアプリケーション状態の変更を実行(実行するように作成)します。
read 関数同様、[env key params]
が引数として渡されてきます。
- env: hash マップ。mutate 処理を行うために必要なコンテキスト(アプリケーション状態等)が格納されています。
- key: keyword。アプリケーション状態の変更処理に対応するキーが格納されています。
- params: hash マップ。 mutate 処理をカスタマイズする際に必要なパラメータが格納されています。
mutate 関数は :action
キーにアプリケーション状態の変更を行う関数を設定したマップを返す必要があります。
Query Expression
Om Next の query expression は vector による表現です。 Datomic の Pull Syntax にインスパイアされているようです。
定義した app-state
から :todos
を取得する場合、 query expression は以下のようになります。
[{:todos [:id :title]}]
また、:todos
に TODO項目を追加する query expression は以下のようになります。
[(todos/add {:title "カレーを食べる"})]
コード解説
(1), (3)
dispatch
はread, mutateのマルチメソッド定義に利用できるヘルパー関数です。key
による dispatch を行います。
(2)
アプリケーション状態のtodos
に対する読み込みリクエストがあった際の読み込み処理を記述します。
env
に格納されている:state
からアプリケーションを状態を取得し、:todos
の値を返しています。
(4)
Component からのリクエストを受け、TODO項目を追加する処理を記述します。read 同様 env
に格納されている:state
からアプリケーション状態を取得し利用しています。
追加するnew-todo
を作成し、:action
キーに指定した関数で変更処理を行います。
(5)
IQuery
は query を宣言するためのプロトコルで Component で read したいデータを指定するために利用します。
query expression を返す必要があります。ここでは TodoItem コンポーネントで利用する id
, title
を指定しています。
(6)
transact!
関数により、reconciler にアプリケーション状態の変更を依頼します。 transact!
の第二引数に指定した [(todos/add ~{:title title})]
に対応する mutate 関数(4) が params に {:title title}
が渡された状態で実行されます。
(7)
TodoList コンポーネントで read したいデータを指定します。 get-query
関数で TodoItem に設定した query を取得することができます。 現状だと [:todos [:id :title]]
を指定したことになります。
(8)
キー入力のイベントがあったら、handle-key-down 関数を呼び出します。handle-key-down では Enter キーが押された場合に TODO 項目を追加する処理を記述しています。
(9)
parser
関数で作成した parser を reconciler に設定します。 reconciler が Component からアプリケーション状態に対するリクエスト(読込み,変更)を受けると parser に設定された read, mutate 関数が呼び出されます。
TODO項目を完了/取消
次にチェックボックスのON/OFFで、TODO項目の完了/取消しを表現できるようにします。
$ git checkout -b step3 step3
で動作確認できます。
新規追加したコード、変更があったコードは以下となります。
;; src/om_next_todolist/core.cljs
;; (1)
(def app-state
(atom {:todos [{:id 1 :title "豚肉を買ってくる" :completed true}
{:id 2 :title "たまねぎを買ってくる" :completed true}
{:id 3 :title "にんじんを買ってくる" :completed false}
{:id 4 :title "じゃがいもを買ってくる" :completed false}
{:id 5 :title "カレーを作る" :completed false}]}))
;; -----------------------------------------------------------------------------
;; Parsing
(defn- id->index
[id todos]
(-> (for [[index todo] (map-indexed vector todos)
:when (= id (:id todo))]
index)
first))
;; (2)
(defmethod mutate 'todo/toggle
[env key params]
(let [state (:state env)
id (:id params)
index (id->index id (:todos @state))]
{:action
(fn []
(swap! state update-in [:todos index :completed] not))}))
;; -----------------------------------------------------------------------------
;; Components
(defui TodoItem
static om/IQuery
(query [this]
[:id :title :completed])
Object
(render [this]
(let [{:keys [id title completed]} (om/props this)
class (if completed "completed" "")]
(dom/li nil
;; (3)
(dom/input #js {:type "checkbox"
:className "toggle"
:checked (and completed "checked")
:onChange #(om/transact! this `[(todo/toggle {:id ~id})])}) ;; (4)
(dom/span #js {:className class} title)))))
コード解説
(1)
TODOの完了状態を管理するための:completed
フラグを追加します。
(2)
TODO の完了/取消しによる状態変更を行うための mutate 関数を追加します。
関数内では、params
で渡されたid
を持つTODO項目に対してcompleted
を反転する action を設定しています。
(3)
完了/取消しをトグルするためのチェックボックスを追加します。
(4)
チェックボックスが変更された際、[(todo/toggle {:id ~id})]
に対応する mutate 関数(2)を実行します。
TODO項目を削除
最後にTODO項目を削除できるようにします。
$ git checkout -b step4 step4
で動作確認できます。
新規追加したコード、変更があったコードは以下となります。
;; -----------------------------------------------------------------------------
;; Parsing
(defn- remove-todo
[todos id]
(-> (remove #(= id (:id %)) todos) vec))
;; (1)
(defmethod mutate 'todos/delete
[env key params]
(let [state (:state env)
id (:id params)
todos (remove-todo (:todos @state) id)]
{:action
(fn []
(swap! state assoc :todos todos))}))
;; -----------------------------------------------------------------------------
;; Components
(defui TodoItem
static om/IQuery
(query [this]
[:id :title :completed])
Object
(render [this]
(let [{:keys [id title completed]} (om/props this)
class (if completed "completed" "")]
(dom/li nil
(dom/input #js {:type "checkbox"
:className "toggle"
:checked (and completed "checked")
:onChange #(om/transact! this `[(todo/toggle {:id ~id})])})
(dom/span #js {:className class} title)
;; (2)
(dom/span #js {:className "delete"
:onClick #(om/transact! this `[(todos/delete {:id ~id}) :todos])}
"[x]")))))
コード解説
(1)
params
で指定されたid
のTODO項目を削除する mutate 関数を追加します。
(2)
クリックでTODO項目を削除するためのspanタグを追加します。
transact!
関数の第二引数 [(todos/delete {:id ~id}) :todos]
に設定しているtodos
はTODO削除の mutate 完了後、TodoList コンポーネントを再描画(renderを実行)させるために設定しています。
おわりに
TODOリストを作成することで Om Next でのコードの書き方を確認してみました。クライアントサイドのみ、なおかつコードも100行程度の小さなプログラムのため、Om Next の良さが伝わりにくかったかなと思います。逆にこの程度のプログラムでは冗長に感じてしまうかもしれません。
Om Next でコードを書いてみて1つ気づいた点としては、core.async が登場しなかったことです。Om だと子から親の Component へのコミュニケーションを取る際に core.async を利用する必要がでてきますが、Om Next だと reconciler による状態管理がこのあたりをうまくハンドリングしてくれるようです。
Om Next はまだ alpha 版ですが、コンセプト自体は固まっていると思います。詳しく勉強したい方は、参考資料にあげた公式ドキュメントに目を通したり、Clojurians Slack にある om の channel をウォッチすると良いと思います。また、Facebook の Relay、Netflix の Falcor のアイディアを参考にしているのでこれらのドキュメントも Om Next の理解に役立つと思います。
勉強不足なため、認識が間違っている点等がありましたら、コメントで教えていただけると助かります。