English

hypersig を選ぶ理由

hypersig はページをサーバーで描画し、その動きを HTML に書き込みます。ブラウザが扱う文字列はすべて Rust の型から作られるので、ブラウザ向けのビルドもハイドレーションも要らず、手で書く文字列もありません。

サーバー側とブラウザ側

Rust のサーバーが配信するページは、サーバー側とブラウザ側の 2 つに分かれます。サーバー側は HTML を組み立ててリクエストに答えます。ブラウザ側はクリックや入力に反応し、ブラウザが実行できるのは JavaScript か wasm だけです。hypersig に近いフレームワークが 2 つあり、どちらもブラウザの中にシグナルを持ち、ブラウザ向けには何もビルドしません。ただ、どちらにも検査されずに残るものがあります。

  • Datastar は HTML を送り、動きを属性に書きます。ハイドレーションはなく、サーバーは Server-Sent Events で要素とシグナルをパッチします。ただ、属性に書くシグナル名、URL、式は文字列なので、どれかに打ち間違いがあっても、ブラウザが実行するまで分かりません。
  • Topcoat はブラウザで動く式を Rust で書き、JavaScript に変換するので、式の型は検査されます。ただ、href! が検査するのはリンク先のハンドラだけで、パスパラメータが足りないか多いと、ページを描くときにパニックします。また、式から一覧に項目を足せないので、一覧はサーバーが答えて描き直したときにしか変わりません。

hypersig は Datastar と同じく、ページを属性付きの HTML で組み立て、Rust がすべての属性を型の付いた値から書きます。リンクはルートのパラメータを型の付いた値で受け取り、一覧はサーバーが答える前にブラウザが変えられるシグナルです。

HTML に書かれるページの動き

クイックスタート のカウンターに、リンクを 1 つ足したものです。HISTORY は、カウントをパスに含むページです。

use hypersig::html::{a, button, div, span};
use hypersig::{Action, Route};

#[derive(Signals, Serialize, Deserialize)]
struct Counter {
    count: i32,
}

hypersig::routes! {
    fn pages(Router<()>);
    const HISTORY: Route<i32> = "/history/{count}" => history;
}

const RESET: Action<Counter> = Action::post("/reset");

let count = Counter::count();
let page = Page::new("Counter", Counter { count: 0 }).child(
    div()
        .child(button().on_click(count.set(count + 1)).text("+1"))
        .child(span().bind_text(count.to_text()))
        .child(button().on_click(RESET.call()).text("Reset"))
        .child(a().href(HISTORY.link_with(count)).text("History")),
);

Page::render は次の body を書きます。読みやすいように、HTML の文字参照を元の文字に戻して示します。

<body data-hs-signals='{"count":0}'>
  <div>
    <button data-hs-on-click='["set","count",["add",["sig","count"],["lit",1]]]'>+1</button>
    <span data-hs-text='["str",["sig","count"]]'>0</span>
    <button data-hs-on-click='["call","POST","/reset",null,null,null]'>Reset</button>
    <a href="/history/0"
       data-hs-attr='[["href",["concat",["lit","/history/"],["uri",["sig","count"]]]]]'>History</a>
  </div>
</body>

それぞれの属性は Rust のコードの値 1 つから作られます。data-hs-signals は Counter 構造体をシリアライズしたものです。data-hs-on-click は文 count.set(count + 1) を JSON にしたもので、その中の count は Counter::count() が指すパスです。/reset は RESET のパスで、href は HISTORY から作られます。フィールド名の打ち間違い、型の違う値、ルートが受け取らないパラメータを渡すリンクは、この HTML が書かれる前にコンパイルエラーになります。

ハイドレーションをしない

ページのコードは Rust で書かれ、ブラウザには送られません。ページを読み込むと、ランタイムは data-hs-signals から状態を読んでストアに入れ、<body> にイベントの種類ごとに 1 つのリスナーを付け、data-hs-* 属性を読みます。ブラウザが実行する式と文はすべて属性に入っているので、コンポーネントのコードをダウンロードすることも、コンポーネントを実行し直すこともありません。Qwik の resumability と同じく、状態とリスナーは HTML に含めて送られます。

Page::render は束縛も初期値で評価するので、HTML の時点で 0 が表示され、リンクも /history/0 を指しています。ランタイムを読み込む前からページは正しく表示され、ランタイムはどのページでも同じファイルです。大きさは約 42 KB(gzip で約 14 KB)で、クレートに埋め込まれています。

画面からサーバーまで型で検査

属性を正しく保つ決まりは、手で書いた文字列をブラウザに渡さないことです。文字列の種類ごとに、それを作る型があります。

  • シグナルの名前は #[derive(Signals)] から作られます。この derive はフィールドごとに型付きのアクセサを 1 つ足します。
  • 式と文は Expr と Stmt の値で、その演算子は両辺の型を検査します。
  • アクションの URL は Action<S, A> から作られます。この型は、アクションが属するページと引数の型も決めます。
  • ページへのリンクは Route<P, Q> から作られます。ルートは配信するハンドラと一緒に routes! で宣言するので、リンクは正しいパスのパラメータとクエリを持ちます。
  • 差分を適用する先は Fragment::ID から作られます。ページと差分は同じ定数を読みます。
  • DOM の呼び出しで使う要素の名前は NodeRef から作られます。
use hypersig::Signals;
use serde::{Deserialize, Serialize};

#[derive(Signals, Serialize, Deserialize)]
struct Counter {
    count: i32,
}

// error[E0277]: `&str` is not an expression of type `i32` on the page whose signals are `Counter`
let reset = Counter::count().set("zero");

状態はサーバーが持つ

ブラウザはページの上で変わるものをシグナルとして持ち、データはサーバーが持ちます。アクションは Server-Sent Events で差分を返します。差分はシグナルの新しい値か、ページの要素と差し替える HTML の断片です。文はブラウザでただちに実行され、一緒に送ったアクションの呼び出しはそのあとでサーバーに届くので、サーバーの応答を待たずに画面が変わります。そのための追加のコードは要りません。アクション、差分、ストリームは サーバーとのやり取り で説明します。

データを取る単位

RSC と Next.js では、データを取得する処理をサーバーコンポーネントの中に await で書くので、取得はコンポーネントごとに別々に始まり、結果は Suspense の境界ごとに届いた順で表示されます。境界の置き方しだいで画面が少しずつ埋まり、親の await が終わるまで子の取得が始まらない書き方では、取得が直列に並びます。Remix と TanStack Start では、ルートごとの loader がページを表示する前に並列に実行されます。ただし変更のあとにも loader が再実行されるので、チェックボックスを 1 つ切り替えただけでもページや一覧のデータを取得し直します。

hypersig では、最初のデータはページの HTML にシグナル構造体の JSON として入っているので、表示のためにブラウザからデータを取得することはありません。変更のあとはアクションのハンドラが変わったシグナルの値だけを Counter::count().patch(0) のように返し、ブラウザはその値を使っている表示だけを書き換えるので、チェックボックスを 1 つ切り替えてもページや一覧を取得し直すことはありません。

読み込みと更新でブラウザが実行するものを、ほかのフレームワークと比べると次のようになります。表の「エフェクト」は、シグナルの値を読んで DOM を書き換える処理で、hypersig では data-hs-text や data-hs-show のような表示の属性 1 つにつき 1 つあります。

hypersigReactVueSolidQwik
ブラウザに送るアプリのコードなし(式は HTML の属性にデータとして入る)コンポーネント全部コンポーネント全部コンポーネント全部操作されたハンドラの分だけ、操作したときに
読み込み時にブラウザが実行するものすべてのエフェクトを 1 回ずつコンポーネントのツリー全体コンポーネントのツリー全体各コンポーネントを 1 回(リアクティブな依存関係を作る)なし(状態とハンドラの場所は HTML に入っている)
更新で再実行する範囲値を読んだエフェクトだけ状態を持つコンポーネントと子孫コンポーネントの描画関数(仮想 DOM で差分を取る)値を読んだエフェクトだけ値を読んだエフェクトだけ

負担になるのは失敗したときです。サーバーがエラーを返しても、ブラウザで変えた値はそのまま残り、ひとりでに元へ戻ることはありません。Call::on_error は呼び出しを書いた場所で文を実行するので、保存に失敗した 1 つの変更だけを元に戻したり、印を付けたりできます。

引き換えにあきらめるもの

  • ブラウザで実行できるのは、Expr と Stmt で書けることだけです。四則演算、比較、テキスト、リスト、条件、サーバーと DOM の呼び出しが書けます。それ以外の計算はサーバーで実行します。束縛と文のほかにページから使えるブラウザの機能と、JavaScript のライブラリが必要で hypersig に向かないアプリケーションは、束縛と文で足りないとき で説明します。
  • シグナルの値は JSON で送るので、シグナルの型は serde のトレイトを実装します。i64 は 2^53 を超えると、ブラウザで精度が落ちます。
  • リンクをたどると、次のページをサーバーから読み込みます。今のページを画面に残したまま移るクライアント側の遷移はありません。
  • ページは axum で配信します。hypersig が組み込みで対応しているサーバーは axum だけです。

次は クイックスタート で、このカウンターを作って動かします。