Edited at

re-frameによるSPAの開発

More than 3 years have passed since last update.

re-frameは、reagentを利用したClojureScriptによる、SPA開発のためのプラクティスで、充実したガイドラインと少量のライブラリを提供しています。

reagentとre-frameの関係はReact.jsとFluxの関係に似ています。


概要

re-frameでは、app-dbと呼ばれる単一のグローバルなステートのみが存在しています。app-dbの値を更新し、app-dbを変更をサブスクライブすることで、リアクティブな動作を実現できます。

reagent単体では、イベントの実行方法や、関心のあるステートの更新のみをサブスクライブする統一された記述方法が定義されておらず、実装者任せになりますが、re-frameを用いると標準的な枠組みで実装できるようになります。

re-frameで、登場する主な要素は以下です。

要素
役割
react-reduxでのイメージ

app-db
単一のステート
Stores

Components
画面部品
Containers/Components

Subscriptions
app-dbのサブスクライブ条件
react-reduxのconnect()の第一引数mapStateToPropsあたり

Handlers
イベント、現在のapp-dbを受け取り新しいapp-dbを返す
Actions + Reducers

※react-reduxでのイメージは、強引に当てはめるとしたら、という程度です。

このうち実装が必要なのは、Components、Subscriptions、Handlersです。

1. Subscriptionsにapp-dbに対するreagent/reactionを定義

2. Handlersにapp-dbを更新するユーザイベントを定義

3. Componentsでre-frame/subscribeを実行し、Subscriptionsに定義したreagent/recationをサブスクライブ

4. ComponentsのonClickなどでre-frame/dispatchを実行し、Handlersに定義したイベントを非同期実行

が基本的な実装になります。


実際にSPAを作ってみる

re-frameを使って、docker-statsの結果を表示したり、コンテナを操作するSPAを作ってみたので、re-framewで開発するための、クライアント側の実装の要点をまとめました。

完成版のソースはこちらです。

https://github.com/nysd/docker-ui-clj/tree/reframe

なお、サーバサイドレンダリングは考慮してません。


アプリケーションの概要

[サーバ]


  • サーバ起動後定期的にdocker ps/docker statsを実行

  • 定期的にtopicに対してdocker statsの結果をpublish

  • websocketで接続を受け付けたクライアントは、topicをサブスクライブしクライアントのチャネルに対してメッセージ送信

[クライアント]


  • クライアントはwebsocketでサーバに接続し、サーバから常時docker statsを結果を取得

  • docker statsの結果を画面にレンダリング


クライアント側の追加ライブラリ

re-frameとreagentだけではルータや画面遷移のための機能がないので以下を利用します。

ライブラリ
役割

venantius/accountant
pushStateによる画面遷移の管理を行ってくれる

gf3/secretary
クライアント側ルータ


サーバ側ルータの作成

まずは、サーバ側のルータでエントリポイントになるapp.runを実行するhtmlを返却します。

どのエンドポイントが叩かれても必ず同じhtmlを返却します。


src/main/clj/docker/ui/routes/site.clj

 (defn- single-page

[]
(hiccup/html5
[:head
[:title "Dokcer UI"]
[:body
[:div {:id "app" :class "container"}]
[:script {:src "/assets/js/app.js"}]
[:script "docker.ui.app.run();"]]))

(defroutes routes
(resources "/")
(GET "/containers/:id" [] (single-page))
(GET "/containers/:id/start" [] (single-page))
(GET "/containers/:id/start/complete" [] (single-page))
(GET "/containers/:id/start/failure" [] (single-page))
(GET "/containers/:id/stop" [] (single-page))
(GET "/containers/:id/stop/complete" [] (single-page))
(GET "/containers/:id/stop/failure" [] (single-page))
(GET "/stats" [] (single-page))



イベントの作成

re-frame/register-handlerでイベントを定義します。

引数のdbに新しい値を適用した結果を返却するのがポイントです。

dbはre-frameのapp-dbでatomですが直接更新してはいけません。


src/main/cljs/docker/ui/handlers.cljs

;app-db初期化、デフォルトの画面を設定する。

(re-frame/register-handler
:initialize-db
(fn [_ _]
{:current-view view/default-view}))

;app-dbのdocker-statsの結果を更新する。
(re-frame/register-handler
:update-stats
(fn [db [_ stats]]
(assoc db :stats stats )))

;表示画面を変更するためにcurren-viewを更新する
(re-frame/register-handler
:change-view
(fn [db [_ page]]
(assoc db :current-view page)))

;画面遷移する
(re-frame/register-handler
:navigate
(fn [db [_ url]]
(accountant/navigate! url)
db))



サブスクリプションの作成

re-frame/register-subでサブスクリプションを定義します。

dbからサブスクライブしたいキーを指定してreagent/reactionを適用するのがポイントです。


src/main/cljs/docker/ui/subs.cljs



;docker-statsの更新を検知する。
(re-frame/register-sub
:stats
(fn [db]
(reagent/reaction (:stats @db))))

;画面の変更を検知する。
(re-frame/register-sub
:current-view
(fn [db]
(reagent/reaction (:current-view @db))))



コンポーネントの作成

画面部品であるコンポーネントを作成します。

re-frame/subscribeでapp-dbの更新を検知しレンダリングし、DOMイベントはre-frame/dispatchを使って実行するのがポイントです。

下はちょっと複雑な例ですが、サーバから定期的にwebsocket経由でdocker-statsの結果を受け取ってapp-dbを更新しています。


src/main/cljs/docker/ui/views.cljs

(defn stats-view []

(let [stats-ratom (re-frame/subscribe [:stats])]
(reagent/create-class
;React.jsのComponentDidMountフェーズで実行するイベント
{:component-did-mount
(fn []
(go
(let
[url (str "ws://" (.-host (.-location js/window)) "/ws/docker/stats")
{:keys [ws-channel]} (<! (ws-ch url (:format :edn) ))]
(loop []
(let [{:keys [message error]} (<! ws-channel)]
(if error
(close! ws-ch)
(do
;サーバからdocker-statsの結果を受け取る毎にイベント発行
(re-frame/dispatch [:update-stats message])
(recur))))))))
;レンダリングのための関数
:reagent-render
(fn []
[:div
[:h1 "Docker I/O"]
[:div
[:table.table.table-hover
[:thead
[:tr
[:th "CONTAINER"]
[:th "CPU %"]
[:th "MEM USAGE / LIMIT"]
[:th "MEM %"]
[:th "NET I/O"]
[:th "BLOCK I/O"]]]
[:tbody
(for [container (:detail @stats-ratom) ]
;レコードが選択されたらコンテナ詳細画面に遷移するイベント発行
[:tr {:key (:id container)
:on-click #(re-frame/dispatch
[:navigate (str "/containers" (:id container))])}
〜以下略〜


レンダリング関数の注意点

ComponentDidMountを利用せず単純にレンダリングするだけの場合、以下のようにレンダリング関数を返すだけです。

ただし、app-dbをsubscribeする場合必ずレンダリングのために関数を返す必要があります。


src/main/cljs/docker/ui/views.cljs

;OK

(defn current-view
[]
;current-viewの中身(views.clsjで定義した他のview)を取得してレンダリング
(let [current-page-ratom (re-frame/subscribe [:current-view])]
(fn [] [@current-page-ratom])))

re-frameのガイドラインによると、関数を返却しない場合、サブスクライブしてるキーに対応するapp-dbの値が変更されなくても、app-dbが更新される都度、レンダリングのための計算が実行されてしまうとのこと。下はNGの例。

(defn current-view

[]
(let [current-page-ratom (re-frame/subscribe [:current-view])]
;× 関数を返却しなくてはいけない
[@current-page-ratom]))


イベントの実行について

onClickなどDOMのイベントから呼び出す処理は必ずre-frame/dispatchを利用します。

re-frame/dispatchは内部でQueueを作っていて、goog.async.nextTickを利用して非同期実行されます。


Ajaxコールのサンプル

ajaxの実行、成功時処理、失敗時処理をそれぞれregister-handlerで登録します。


src/main/cljs/docker/ui/handlers.cljs

;ajax成功

(re-frame/register-handler
:inspect-container-success
(fn
[db [_ id response]]
(re-frame/dispatch [:change-view #(view/info-view response)])
(assoc db :loading? false)))

;ajax失敗
(re-frame/register-handler
:inspect-container-failure
"src/main/cljs/docker/ui/handlers.cljs"
(re-frame/register-handler
:inspect-container-failure
(fn
[db [_ e]]
(println e)
(assoc db :loading? false)))

;ajax使って情報取得、これをDOMのonClickなどで実行する
(re-frame/register-handler
:inspect-container
(fn [db [_ id]]
(ajax/GET (str "/api/containers/" id)
{:handler (fn [res] (re-frame/dispatch [:inspect-container-success id res]) )
:error-handler (fn [e] (re-frame/dispatch [:inspect-container-failure e])) })
(assoc db :loading? true)))



クライアント側ルータの作成

ブラウザのパスに対応するルータを作成します。


src/main/cljs/docker/ui/routes.cljs

(defroute "/stats" []

;view/stats-viewを表示するイベントを実行
(re-frame/dispatch [:change-view view/stats-view]))

(defroute "/containers/:id" [id]
;docker-inspectを実行するイベントを実行
(re-frame/dispatch [:inspect-container id]))



起動関数の作成

エントリポイントとなる関数を作成します。先に表示したhtmlでは必ずこの関数を呼び出すようにします。

エントリポイントでは、re-frameのapp-b初期化から初期ページの表示までを実行します。


src/main/cljs/docker/ui/app.cljs

(defn ^:export run

[]
;app-dbの初期化
(re-frame/dispatch-sync [:initialize-db])

;ブラウザのパスが示すクライアント側のルータを実行
(accountant/configure-navigation!)
(accountant/dispatch-current!)

;画面表示 /statsでアクセスした場合view/stats-viewが表示される
(reagent/render [view/current-view]
(.getElementById js/document "app") ))


通常、イベントの実行はre-frame/dispatch-syncではなく、非同期のre-frame/dispatchを利用します。

今回は、この後にreagent/render [view/current-view]でapp-dbの:current-viewを利用するため、イベントの実行タイミングによってはcurrent-viewがnilになってしまいます。

そのためDB初期化のみdispatch-syncを利用してします。


まとめ

re-frameを用いることで、regentベースのアプリケーションを、標準的な枠組みの上で開発することができます。

今回書いたのは必要最低限の実装ですが、re-frameのガイドラインやWikiにはapp-dbのスキーマ定義やvalidationの方法など充実したガイドがそろっています。

re-frameを開発標準とすることで、Clojure/ClojureScriptを、大人数でのアプリケーションの開発に導入しやすくなるのではないでしょうか。