仮想 DOM 不使用の自作 Web UI フレームワークの仕組み【Jelly】
この記事は traP 夏ブログリレー 9 日目の記事です。
はじめに
こんにちは。21B のゆきくらげです。この度、Jelly という Web アプリのフロントエンド用フレームワークを作ったので、その仕組みを解説します。
- リポジトリ: purescript-jelly
- 実際に Jelly で作ったサンプル: PureScript Jelly
- Jelly で作成途中のポートフォリオ: YUKINET
Jelly の基本的な概要は Zenn の記事「仮想 DOM を使わない Web フレームワーク 'Jelly' を作った in PureScript」に書いていますが、こちらでも軽く説明します。
Jelly の使い方
コンポーネントの作成
例えば Increment ボタンとテキスト Counter: <カウント回数>
を表示して、Increment ボタンを押すとカウントが増えるようなコンポーネントを作ります。
main = launchApp counter unit
counter :: Component Unit
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"
雰囲気が分かれば結構です。このような感じで、結構単純に書けることが分かりますでしょうか。
Signal による状態管理
Jelly を支配しているのは Signal
という型です。主にこれを解説したいので、Signal の動作を見てみましょう。
状態とそれに依存する処理
main = do
hogeSig /\ hogeAtom <- signal "hoge"
launch_ do
hoge <- hogeSig
log hoge
modifyAtom_ hogeAtom (\prev -> prev <> "!")
writeAtom hogeAtom "hoge!!"
結果
hoge
hoge!
hoge!!
signal :: ∀ m a. MonadEffect m ⇒ a → m (Signal a /\ Atom a)
は Signal
と Atom
のペアを作成します。これらのペアは「状態」を表します。Signal
は出力、Atom
は入力です。 (React の useState と似たもの)
Signal
はモナドになっているほか、MonadEffect
のインスタンスであるため、任意の Effect
を埋め込めます。すなわち
do
hoge <- hogeSig
log hoge
は hoge の中身をログに残すという意味です。
launch_ :: Signal Unit → Effect Unit
は React での useEffect
に似ています。与えられた Signal
を、依存する状態が更新されるたび実行します。 useEffect
と違うのは、依存関係を書かなくて良いところです。上の例では状態 hoge
が更新されるたびに、「hoge
の中身をログに残す」が実行されます。
また、 Atom
は入力と述べましたが
modifyAtom_ :: ∀ a. Eq a ⇒ Atom a → (a -> a) → Effect Unit
や
writeAtom :: ∀ a. Eq a ⇒ Atom a → a → Effect Unit
を通して、状態の中身を更新することができます。
上記例では modifyAtom_
で変数の中身に "!"
を追加、writeAtom
で "hoge!!"
に書き換えています。
既に launch_
を実行しているので、値を更新するたび出力がなされ、結果のようになります。
状態はいくつでも使うことができ、また、launch_
も何回でも実行できます。
main = do
hogeSig /\ hogeAtom <- signal "hoge"
fugaSig /\ fugaAtom <- signal "fuga"
piyoSig /\ piyoAtom <- signal "piyo"
launch_ do
hoge <- hogeSig
log hoge
launch_ do
fuga <- fugaSig
piyo <- piyoSig
log $ fuga <> piyo
writeAtom hogeAtom "hoge!"
writeAtom fugaAtom "fuga!"
writeAtom piyoAtom "piyo!"
結果
hoge
fugapiyo
hoge!
fuga!piyo
fuga!piyo!
hoge
の値を書き換えると一回目の launch_
に渡した Signal
が実行され、fuga
あるいは piyo
の値を書き換えると二回目の launch_
に渡した Signal
が実行されます。
defer で次回更新まで処理を遅延する
例えば、ある値に依存してイベントリスナに登録する処理を走らせたいとします。このとき、値が更新されるたびにイベントリスナが登録されてしまうので、どんどんイベントリスナが増えてしまいます。この類の問題を防ぐには、 defer :: Effect Unit → Signal Unit
を使います。
main = do
hogeSig /\ hogeAtom <- signal "hoge"
launch_ do
hoge <- hogeSig
log hoge
defer $ log $ "defer: " <> hoge
log "1"
writeAtom hogeAtom "hoge!"
log "2"
writeAtom hogeAtom "hoge!!"
結果
hoge
1
defer: hoge
hoge!
2
defer: hoge!
hoge!!
1 回目の writeAtom
が実行され、launch_
に渡した処理が実行される前に、defer
に渡した処理が挟まります。これは React の useEffect
の返り値に似ています。
Signal に必要な動作まとめ
ここまでで、Signal
がどのような動作をする必要があるのか分かったのでまとめます。
- 状態の更新に合わせて処理を実行
- defer による遅延
- 依存関係の自動解決
Signal の仕組み
ここからこれらの動作を実現する Signal の仕組みを解説しますが、実は意外と単純にできています。
また Signal のコアは JavaScript で実装されている (PureScript から FFI しています) ので、ここからは主に JavaScript を用いて解説します。
状態の更新に合わせて処理を実行
まずは状態の更新に合わせて処理を実行する方法を考えます。最初に状態を作成するのには次のようにするのはどうでしょうか。
const atom = {
effects: new Set(),
value: "hoge",
};
このような構造を Atom
と呼びましょう (若干違いますが、Jelly での Atom
に相当します。)
状態 atom
に依存した処理を追加したい時は次のようにします。(初回実行も忘れずに!)
const effect = () => {
console.log(atom.value);
};
effect();
atom.effects.add(effect);
これで atom.effects
に処理 effect
が追加されました。さらに atom
を書き換える時に effects
を実行してやります。
atom.value = "hoge!";
[...atom.effects].forEach((effect) => effect());
このようにすれば、状態の更新に合わせて処理を実行することができます。最後にこれらの操作を一般につかえるように関数にまとめてしまいましょう。
- 状態の作成
const createAtom = (value) => ({
effects: new Set(),
value,
});
- 状態を読み込む
const readAtom = (atom) => atom.value;
- 状態の更新
const writeAtom = (atom, value) => {
atom.value = value;
[...atom.effects].forEach((effect) => effect());
};
- 状態に依存する処理の追加
const launch = (atom, effect) => {
effect();
atom.effects.add(effect);
};
defer による遅延
次は defer
による遅延です。遅延されるものは処理 effect
と一緒に保存するのがよいでしょう。
const effect = () => {
console.log(hoge.value);
};
const observer = {
effect,
callbacks: new Set();
}
このような構造を Observer
と呼びましょう
注: 名前はすごい適当です。Observer とか Signal などの概念ががプログラミングにはすでに存在した気がしないでもないですが、全く関係ないです
遅延する処理を追加する時は次のようにします。
const callback = () => {
console.log("defer: " + hoge.value);
};
observer.callbacks.add(callback);
ただ、これでは effect と分離しています。effect の中で追加するために、次のようにします。
const effect = (obs) => {
console.log(hoge.value);
const callback = () => {
console.log("defer: " + hoge.value);
};
obs.callbacks.add(callback);
};
const observer = {
effect,
callbacks: new Set();
}
effect
の引数に Observer
を追加しました。これで effect
の中で Observer
を操作することができます。
上で作成した関数にも諸々の処理を追加しましょう。
- 状態の作成 (effects ではなく observers に変更)
const createAtom = (value) => ({
observers: new Set(),
value,
});
- 状態を読み込む (変わらず)
const readAtom = (atom) => atom.value;
- 状態の更新 (更新時に callbacks を実行して clear するようにした。effect は Observer を引数にとることに注意)
const writeAtom = (atom, value) => {
atom.value = value;
[...atom.observers].forEach((obs) => {
const callbacks = [...obs.callbacks];
obs.callbacks.clear();
callbacks.forEach((callback) => callback());
obs.effect(obs);
});
};
- 状態に依存する処理の追加 (Observer を作成してから追加するようにした。effect は Observer を引数にとることに注意)
const launch = (atom, effect) => {
const observer = {
effect,
callbacks: new Set(),
};
effect(observer);
atom.observers.add(observer);
};
- 遅延処理の追加 (Observer を引数にとることに注意)
const defer = (obs, callback) => {
obs.callbacks.add(callback);
};
依存関係の自動解決
最後に依存関係の自動解決ですが、実はここまでの実装から readAtom
を変更するだけで実現できます。
const readAtom = (obs, atom) => {
atom.observers.add(obs);
defer(obs, () => atom.observers.delete(obs));
return atom.value;
};
Observer
を引数にとるようにしました。これによって、Atom
に直接依存関係を追加できます。また、次の effect
実行時に依存関係が再び追加されてしまうので、observers
から削除する処理を defer
を使って追加しています。
また、この自動解決によって launch
の引数を effect
だけにすることができます。
const launch = (effect) => {
const observer = {
effect,
callbacks: new Set(),
};
effect(observer);
};
これまでの関数と使い方
いったん全てまとめてみましょう。
// 状態の作成
const createAtom = (value) => ({
observers: new Set(),
value,
});
// 状態を読み込む
const readAtom = (obs, atom) => {
atom.observers.add(obs);
defer(obs, () => atom.observers.delete(obs));
return atom.value;
};
// 状態の更新
const writeAtom = (atom, value) => {
atom.value = value;
[...atom.observers].forEach((obs) => {
const callbacks = [...obs.callbacks];
obs.callbacks.clear();
callbacks.forEach((callback) => callback());
obs.effect(obs);
});
};
// 状態に依存する処理の追加
const launch = (effect) => {
const observer = {
effect,
callbacks: new Set(),
};
effect(observer);
};
// 遅延処理の追加
const defer = (obs, callback) => {
obs.callbacks.add(callback);
};
使い方は次のようになります。
const hoge = createAtom(0);
launch((obs) => {
const value = readAtom(obs, hoge);
defer(obs, () => console.log("defer: " + value));
console.log(value);
});
console.log("write: 1");
writeAtom(hoge, 1);
console.log("write: 2");
writeAtom(hoge, 2);
結果
0
write: 1
defer: 0
1
write: 2
defer: 1
2
うまく動いてますね!
ReaderT モナドで Observer を隠す
さて、このままでは obs
が表に現れていて若干使い勝手が悪いです。理想は次のように使えることでしょう。
const hoge = createAtom(0);
launch(() => {
const value = readAtom(hoge);
defer(() => console.log("defer: " + value));
console.log(value);
});
JavaScript ではグローバル変数などを使うなどしか思いつかないですが、PureScript ではもっと安全な方法があり、それが ReaderT
モナドです。
newtype Signal a = Signal (ReaderT Observer Effect a)
これが Signal
です! Signal
の中ではいつでも Observer
が呼び出せるので、readAtom
や defer
の obs
を隠すことができます。例えば、defer
は次のように実装されています。
defer :: Effect Unit -> Signal Unit
defer callback = do
obs <- ask -- ここで obs を取り出す
liftEffect $ addObserverCallback obs callback -- obs.callbacks.add(callback); と同じ
また他にも PureScript での Effect 周りの追加などあるので、実際の実装はリポジトリを覗いて見てください。
Jelly 本体の仕組み (軽く)
これによってある状態と、その状態に依存する処理を紐付けることができました。これらを使えば仮想 DOM を使わずとも、状態と DOM を紐付けることができます。
例えば Text ノードの中身をある状態に合わせて変えるには次のようにします。
const hoge = createAtom(0);
const text = document.createTextNode("");
launch((obs) => {
const value = readAtom(obs, hoge);
text.textContent = value.toString();
});
document.body.appendChild(text);
setInterval(() => {
writeAtom(hoge, hoge.value + 1);
}, 1000);
適当に html を作って
<html>
<head>
</head>
<body>
<script src="test.js"></script>
</body>
</html>
ブラウザで開くと、1 秒ごとに Text ノードの数字が増えていくことが確認できます。
Jelly はこれらを PureScript で実装しているだけです。 (ただ、実際の実装がかなり汚いのであまり見せられたものではない)
まとめ
状態と依存関係を自動解決する Signal
を構成し、DOM に反映することができました。今後も Jelly は開発していきたいと思っています。
例えば、Signal
関係の操作は単純とはいえだいぶごたごたしているので、もうちょっと整理できる場所があるのではないかと考えています (高速化につながりそう)。他のリアクティビティベースのフレームワーク (SolidJS) などの実装も覗いてみたいです。
次回予告
明日のブログリレーの担当は @yashu さんです。楽しみ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~