Guide / Reinhardt Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9
好きな言語でリアクティブUIを作るには?
この記事の内容
好きな言語でリアクティブUIを作る仕組みを調べます。 この動画ではMoonBitを実装例に使い、Reinhardtの依存関係に基づく更新をWebAssembly経由で再現します。小さく動く核を作り、好きな言語へ移す際に保つデータ構造と挙動の検査を説明します。
RustとMoonBitの実験をダウンロード。展開してframework-internals/README.mdに従ってください。Rust 1.96.0、Moon CLI 0.1.20260827、moonc 0.10.11+6ff76a5f9、Node 22.22.1、Python 3.12、ネイティブのCツールチェーンで検証しています。初回ビルドでは依存を取得します。SQLiteの実験はそれぞれ別のメモリ内DBを使い、サーバーは不要です。
rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --version 0.2.126 --locked
python3 verify.py --wasm
python3 -m http.server 8927 --bind 127.0.0.1 --directory wasm-uiWasm GC対応ブラウザでhttp://127.0.0.1:8927/を開き、Run comparison checksを押してください。NodeでのABI検証と実ブラウザでの比較は、それぞれ異なる境界を確認します。
Two runtimes, one visible result
1回のクリックでカウンターを3回変え、表示には最後の値を出したいとします。 ReinhardtとMoonBitの両方で値は3になり、テキストのEffectは追加で1回実行されます。 Reinhardtの実際のRustのリアクティブランタイムをたどり、その更新経路をMoonBitとWebAssemblyで再現します。
調べる対象はマウントしたカウンターと依存関係で、React全体の実装ではありません。 両モジュールをブラウザで動かし、実際に更新したノードを比較します。
A read records who needs an update
カウントを保存するSignalと、それをTextノードへ書くEffectから始めます。 Effectは、読み取った値によって、どの変更時に再実行するかが決まるコールバックです。 コールバックの実行中に追跡付きでSignalを読むと、現在の処理が依存先として記録されます。
後の書き込みでは、仮想的なツリー全体を比較せずに、影響を受けるコールバックを選べます。 Effectは最初の表示を作るために初回実行され、その後はランタイムが通知を処理すると再実行されます。 今回の小さな再現実装では、Reinhardtの設計のこの部分を追います。
untrackedの読み取りは、その後の変更を購読せずに現在の値を取得します。そのため、返る値が同じでも、追跡付きの読み取りを置き換えると動作が変わります。
Use the actual Pages binding
Rustのブラウザ版ではリアクティブスコープを作り、カウンターがある間は保持します。 Reinhardt Pagesを使い、countのSignalを要素のdata属性に結び付けます。 フレームワークのメソッドは、Signalを読んで属性を設定するEffectを作ります。
サンプルではさらに、既存のTextノードのデータを変えるEffectを加えます。 カウンターが数えるのはそのテキストの処理だけで、フレームワークのEffect全体やブラウザの描画回数ではありません。 ソースで操作を確認できるため、MoonBit版にも同じ期待値を設定できます。
実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。
let count = scope.enter(|| {
let count = Signal::new(0);
Element::new(root).set_reactive_attribute("data-count", count);
let runs = text_runs.clone();
Effect::new(move || {
text.set_data(&count.get().to_string());
runs.set(runs.get() + 1);
});
count
});Compare the actual browser panels
両方のWebAssemblyモジュールをビルドして、付属のページを開きます。 初期画面では、両方のパネルに0、テキストEffectの実行回数に1が表示されます。 Signalへの書き込みが処理を予約し、現在のイベント処理の後にホストが両ランタイムをflushします。
1つのイベントで3回書くと値は3になり、テキストの実行回数は2までしか増えません。 Resetでは再び0を表示し、実行回数は3になります。 結び付けた属性とテキストの値が一致し、元のTextノードも同じオブジェクトのままです。
これは実ブラウザの画面を編集して静止表示したもので、時間のベンチマークではありません。
Build the dependency kernel in MoonBit
MoonBitのランタイムは、Signal、コールバック、実行待ちの処理を保存します。 追跡付きの読み取りで、現在の処理をSignalの購読者に加えます。 書き込みでは購読者を1回だけ予約するため、flush前に繰り返し書いても同じ処理を重複させません。
コールバックを再実行する前に古い購読を外し、今回読んだ値を新たに記録します。 分岐を切り替えるassertで、Effectが読まなくなったSignalから再実行されないことを確認します。 左右の値を選ぶ処理を考えてください。右へ切り替えた後は左を変更しても実行回数が変わらないはずです。表示の数値だけを見ていると、不要な再実行を見落とします。
同じ核をSignal編とブラウザ例の両方に使い、状態の更新をJavaScript側に隠してはいません。
実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。
fn[T] Runtime::run_effect(self : Runtime[T], id : Int) -> Unit {
for slot in self.slots {
slot.subscribers.retain(other => other != id)
}
let previous = self.observer
self.observer = Some(id)
self.effects[id]()
self.observer = previous
}Cross the ABI and preserve lifetime rules
MoonBitではこの核をWasm GCへコンパイルし、Rustのモジュールにはwasm-bindgenを使います。 メモリの表現が異なるため、今回はホストとの境界で整数値だけを受け渡します。 大きなデータ構造を境界越しに渡す前に、整数の関数の公開とホスト関数の呼び出しを1つずつ確認します。WebAssembly対応でも、すべてのバックエンドが同じブラウザインターフェースを持つとは限りません。
JavaScriptがDOMノードを所有し、テキストを設定する操作をMoonBitへ公開します。 検証したSignalの経路では同じ整数を書いても通知されるので、さらにResetするとEffectが再実行されます。 所有者を解放するとリアクティブな保存場所が片付き、両方とも後からの書き込みを拒否します。
比較ボタンでは、表示の値だけでなく、これらの境界も検証します。
実行可能なソースからの抜粋です。プログラム全体はダウンロードに含めています。
fn set_text(value : Int) -> Unit = "dom" "set_text"
///|
let runtime : @reactive.Runtime[Int] = @reactive.Runtime::new(value => value)
///|
let count : @reactive.Signal[Int] = runtime.signal(0)Follow one additional dependency
2つ目のSignalを追加し、その値だけを変えるとどのEffectが動くか予想してみてください。 MoonBitのキューを変える前に固定したランタイムを読み、期待する実行回数をassertとして残します。 ダウンロードにはNodeのABI検証と別のブラウザ比較があり、どちらも速度を測るものではありません。
この単一所有者のカウンターでは、コンポーネントツリー全体、サーバー描画、hydration、完全な寿命管理を省いています。 Reinhardt Pressは実際のソースを示し、小さな再現例から、具体的な質問や貢献へつなげられるようにしています。
好きな言語へ仕組みを持ち帰る
持ち帰れる要素は、値の保存場所、購読者のリスト、重複を除いたキューです。好きな言語のレコードやコレクションで表せます。
ブラウザーでは、WebAssemblyへのコンパイル先を選び、DOMを書き換えるホストとの境界を定義します。境界には整数IDを使い、3回の書き込みで同じノードを1回更新することを確認します。
ソースの対応と検証の限界
PORTING.mdに、固定したフレームワークの関数と実行できるMoonBit実装の対応、および省略した機能を記載しています。ランタイムの表現は異なり、完全な移植ではなく範囲を限定した調査です。Node 22のSQLite APIではexperimentalの警告が出ます。