ClojureScriptの状態管理にIntegrantを使う
ClojureScriptでフロントエンドの開発を行うさい、ほとんどの場合Figwheelを用いると思う。これによって自動的にリロードが行われるようになる。ただし、このときアプリケーションをリローダブルに書くことが求められる。
フロントエンド開発においてリローダブルでないとはどういうことかというと、例えば以下のような状況が考えられる。
イベントリスナーに登録するが削除されない
DOMを追加するが削除されない
データベース(atomなど)を変更するが初期化されない
ローカルストレージを利用するが削除されない
このような状態でFigwheelを利用して、アプリケーションを書いてしまうと、リロードが行われる度にイベントハンドラーが何度も追加されたり、DOMが何度も追加されてしまうことになる。そうすると結局毎回毎回自分でブラウザをリロードして状態をクリアする必要がある。
これは嬉しくないので、リローダブルに書く必要があるのだけど、普通に書くとしんどいのでIntegrantを利用すると良い、という話(本質的にはComponentでもよいし、実際そのようにすることも可能だ)。
簡単な例として、ブラウザの現在のサイズを表示するだけのアプリを実装してみる。
まずはリローダブルなシステムをatomで用意する。
code:core.cljs
(defonce system (atom nil))
ここにIntegrantで起動したシステムを格納することにする。defonceを使うことで自動的にリロードされたとしても、systemは再束縛されない。
画面に表示する部分はRumを使って、以下のように実装した。
code:core.cljs
(rum/defc display-window-size < rum/reactive
db
(let [{:keys height width} (rum/react db)]
[:div
:h1 (str "window height: " height)
:h1 (str "window width: " width)]))
dbというatomを受け取って、ブラウザのサイズが変わってもちゃんと表示が変わるようになっている。
ウィンドウのサイズが変更されるのは、リサイズイベントを取ればよいので、これは以下のように書くことができる。
code:core.cljs
(defn current-window-size []
(let [width (.-innerWidth js/window)
height (.-innerHeight js/window)]
{:width width :height height}))
(defmethod ig/init-key :demo/event
db
(letfn [(handler _
(reset! db (current-window-size)))]
(events/listen js/window "resize" handler)
handler))
(defmethod ig/halt-key! :demo/event
handler
(events/listen js/window "resize" handler))
イベントリスナーに登録するイベントハンドラーは、削除するさいにも必要なわけだけれど、普通に書こうとすると少しばかり面倒だったりする(特に今回のdbみたいに外部のものに依存している場合は)。
しかし、このようにIntegrantを使えば、システムの中にイベントハンドラーを保持しておくことができ、システムを停止するときに利用することができる。
RumのコンポーネントをマウントするためのDOMも必要となる(若干こじつけだけれど)。それは、次のように書くことができる。
code:core.cljs
(defmethod ig/init-key :demo/dom
id
(let dom (dom/createDom "div" {:id id})
(dom/appendChild (.-body js/document)
dom)
dom))
(defmethod ig/halt-key! :demo/dom
elm
(dom/removeNode elm))
見て分かる通り、イベントハンドラーと同様に作成したDOMをシステムで保持することによって、停止するときにそのDOMを直接削除することができる。これはいちいちquerySelectorなどを利用することがないという点でも優れている。
残りはアプリケーションで必要になるデータを保持するデータベースと、Rumのコンポーネントをマウントするための処理だ。簡単なのでさくっと紹介してしまうことにする。
code:core.cljs
(defmethod ig/init-key :demo/db
_
(atom (current-window-size)))
(defmethod ig/halt-key! :demo/db
db
(reset! db nil))
(defmethod ig/init-key :demo/app
{:keys db elm}
(rum/mount (display-window-size db) elm))
dbは特に何にも依存していないので、現在のウィンドウサイズを保持したatomを作成するだけでよい。システムを停止させるときの処理は必要ないが、なんとなく気分で書いた。また、Rumのコンポーネントを初期化するための、コンポーネント(言葉が紛らわしい)はdbとマウント先のDOMに依存しているため、初期化するさいに受け取りRumのコンポーネントをマウントする。
最後に、システムの依存関係を設定として記述してシステムを起動/停止する関数を用意すればよい。
code:core.cljs
(def system-conf
{:demo/dom "myapp"
:demo/event (ig/ref :demo/db)
:demo/db nil
:demo/app {:db (ig/ref :demo/db)
:elm (ig/ref :demo/dom)}})
(defn start []
(reset! system (ig/init system-conf)))
(defn stop []
(ig/halt! @system)
(reset! system nil))
ClojureScriptの場合は、ig/read-stringを利用することができない(できたとしてブラウザは何処からリソースファイルを貰えばいいんだ…)ので、直接ファイルに設定を書いてしまえばよい。
Integrantを使うメリットのひとつに初期化するときの依存関係を決定できるというのがある。DOMがないとマウントできないとか、dbを初期化した後でないとイベントハンドラーの内部で利用できないといった問題も一緒に解決できる。
今回作ったサンプルは以下に置いておくので、興味があれば適当に参照してほしい。
https://github.com/ayato-p/clojure-sandbox/blob/46ac81c2b10fc6762a30ce244b5ae23ad96f1164/integrant-for-cljs/src/demo/core.cljs
#ClojureScript #Integrant