RecoilにインスパイアされてPureScript用仮想DOMつくった

追記

ここに記述した内容は既に古くなっています。 内部実装は既に書き直されていますし、Hooks likeなAPIもインターフェースが変更されています。 興味のある方は直接purescript-grainへ見に行ってください。

動機

Recoilが思ったより話題になっていたので、PureScriptだったらどういう表現ができるかなと思ってつくりたくなった。

そういうわけで、PureScriptにおける仮想DOM実装の3作目が爆誕してしまった。

結果

purescript-grain

こういう感じになった

型クラスのインスタンスにした型が各状態になり各状態キーにもなるという感じになった。 実はここの表現に関してはかなり迷って、最初は、シンボル(型レベル文字列)と状態の型をもち、キーの型から状態の型への関数従属性をもった型クラスのインスタンスにすることで表現しようと考えたが、PureScriptはOrphan instance禁止なため、且つ、上記の関数従属性があるので、型クラスの宣言と別のモジュールでインスタンスの宣言をするとコンパイルが通らない。 よって、そこから少し右往左往してこの形に落ち着いた。

グローバルステート

以下のように任意の型をGlobalGrainインスタンスにし、状態の取得・購読や更新関数の取得をプロキシ経由で行う。

その型のコンストラクタ関数の参照を型の参照とみなし、状態のキーとして扱い、一意に識別する。

各所で勝手に宣言された状態は、ひとつのオブジェクトにおしこまれるようになっているため、全状態を見たりできるdev toolを作ろうと思えばつくれる状態にはなっている。

ただ、現状は型の参照をキーにして、uuid v4ベースの文字列を管理し、その文字列を内部で状態のキーとして使っているため、ヒューマンリーダブルではない。

これに対するアプローチは状態名のヒューマンリーダブルな表現を登録してもらうか、あるいは、型からモジュール名を含めたシンボル(型レベル文字列)をつくる、みたいな、コンパイラが解決する特殊な型クラスがあったりするといい。

後者の場合は、以下のtypeRefOfの実装もしなくて済むようになる。 後者の案に似たような提案はされているのでそういった機能が入ることを0.1%くらい期待しているが、あまり期待できない。

前者の案を採用する場合は、所詮表示用なのでユニークである制約はつける必要はないが、まぁだるいといえばだるいので、結局、現状はヒューマンリーダブルな名付けはしなくていいようになっている。この課題に関しては先延ばしにするということにした。

import Prelude

import Grain (class GlobalGrain, GProxy(..), VNode, fromConstructor, useUpdater, useValue)
import Grain.Markup as H

newtype Count = Count Int

instance globalGrainCount :: GlobalGrain Count where
  initialState _ = pure $ Count 0
  typeRefOf _ = fromConstructor Count

view :: VNode
view = H.component do
  Count count <- useValue (GProxy :: _ Count)
  updateCount <- useUpdater (GProxy :: _ Count)
  let increment = updateCount (\(Count c) -> Count $ c + 1)
  pure $ H.div
    # H.onClick (const increment)
    # H.kids [ H.text $ show count ]

動的なアイテムに対するグローバルステート

なにか動的なアイテムにそれぞれに対して、状態をつくりたい、要するにUIへの通知範囲を極端にしぼりたいケースにも対応している。

以下は簡単な例である。 この場合は、プロキシの値がもつキーと型への参照を用いてアイテムごとの状態キーが作られるようになっている。

import Prelude

import Grain (class KeyedGlobalGrain, KGProxy(..), VNode, fromConstructor, useUpdater, useValue)
import Grain.Markup as H

newtype Item = Item
  { name :: String
  , clicked :: Boolean
  }

instance keyedGlobalGrainItem :: KeyedGlobalGrain Item where
  initialState (KGProxy key) = pure $ Item
    { name: "Item " <> key
    , clicked: false
    }
  typeRefOf _ = fromConstructor Item

view :: String -> VNode
view key = H.component do
  Item item <- useValue (KGProxy key :: _ Item)
  updateItem <- useUpdater (KGProxy key :: _ Item)
  let onClick = updateItem (\(Item i) -> Item $ i { clicked = true })
  pure $ H.div
    # H.onClick (const onClick)
    # H.kids [ H.text $ item.name <> if item.clicked then " clicked" else "" ]

その他

この記事の本筋とは関係ないので書かないが、ローカルステートもある。 また、FFIでnpmパッケージを使っておらず全体的にPureScriptで書いてある。

また、2作目につくった仮想DOMは、でかいひとつのグローバルステートしかもてない制約と一部グローバルステートしか持てないことで不便になるケースに対応するための仕組みをいれた形のものだが、多数のファイルが型レベルでグローバルステートに依存する設計になっており、ステートに手を加えると、大量のファイルが再コンパイルされるので、 コードベースがでかくなってくると差分ビルドしてても重い。 というかそういう状況が発生し始めていた。

3作目のpurescript-grainは各グローバルステートの宣言をモジュールに分けておくだけで、仕組み上同じ問題は起こらない。

また、仕組み上、本家のReact hooksと違い、呼び出し順序が変わろうが、呼び出したり呼び出さなかったりしようが、バグらない。

既に少し派生パッケージをつくってみたが、書き心地としては悪くない。(自分が作ったので自分がそう思うのは当たり前だが...

なお、package-setsに追加したものの、まだpackage-setsがリリースされてないし、今日もさらにアップデートしたので、 試したくなった場合は、packages.dhallのadditionに手動で加えてもらう必要が有ります