feature image

2022年8月18日 | ブログ記事

仮想 DOM 不使用の自作 Web フレームワーク "Jelly" の仕組み

仮想 DOM 不使用の自作 Web UI フレームワークの仕組み【Jelly】

この記事は traP 夏ブログリレー 9 日目の記事です。

はじめに

こんにちは。21B のゆきくらげです。この度、Jelly という Web アプリのフロントエンド用フレームワークを作ったので、その仕組みを解説します。

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)

SignalAtom のペアを作成します。これらのペアは「状態」を表します。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 がどのような動作をする必要があるのか分かったのでまとめます。

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 を操作することができます。

上で作成した関数にも諸々の処理を追加しましょう。

const createAtom = (value) => ({
  observers: new Set(),
  value,
});
const readAtom = (atom) => 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 = (atom, effect) => {
  const observer = {
    effect,
    callbacks: new Set(),
  };
  effect(observer);
  atom.observers.add(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 が呼び出せるので、readAtomdeferobs を隠すことができます。例えば、defer は次のように実装されています。

defer :: Effect Unit -> Signal Unit
defer callback = do
  obs <- ask -- ここで obs を取り出す
  liftEffect $ addObserverCallback obs callback -- obs.callbacks.add(callback); と同じ

また他にも PureScript での Effect 周りの追加などあるので、実際の実装はリポジトリを覗いて見てください。

Signal.purs
Signal.js

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 さんです。楽しみ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

yukikurage icon
この記事を書いた人
yukikurage

色々しますよ

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2022年9月26日
競プロしかシラン人間が web アプリ QK Judge を作った話
tqk icon tqk
2022年9月16日
5日でゲームを作った #tararira
Komichi icon Komichi
2023年9月27日
夏のブログリレーは終わらない【駄文】
Komichi icon Komichi
2023年9月13日
ブログリレーを支えるリマインダー
H1rono_K icon H1rono_K
2023年8月21日
名取さなになりたくてOBSと連携する配信画面を作った
d_etteiu8383 icon d_etteiu8383
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記