🍮

仮想 DOM を使わない Web フレームワーク 'Jelly' を作った in PureScript

2022/08/06に公開

ゼリーの絵文字が無かったのでプリンで代用しました。

はじめに

リポジトリはこちら

https://github.com/yukikurage/purescript-jelly

試しに作ったサイトはこちら

https://yukikurage.github.io/purescript-jelly-examples/

開発者ツールを開きながら色々操作してみてください。必要な部分だけ DOM を更新できていることが分かると思います。

今回の記事では使い方のみ書きます。仕組み編は別記事にて。

追記

ポートフォリオサイトを Jelly で作りかけています

https://yukikurage.github.io/web-portfolio/

なぜ作ったのか

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 型の値を返します。

chchild 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

launchAppComponent と 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) は、初期値を受け取って、SignalAtom を返します。

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 を呼ぶことで、それ以降は状態を更新しても再実行されません。

このように、signallaunch を組み合わせる事で、状態と、それに依存する処理を記述することが可能になります。 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 に渡している Signalon 関数が目新しいので解説します。

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"

ここで、component1count を読み取って表示していて、component2count に値を書き込んでいます。

useContext :: ∀ r. Hook r rContext を読む Hook です。これを使うことで、component1component2に状態をバケツリレーすることなく、共有することが可能になります。

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 したときに再実行を止めるようになっています。

useTimeoutuseIntervaluseEventListener

それぞれ 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"

削除したタスクのノードだけが消されます。

ちなみに実装はめちゃくちゃ汚いです (改善したい)

https://github.com/yukikurage/purescript-jelly/blob/master/src/Jelly/Hooks/Ch.js

まとめ

JellyReact と同じように Hook を使ってコンポーネントを構築します。しかし、React と違うのは次のようなところです

  • 仮想 DOM を用いていない
  • Hook の実行が一回である
  • Signal を使って依存関係を解決している
  • 子要素や属性も Hook を使って追加する

今度 Jelly を使ってポートフォリオサイトを作る予定です。また、仕組み編の記事も書く事を予定しています。

GitHubで編集を提案

Discussion