仮想 DOM を使わない Web フレームワーク 'Jelly' を作った in PureScript
ゼリーの絵文字が無かったのでプリンで代用しました。
はじめに
リポジトリはこちら
試しに作ったサイトはこちら
開発者ツールを開きながら色々操作してみてください。必要な部分だけ DOM を更新できていることが分かると思います。
今回の記事では使い方のみ書きます。仕組み編は別記事にて。
追記
ポートフォリオサイトを Jelly で作りかけています
なぜ作ったのか
PureScript で一番使われているであろうフレームワークは Halogen でしょう。(統計情報は無いですが……)
この Halogen は素晴らしいフレームワークなのですが、個人的に使うには抽象度が高すぎるなと感じてしまったので、個人利用で使いやすいフレームワークを作ってみようと思いました。ついでに、仮想 DOM を使っていないフレームワークはどんなもんなのか気になったので仮想 DOM も使っていません。
使い方
インストール方法は README に書かれています。
また、 import 文は省略します (少なくとも Jelly から同じ関数名のものを提供している事はないので、どこから import するのかは自明に定まるからです)
とりあえず動かす
type Context = Unit
main :: Effect Unit
main = launchApp root unit
root :: Component Context
root = el "div" do
"id" := pure "root"
ch $ text $ pure "Hello, Jelly!"
実行すると、 body 以下に div が追加されます。
<body>
<div id="root">Hello, Jelly!</div>
</body>
それぞれの関数を説明していきます。
text
:: ∀ r. Signal String → Component r
text
関数は Signal String
型の値を受け取って、Component r
型の値を返します。
Signal
に関しては後ほど解説しますが、とりあえず Applicative
のインスタンスなので pure
を使って任意の値を Signal
にできることが分かれば良いです。
Component r
は一つのノード、アプリケーションの構成要素を表しています。ここででは Text ノードです。
r
はコンテキストの型です。コンテキストはアプリケーション全体でいつでも呼び出せる値です。今回は Unit
としているので特に何もできません。
ch
:: ∀ r. Component r → Hook r Unit
ch
関数は Component r
型の値を受け取って、Hook r Unit
型の値を返します。
ch
は child node
の略です。これを使って、他のコンポーネントに子要素としてコンポーネントを追加できます。
Hook
は要素の初期化処理を表すモナドです。 React の Hooks と似ていますが、初期化時のみ実行され、複数回実行されることはありません。
このように Hook
を返す関数を Hook と呼びましょう。
:=
:: ∀ r. String → Signal String → Hook r Unit
こちらは要素に属性を追加する Hook です。使い方は見ての通り。Signal
に関しても text
と同様です。
el
:: ∀ r. String → Hook r Unit → Component r
element
の略です。要素の tag と初期化処理である Hook
を受け取って、Component
を作成します。
launchApp
:: ∀ r. Component r → r → Effect Unit
launchApp
は Component
と Context を受け取って、実際の DOM の main に埋め込む関数です。
今回 Context は Unit
なので unit
を渡しています。
Signal
ここまでで、状態を持たないアプリケーションならだいたい作れます。また、それぞれの型が非常に単純であることが分かると思います。
ここからは状態管理に入るのですが、まずは Signal
について説明します。
Signal
は、モナドであって、"実行時に変化するような値に依存した処理" を表すものです。
例を示した方が分かりやすいでしょう。 Signal
を使うには signal
関数と launch
を組み合わせる必要があります。
main :: Effect Unit
main = do
xSig /\ xAtom <- signal 0
_ <- launch do
x <- xSig
log $ "x = " <> show x
writeAtom xAtom 1
writeAtom xAtom 2
実行結果は
x = 0
x = 1
x = 2
signal :: ∀ m a. MonadEffect m ⇒ a → m (Signal a /\ Atom a)
は、初期値を受け取って、Signal
と Atom
を返します。
Atom
は、Signal
を出力としたらならば入力を表すもので、
writeAtom :: ∀ a. Eq a ⇒ Atom a → a → Effect Unit
や
modifyAtom :: ∀ a. Eq a ⇒ Atom a → (a -> a) → Effect a
modifyAtom_ :: ∀ a. Eq a ⇒ Atom a → (a -> a) → Effect Unit
で状態を書き換えることができます。
launch :: Signal Unit → Effect (Effect Unit)
は Signal
を実行し、その Signal
が依存する変数が更新されたら、再度 Signal
を実行するようにする Effect です。戻り値については後述します。
ここで launch
に渡される処理は
do
x <- xSig
log $ "x = " <> show x
ですが、これは「変数 x
に依存して」「x
の値を出力する」処理として解釈できます、
ここで、実行結果を見てみると、writeAtom
で x の状態を書き換えるたびに、launch
に渡された Signal
が再実行されていることが分かります。
この挙動は React の useEffect
に非常に似ています。が、依存関係を書かなくても自動的に解決してくれるのが特徴です。
launch
の戻り値の Effect Unit
は再実行を止める Effect です。例として
main :: Effect Unit
main = do
xSig /\ xAtom <- signal 0
stop <- launch do
x <- xSig
log $ "x = " <> show x
writeAtom xAtom 1
writeAtom xAtom 2
stop
writeAtom xAtom 3
の実行結果は
x = 0
x = 1
x = 2
です。stop
を呼ぶことで、それ以降は状態を更新しても再実行されません。
このように、signal
と launch
を組み合わせる事で、状態と、それに依存する処理を記述することが可能になります。 launch
はこれ以降出てきませんが、Jelly
の内部では、launch
が使われています。
状態を持つコンポーネント
さて、これを Hook
と組み合わせて、状態を持つコンポーネントを作ってみます。例として、カウンターを作成します。
counter :: forall r. Component r
counter = el "div" do
countSig /\ countAtom <- signal 0
ch $ text do
count <- countSig
pure $ "Counter: " <> show count
ch $ el "button" do
on "click" \_ -> do
modifyAtom_ countAtom (_ + 1)
ch $ text $ pure "Increment"
text
に渡している Signal
と on
関数が目新しいので解説します。
do
count <- countSig
pure $ "Counter: " <> show count
これは「状態 count
に依存して」「"Counter: {count}"
を返す」処理です。これを text
に渡す事で、状態が書き換わるたびに text
の中身が置換されます。
on :: ∀ r. String → (Event → Effect Unit) → Hook r Unit
は、要素のイベントリスナに Effect を登録する hook です。今回は、"click"
イベントに対して、count
をインクリメントする Effect を登録しています。これによって、
ボタンをクリック → count
をインクリメント → text
の中身が置換される
という動作が実現されます。
コンテキスト
コンテキストはアプリケーション全体でいつでも呼び出せる値と述べました。これと Signal
を組み合わせる事で、アプリケーション全体で状態を共有することができます。
main :: Effect Unit
main = do
count <- signal 0
launchApp contextExample $ { count }
contextExample :: Component Context
contextExample = el "div" do
ch $ component1
ch $ component2
component1 :: Component Context
component1 = el "div" do
{ count: countSig /\ _ } <- useContext
ch $ text $ show <$> countSig
component2 :: Component Context
component2 = el "button" do
{ count: _ /\ countAtom } <- useContext
on "click" \_ -> modifyAtom_ countAtom (_ + 1)
ch $ text $ pure "Increment"
ここで、component1
は count
を読み取って表示していて、component2
は count
に値を書き込んでいます。
useContext :: ∀ r. Hook r r
は Context
を読む Hook です。これを使うことで、component1
、component2
に状態をバケツリレーすることなく、共有することが可能になります。
Hook
Jelly
はいくつかの基本的な Hook
を提供します。
useUnmountEffect
:: ∀ r. Effect Unit → Hook r Unit
コンポーネントが Unmount されるときに Effect を実行する Hook です。
useSignal
:: ∀ r. Signal Unit -> Hook r Unit
使い方
component = el "div" do
xSig /\ xAtom <- signal 0
useSignal do
x <- xSig
log $ "x = " <> show x
このようにすることで、x が更新されるたびに log x
が実行されます。
launch
と似ていますが、違いは実装を見れば分かります。
useSignal :: forall r. Signal Unit -> Hook r Unit
useSignal signal = do
stop <- liftEffect $ launch signal
useUnmountEffect stop
Unmount したときに再実行を止めるようになっています。
useTimeout
、useInterval
、useEventListener
それぞれ setTimeout
setInterval
addEventListener
に対応する Hook ですが、Unmount したときに実行が止まるようになっています。
chWhen
chIf
chSig
ch
の亜種です。
chWhen :: ∀ r. Signal Boolean → Component r → Hook r Unit
chIf :: ∀ r. Signal Boolean → Component r → Component r → Hook r Unit
chSig :: ∀ r. Signal (Component r) → Hook r Unit
Signal
に依存して付け外しするコンポーネントを作成する Hook です
chsFor
:: ∀ r a. Eq a ⇒ Signal (Array a) → (a → Maybe String) → (Signal a → Component r) → Hook r Unit
こちらも ch
の亜種で、配列の差分だけ更新して他は前のノードを使いまわす Hook です。 React での key
を使った map
に近いものです。
第一引数は依存する配列、第二引数は、配列の値から key
を取り出す関数、第三引数は、配列の値からコンポーネントを作る関数です。例として Todo コンポーネントを作ってみます。
initTasks
:: Array { id :: String , title :: String }
initTasks =
[ { id: "1", title: "Todo 1" }
, { id: "2", title: "Todo 2" }
, { id: "3", title: "Todo 3" }
]
todoList :: Component Context
todoList = el "div" do
tasks /\ tasksAtom <- signal initTasks
chsFor tasks (_.id >>> Just) \task -> el "div" do
ch $ text $ _.title <$> task
ch $ el "button" do
on "click" $ \_ -> do
t <- readSignal task
modifyAtom_ tasksAtom $ filter $ \t' -> t'.id /= t.id
ch $ text $ pure "Delete"
削除したタスクのノードだけが消されます。
ちなみに実装はめちゃくちゃ汚いです (改善したい)
まとめ
Jelly
は React
と同じように Hook
を使ってコンポーネントを構築します。しかし、React
と違うのは次のようなところです
- 仮想 DOM を用いていない
-
Hook
の実行が一回である -
Signal
を使って依存関係を解決している - 子要素や属性も
Hook
を使って追加する
今度 Jelly
を使ってポートフォリオサイトを作る予定です。また、仕組み編の記事も書く事を予定しています。
Discussion