以前、Om はじめの一歩, 入門 Om Next といった React の ClojureScript ラッパーの記事を書きました。今回も、TODOリストを題材に Re-frame での基本的な書き方を確認してみたいと思います。
Re-frame とは
ClojureScript には React のラッパーがいくつかあります。前述の Om、Om Next や Reagent、 最近だと Rum なども見かけます。Re-frame はこの React ラッパーの1つである Reagent のフレームワークという位置づけで、Reagent ベースで SPA を作る際の共通となる枠組みを提供します。この提供される枠組みは Redux により提供される枠組みに近く、JavaScript における React+Redux での開発、Swift における ReSwift を使った開発など、他の言語で Redux ライクな開発を経験している人には扱いやすく、導入の敷居が低いフレームワークと言えます。
TODO リストを作る
クライアントサイドで完結するTODOリストを作成し、Re-frame の使い方を確認していきたいと思います。
仕様は入門 Om Nextの時と同じで、以下となります。
仕様
- TODO を表示することができる
- TODO を追加することができる
- TODO を完了/取消しすることができる
- TODO を削除することができる
動作確認方法
最終的に完成したコードは github においてあり、以下の方法で動作確認をすることができます。
$ git clone https://github.com/snufkon/re-frame-todolist
$ cd re-frame-todolist
$ lein figwheel dev
コンパイル完了後、 http://localhost:3449 にアクセス。
また、各ステップの実装完了後の状態で tag を作成してあるので、必要があれば tag をチェックアウトして動作確認してください。
$ git checkout -b step0 step0
Step0: プロジェクト作成
re-frame のテンプレートを使いプロジェクトの雛形を作成します。
$ lein new re-frame re-frame-todolist
作成されるプロジェクトのディレクトリ構成は以下のようになります。
$ tree re-frame-todolist
re-frame-todolist
├── README.md
├── project.clj
├── resources
│ └── public
│ └── index.html
└── src
├── clj
│ └── re_frame_todolist
│ └── core.clj
└── cljs
└── re_frame_todolist
├── config.cljs
├── core.cljs <-- 変更対象
├── db.cljs <-- 変更対象
├── events.cljs <-- 変更対象
├── subs.cljs <-- 変更対象
└── views.cljs <-- 変更対象
特に変わったところはなく、一般的な Clojure/ClojureScript のプロジェクト構成です。今回作成するTODOリストの実装は、クライアントサイドだけで完結するため、clj への変更は行わず、上記で示した5つの cljs ファイルのみに変更を加えていきます。
動作確認
TODOリストを作成する前に、テンプレートで作成されたプロジェクトの動作確認しておきます。
$ lein clean
$ rlwrap lein figwheel dev
特に問題がなければ http://localhost:3449 へのアクセスで、以下のように"Hello from re-frame"といったメッセージが表示されると思います。
各ファイルの役割
編集対象の各ファイルの役割は、主に以下のようになります。
- core.cljs: アプリケーションのエントリポイントを提供。初期化に関するコードを記述
- db.cljs: アプリケーションの初期状態に関するコードを記述
- subs.cljs: View からの問い合わせに答え、アプリケーション状態を加工した結果を返すためのコードを記述
- view.cljs: Reagentコンポーネントを使い View を作成するコードを記述
- events.cljs: View からディスパッチされたイベントを処理するハンドラーのコードを記述
コード確認
"Hello from re-frame" がどのように表示されるかを追いながら、初期状態のコードを確認します。
まず、エントリポイントとなる core.cljs
から見ていきましょう。
core.cljs
(ns re-frame-todolist.core
(:require [reagent.core :as reagent]
[re-frame.core :as re-frame]
[re-frame-todolist.events :as events]
[re-frame-todolist.views :as views]
[re-frame-todolist.config :as config]))
(defn dev-setup []
(when config/debug?
(enable-console-print!)
(println "dev mode")))
(defn mount-root []
(re-frame/clear-subscription-cache!)
(reagent/render [views/main-panel]
(.getElementById js/document "app")))
(defn ^:export init []
(re-frame/dispatch-sync [::events/initialize-db])
(dev-setup)
(mount-root))
init
関数が index.html
から呼ばれるアプリーケーションのエントリポイントとなります。init
関数ではまず、dispatch-sync
関数により、::initialize-db
イベントを発行し、アプリケーション状態の初期化を行っています。実際の初期化処理は、events.cljs
に記載した ::initialize-db
イベントのハンドラーによって行われます。アプリケーション状態の初期化完了後は、render
関数でアプリケーションのルートとなる main-panel
コンポーネントの描画を行っています。
events.cljs
(ns re-frame-todolist.events
(:require [re-frame.core :as re-frame]
[re-frame-todolist.db :as db]))
(re-frame/reg-event-db
::initialize-db
(fn [_ _]
db/default-db))
events.cljs
では reg-event-db
関数を使いイベントハンドラーを登録します。 ::initialize-db
がイベントを表すIDで、(fn [_ _] db/default-db)
の部分がハンドラです。ハンドラは引数として、第一引数として、現在のアプリケーション状態、第二引数としてイベントとパラメータのベクターを受け取ります。また、ハンドラが返す値が次のアプリケーション状態になります。::initialize-db
では現状のアプリケーション状態に関わらず、default-db
を返すことで初期化を行っています。アプリケーション状態の変更はこの event.cljs
内のみで実施するようにします。
db.cljs
(ns re-frame-todolist.db)
(def default-db
{:name "re-frame"})
events.cljs
での初期化時に利用する default-db
が定義されています。現状は画面に表示されている "Hello from re-frame" の "re-frame" 部分を保持する :name
キーが登録されています。
view.cljs
(ns re-frame-todolist.views
(:require [re-frame.core :as re-frame]
[re-frame-todolist.subs :as subs]))
(defn main-panel []
(let [name (re-frame/subscribe [::subs/name])]
[:div "Hello from " @name]))
view.cljs
には core.cljs
からルートとして描画される main-panel
コンポーネントが定義されています。main-panel
は Reagent のコンポーネントで、通常の ClojureScript の関数として定義し、Hiccup 形式で描画する HTML を返すようにします。画面に表示されている "Hello from re-frame" の文字列のうち "re-frame" の部分はアプリケーション状態を取得し表示してます。アプリケーション状態の取得は、直接 default-db
を参照するのではなく、subscribe
関数を使い re-frame が管理しているアプリケーション状態を参照するようにします。subscribe
している値が変更されるとコンポーネントの再描画が行われます。
subs.cljs
(ns re-frame-todolist.subs
(:require [re-frame.core :as re-frame]))
(re-frame/reg-sub
::name
(fn [db]
(:name db)))
subs.cljs
では reg-sub
関数を使い view からの問い合わせに応じて値を返す処理(query関数)を登録します。re-frame で管理しているアプリケーション状態の全体を返すのではなく、コンポーネントで利用する単位、view に適した情報に加工し返すようにします。
Step1: TODO リスト表示
では、TODOリストの実装に入っていきたいと思います。
最初に、ハードコーディングされた TODOリストを表示します。変更が発生する部分のコードのみ記載および説明していきます。
(def default-db
{:todos {1 {:id 1 :title "豚肉を買ってくる"}
2 {:id 2 :title "たまねぎを買ってくる"}
3 {:id 3 :title "にんじんを買ってくる"}
4 {:id 4 :title "じゃがいもを買ってくる"}
5 {:id 5 :title "カレーを作る"}}})
まず、初期データに表示するTODOリストを設定します。TODO を取得、削除する際に id で該当の TODO を取ってくる際に都合がよいため、データ構造はマップにしておきます。また、:name
キーは不要のため削除します。
(re-frame/reg-sub
::todos
(fn [db]
(-> db :todos vals)))
view からの問い合わせに応じて、TODOリストを返す処理を登録します。view で利用する際に不要なマップのキー情報は削除してTODOリストを返すようします。::name
に関する reg-sub
関数は不要のため削除します。
(defn todo-item
[todo]
[:li (:title todo)])
(defn todo-list
[]
(let [todos @(re-frame/subscribe [::subs/todos])]
[:ul
(for [todo todos]
^{:key (:id todo)}
[todo-item todo])]))
TODOリストを表示するための todo-list
コンポーネントを追加します。subscribe
関数で subs.cljs
に登録した ::todos
を購読しているため、todos に変更があると更新された値が取得され、コンポーネントが再描画されます。1つ1つの TODO は todo-item
コンポーネントで表示するようにします。main-panel
は不要なため削除します。
(defn mount-root []
(re-frame/clear-subscription-cache!)
(reagent/render [views/todo-list]
(.getElementById js/document "app")))
main-panel
ではなく TODOリストを表示するために作成した todo-list
コンポーネントをルートとして表示するように変更します。
Step2: TODO 追加
TODOリストの上部に入力欄を配置し、テキスト入力後、Enterキーを押すことでTODO項目を追加できるようにします。
(defn todo-input
[]
(let [val (reagent/atom "")]
(fn []
[:input {:type "text"
:value @val
:class "new-todo"
:placeholder "What needs to be done?"
:on-change #(reset! val (-> % .-target .-value))
:on-key-down #(when (= (.-which %) 13)
(let [title (-> @val cstr/trim)]
(when (seq title)
(re-frame/dispatch [::events/add-todo title]))
(reset! val "")))}])))
(defn todo-list
[]
(let [todos @(re-frame/subscribe [::subs/todos])]
[:div
[todo-input] ;; <-- 追加
[:ul
(for [todo todos]
^{:key (:id todo)}
[todo-item todo])]]))
todo-list
コンポーネントに新しいTODOの名前を入力する todo-input
コンポーネントを追加します。todo-input
コンポーネントは todo-list
コンポーネントのように Hiccup 形式をそのまま返すのではなく、関数を返しています。これは、入力途中のテキストをコンポーネントローカルで保持させるための設定 [val (reagent/atom "")]
を記述するためです。いつくかある Reagent コンポーネントを作成する書き方の1つで、コンポーネント単位での初期化、ローカル状態の作成などを行う際に利用します。詳しくは公式のドキュメントを確認してみてください。1
テキストを入力し、エンターキー押下で現状のテキストをパラメータとしてTODO追加のイベント[::events/add-todo title]
を発行します。実際の追加処理は下記の events.cljs
で行います。
(defn- allocate-next-id
[todos]
(-> todos
keys
last
((fnil inc 0))))
(re-frame/reg-event-db
::add-todo
(fn [db [event title]]
(let [id (allocate-next-id (:todos db))]
(update db :todos #(assoc % id {:id id :title title})))))
view からの ::add-todo
イベントを受け、新しいTODO項目を追加するため処理を reg-event-db
関数で登録します。 受け取った title を持つ todo を作成し、現状のアプリケーション状態に update をかける形で TODO を追加します。TODOが追加されると、::subs/todos
を subscribe
している todo-list
が再描画され、TODOの追加が画面のリストに反映されます。
Step3: TODO 完了/取消し
次にチェックボックスのON/OFFで、TODO項目の完了/取消しを表現できるようにします。
(def default-db
{:todos {1 {:id 1 :title "豚肉を買ってくる" :completed true}
2 {:id 2 :title "たまねぎを買ってくる" :completed true}
3 {:id 3 :title "にんじんを買ってくる" :completed false}
4 {:id 4 :title "じゃがいもを買ってくる" :completed false}
5 {:id 5 :title "カレーを作る" :completed false}}})
完了状態を管理するための:completed
キーを追加しておきます。
(defn todo-item
[{:keys [id completed] :as todo}]
[:li
[:input {:type "checkbox"
:class "toggle"
:checked (and completed "checked")
:on-change #(re-frame/dispatch [::events/toggle id])}]
[:span {:class (when completed "completed")}
(:title todo)]])
各TODOにチェックボックスを追加します。チェックボックスのOn/Off で ::events/toggle
イベントを発行し、実際の処理は events.cljs
に記述した対応するイベントハンドラーに依頼します。
(re-frame/reg-event-db
::add-todo
(fn [db [event title]]
(let [id (allocate-next-id (:todos db))
new-todo {:id id :title title :completed false}] ;; completed を追加
(update db :todos #(assoc % id new-todo)))))
(re-frame/reg-event-db
::toggle
(fn [db [event id]]
(update-in db [:todos id :completed] not)))
view.cljs
で dispatch の際に渡された id を使い、対応するTODOの完了状態をトグルします。
また、::add-todo
で新規TODOを追加する際には、completed を false に設定するように追記します。
Step4: TODO 削除
最後に、TODOを削除できるようにします。
(defn todo-item
[{:keys [id completed] :as todo}]
[:li
[:input {:type "checkbox"
:class "toggle"
:checked (and completed "checked")
:on-change #(re-frame/dispatch [::events/toggle id])}]
[:span {:class (when completed "completed")}
(:title todo)]
;; 追加
[:span {:class "delete"
:on-click #(re-frame/dispatch [::events/delete id])}
"[x]"]])
削除イベントを発行するためのボタンを追加します。ボタン押下で対象となるTODO項目の id をパラメータとしたイベント[::events/delete id]
を発行し、実際の削除処理は下記の events.cljs
で行います。
(re-frame/reg-event-db
::delete
(fn [db [event id]]
(update db :todos dissoc id)))
view.cljs
で dispatch の際に渡された id を使い、対応するTODOを削除します。
おわりに
TODOリストを作成することで、Re-frame でのコードの書き方を確認しました。Re-frame の提供する枠組みに従うことで、アプリケーション状態の管理とViewの構築を切り分けたコーディングを行うことができアプリケーションの規模が大きくなった際にも見通しの良いコードが書けそうです。とは言え、フレームワークの提供する制約は弱く、単純に導入するだけではなく view = f(state) を意識して実装していくことが大切だと思います。
今回の記事で Re-frame に興味を持った方は、次の学習として公式にある TodoMVC のサンプルを確認してみると良いと思います。今回触れなかった機能(interceptorとか)やローカルストレージとの連携、spec による状態のバリデーションなどより実践的な使い方を学ぶことができます。
今年は、Om Next のバージョンが beta となりましたが、あまり開発もアクティブではなく、現時点では Re-frame & Reagent がファーストチョイスかなと思います。2