blog.takurinton.dev

Rustのmacroを使ってhtml rendererを作ったの続き

2026-03-08

こんにちは

どうもー。前回の記事 で render! macroを作ったよって話をして、最後に「hydration できるようにしたい」「仮想 DOM はあんまり積む気はなくて Svelte っぽいリアクティブを実装するといいんじゃないか」みたいなことを書いたわけなんだけれども、実際にやってみたのでその話をします。

今回は claude に色々やってもらった。(前回は使っていなかった)

やったこと

大きくは 3 つ。

1. render! macroを SSR/Client のdual modeに対応させた

2. WASM クライアントフレームワーク(Signal, Bindable, Router)を作った

3. SPA ルーティングを実装して、ページ遷移時にフルリロードしないようにした

要するに、前回の「SSG で HTML を吐くだけ」のブログが「SSG で HTML を吐きつつ、ブラウザ上では WASM が SPA ルーティングを担当する」という構成になりました。

render! macroのdual mode化

方針

前回の render! macroは String を組み立てるコードを生成するだけだった。今回はこれを拡張して、同じ render! の記法から 2 種類のコードを生成するようにした。

  • native target(SSG ビルド時): 従来通り String を組み立てる
  • wasm32 target(ブラウザ実行時): web_sys を使って imperative に DOM を構築する
  • 切り替えは #[cfg(target_arch = "wasm32")] で行う。proc-macro 自体はホスト(native)で実行されるので、macroの中で両方のコードを生成して cfg で囲むだけ。

    rust
    #[proc_macro]
    pub fn render(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
        let parsed = parse_macro_input!(input as tokenizer::Render);
        let tokens = parsed.tokens;
        let ssr = codegen::ssr::generate(tokens.clone());
        let client = codegen::client::generate(tokens);
        quote! {{
            #[cfg(not(target_arch = "wasm32"))]
            { #ssr }
            #[cfg(target_arch = "wasm32")]
            { #client }
        }}.into()
    }
    

    これ、戻り値の型が Stringweb_sys::Element で全然違うんだけど、generator は native-only、wasm crate は wasm32-only なので実際にはどちらか一方しかコンパイルされない。型の不整合が起きないのがこの設計のミソだと思う。

    リファクタリング

    dual mode化にあたって、前回 lib.rs に全部書いてた 300 行くらいのコードを分割した。

    (この下のtreeとかもclaudeに書いてもらったんだけど、便利)

    app/src/
    ├── lib.rs           # エントリポイント(cfg 分岐するだけ)
    ├── tokenizer.rs     # Token, Tokenizer, Render(前回の字句解析部分)
    ├── component.rs     # #[component] 属性macro
    └── codegen/
        ├── mod.rs
        ├── ssr.rs       # SSR コード生成(String push)
        └── client.rs    # Client コード生成(DOM 操作)
    

    tokenizer.rs は前回の lib.rs からほぼそのまま抽出。codegen/ssr.rsParser::create_node を関数として切り出しただけ。新しく書いたのは codegen/client.rs だけ。

    Client codegen の設計

    Client 側のコード生成は少し考える必要があった。SSR 側は Stringpush_str していくだけなのでシンプルだけど、Client 側は DOM ツリーを構築しないといけない。

    やったことは要素スタックの管理で、macro展開時に __el_0, __el_1, ... のような変数名を振っていく。

  • Open トークン → document.create_element() + 属性設定 + 親要素への append_child
  • Close トークン → スタックから pop(コード生成は何もしない)
  • Text トークン → document.create_text_node() + append_child
  • Braced { expr }create_text_node("") + Bindable::bind(expr, &node)
  • 生成されるコードはこんな感じになる。

    rust
    {
        let __doc = ::web_sys::window().unwrap().document().unwrap();
        let __el_0 = __doc.create_element("div").unwrap();
        __el_0.set_attribute("class", &::std::string::ToString::to_string(&("foo"))).unwrap();
        let __t_0 = __doc.create_text_node("hello");
        __el_0.append_child(&__t_0).unwrap();
        __el_0
    }
    

    WASM クライアントフレームワーク

    仮想 DOM について

    仮想 DOM は採用しなかった。React が宣言的 UI というパラダイムを広めたインパクトはすごいと思っていて、「状態を渡したら UI が決まる」というメンタルモデルはアプリケーション開発を大きく変えた。ただ、このブログに仮想 DOM は完全にオーバーエンジニアリングだと思う。ページ間で共通してる部分って header くらいで、動的に変わる UI もない。

    代わりに Svelte 式のコンパイル時コード生成を採用した。render! macroの展開時点で DOM 構築が全部決まるので、ランタイムの差分計算が要らない。WASM のバイナリサイズも小さくなる。

    Signal と Bindable

    リアクティブの仕組みとして SignalBindable trait を実装した。

    Signal は Svelte の reactive variable に相当するもので、値の変更を subscribe できる。

    rust
    pub struct Signal<T> {
        inner: Rc<RefCell<SignalInner<T>>>,
    }
    s
    impl<T: Clone + 'static> Signal<T> {
        pub fn new(value: T) -> Self { /* ... */ }
        pub fn get(&self) -> T { /* ... */ }
        pub fn set(&self, value: T) { /* ... */ }
        pub fn subscribe(&self, f: impl Fn(&T) + 'static) { /* ... */ }
    }
    

    Bindable trait は proc-macro と実行時コードの橋渡しをする。proc-macro は型情報を持てないので、生成コードで Bindable::bind(expr, &text_node) を呼ぶことで、String なら単に set_text_content するだけ、Signal<T> なら subscribe して自動更新する、というディスパッチを実現している。

    rust
    pub trait Bindable {
        fn bind(self, node: &web_sys::Text);
    }
    

    正直、今のブログだとリアクティブな更新をする箇所がないので Signal の出番はまだないんだけど、将来的にカウンタとか検索フィルタとかを入れたくなったときの土台として作っておいた。

    `#[component]` macro

    前回は別クレート(wasm-macro)に置いていた #[component] 属性macroを app クレートに統合した。これは Svelte ライクなリアクティブ変換をやるmacroで、let mut x = 0let x = Signal::new(0) に、x = 5x.set(5) に変換する。

    統合した理由は単純で、proc-macro クレートが 2 つに分かれてる意味がなかったから。render!#[component] も同じ app クレートから export すればユーザー(= generator と wasm)は app だけ依存すればいい。

    SPA ルーター

    設計

    SPA ルーターの設計にはかなり苦労した。というか、この部分が今回の実装で一番試行錯誤した。

    最初のアプローチは「遷移先のページを fetch して、レスポンスの HTML から <body> の中身を抜いて差し替える」というもので、これ自体はわりとすんなり動いた。問題はその周辺。

    スタイルシートの問題

    このブログは記事一覧ページと個別記事ページで異なる CSS を読み込んでいる(index.home.cssindex.post.css)。最初は SPA 遷移時にスタイルシートを動的に差し替えるアプローチを取ったんだけど、新しい CSS の読み込みが完了する前に body が差し替わるので一瞬ちらつきが起きてしまった。

    結局、全ページの SSG テンプレートに全 CSS を同期読み込みで含める方式に落ち着いた。CSS の総量がたいしたことないので、これで十分。

    highlight.js の問題

    シンタックスハイライトに highlight.js を CDN から読み込んでいるんだけど、SPA 遷移時に <script> タグを動的に注入しても、外部スクリプトは非同期で読み込まれるため hljs.highlightAll() の実行タイミングが合わない。

    これも CSS と同じアプローチで解決した。全ページで highlight.js を同期ロードしておいて、SPA 遷移時は hljs.highlightAll() を呼ぶだけにした。

    最終的な load_page

    試行錯誤の末、SPA 遷移の処理はこうなった。

    rust
    fn load_page(href: &str) {
        wasm_bindgen_futures::spawn_local(async move {
            // 1. fetch で遷移先の HTML を取得
            let resp = window.fetch_with_str(&href).await;
            let html = resp.text().await;
    
            // 2. DOMParser で HTML をパース
            let parser = DomParser::new();
            let new_doc = parser.parse_from_string(&html, SupportedType::TextHtml);
    
            // 3. <body> の中身を差し替え
            body.set_inner_html(&new_body.inner_html());
    
            // 4. <title> を更新
            doc.set_title(&new_title);
    
            // 5. highlight.js を再実行
            js_sys::eval("if(typeof hljs !== 'undefined') hljs.highlightAll()");
        });
    }
    

    シンプルになったでしょ。CSS とスクリプトの問題を SSG 側に寄せたことで、ルーター自体はかなりすっきりした。

    HTML を直接 fetch する設計

    SPA ルーターの面白いところとして、遷移時に JSON API や Markdown ではなく、SSG で生成済みの HTML をそのまま fetch している点がある。

    普通の SPA だと API サーバーから JSON を取得してクライアントでレンダリングするとか、あるいは Markdown を取得してクライアントでパースするとかが一般的だと思うんだけど、このブログではどちらもやっていない。/post/111/index.html みたいな URL をそのまま fetch して、レスポンスの HTML から <body> の中身を抜いて現在のページに差し込んでいる。

    この設計にした理由は、SSG との相性が良いから。このブログはビルド時に全ページの HTML を生成済みなので、API サーバーがそもそも存在しない。Vercel の静的ホスティングに乗っかっているだけ。だから fetch する先は普通の HTML ファイルになる。

    Markdown を fetch してクライアントで markdown_to_html() を呼ぶ方式も検討したんだけど、そうすると Markdown パーサーを WASM に含める必要があって、バイナリサイズが膨らむ。HTML を fetch する方式なら、パースは DOMParser がやってくれるのでクライアント側に余計な依存が要らない。

    あと、HTML を直接返す方式だとフォールバックが簡単という利点もある。WASM が読み込めなかった場合や JavaScript が無効な環境でも、普通にリンクをクリックすればブラウザが HTML を表示してくれる。Progressive Enhancement になっている。

    click intercept

    内部リンクのクリックをinterceptして SPA 遷移にするところも、地味に考えることがあった。

    <a> タグを直接クリックした場合だけでなく、<a> の中の <span> とかをクリックした場合もinterceptしないといけない。closest("a") でイベントターゲットから <a> タグを探す処理を入れている。

    外部リンク(href/ で始まらない)はinterceptしないようにしている。これを間違えると CDN のリンクとかまでinterceptしてしまって大変なことになる。

    まとめ

    前回の「SSG で HTML を吐くだけ」のブログが、WASM による SPA ルーティングを備えた構成になった。

  • render! macroが cfg(wasm32) で SSR/Client の 2 パスを生成する
  • 仮想 DOM ではなく Svelte 式のコンパイル時コード生成で DOM 操作を行う
  • Signal / Bindable によるリアクティブの土台ができた
  • SPA ルーターで内部リンクの遷移がフルリロードなしで動く
  • SPA 周りの設計は CSS やスクリプトの読み込みタイミングとの戦いで、最終的に「全部 SSG 側で同期ロードしておく」という力技に落ち着いた。エレガントではないけど、このブログの規模感には合っている。

    次やるとしたら、Signal を使ったインタラクティブなコンポーネント(記事検索とか)を実際に作ってみて、Bindable trait が本当にうまく機能するか検証したい。あとはビルド時間の最適化。wasm-pack のビルドが地味に遅いので、CI のキャッシュを工夫する必要がありそう。