4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rust+WasmでSPAフレームワークを自作してみた

4
Last updated at Posted at 2025-12-01

ジョブカン事業部のアドベントカレンダー2日目です。

今回は気がついたらWasm上でSPAフレームワークを自作していましたので、一部ですが記事にしてみたいと思います。

Wasmとは

WasmとはWebAssemblyの略で、ブラウザ上で動作するバイナリフォーマットの実行環境です。
基本的にブラウザではJavaScriptを動かすと思いますが、C++やRust、Goなどのコンパイル言語をブラウザ上で動かすことで高速なアプリケーションを作ることができます。(rubyとかも動かせるようになってきたりしていますが)

なぜSPAフレームワークの自作を?

背景

自分しか使わないwebサービスを自宅にホストしているのですが、「可能な限りメンテナンスしなくても動作する」をコンセプトにしています。
例えばライブラリのアップデートを1年サボっても、「ビルドできなくなってる」みたいなことが無いようにしたいイメージです。

Rustでフロントエンドを書く

上記を実現するためにnpmパッケージを使わないでフロントエンドを書けないかと思い立ち、バックエンド・フロントエンド両方にRustを使うことにしました。

気がついたら…

最初は既存のSPAフレームワークを使って実装をしていたのですが、Rustなのに実行時エラーに出くわすようになってきました。
ChatGPTに聞くと、Wasm特有の問題だったりリアクティブなUIをRustで作る際のトレードオフみたいな回答が返ってきました。

なぜRustで実行時エラーになってしまうのか、何をトレードオフで得ているのかが気になりだしたタイミングで気がついたらフレームワークを作り出してました。

開発環境構築

Wasmを動かす

まずは、Rust側からconsole.logを出すだけの環境を作ってみたいと思います。
webサーバーの立ち上げと、ビルドされたwasmバイナリをフロントエンドに繋ぐ部分をよしなにやってくれるTrunkというツールを使います。
RustとJavaScriptの境界はwasm-bindgenweb-sysというライブラリがうまいことしてくれています。

Trunk.toml
[build]
public_url = "/"

[serve]
address = "127.0.0.1"
port = 8080
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>アドカレ</title>
    <link data-trunk rel="rust" />
  </head>
  <body>
    <div id="app-root"></div>
  </body>
</html>
Cargo.toml
[package]
name = "adcal2025"
version = "0.1.0"
edition = "2024"

[dependencies]
web-sys = { version = "0.3.82", features = ["console"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
main.rs
use web_sys::console;

fn main() {
    console::log_1(&"Hello, world!".into());
}
$ trunk serve

立ち上がったサーバーにアクセスすると、開発者ツールのコンソールにHello, world!が出ています。
image.png

HTMLを出力する

特定の要素の下に、Rust側からHTMLを埋め込んでみたいと思います。

main.rs
fn main() {
    let window = web_sys::window().unwrap();
    let document = window.document().unwrap();
    let root_elm = document.get_element_by_id("app-root").unwrap();
    root_elm.set_inner_html("<h1>Hello, World!</h1>");
}

細かいところは違いますが、JavaScriptで似たようなコードを見たことが有るのではないでしょうか。

ちなみに、web-sysを使う際には都度何を使うのかをCargo.tomlfeaturesに書く必要があり、このコードを動かすには"Window", "Document", "Element"を追加する必要があります。

web-sys = { version = "0.3.82", features = ["console", "Window", "Document", "Element"] }

これを実行すると、無事h1要素がDOMに反映されました。
image.png
image.png

SPAフレームワークを作る

ここまでで、Rust側からHTMLを表示する基本的な部分はできたため、フレームワークを作っていきたいと思います。

基本設計

設計ですが、今回は下記方針で行おうと思います。

  • UIを宣言的に定義する
  • 簡単な画面のみを想定しているため、パフォーマンスは多少落ちても問題ない
  • イベントはrootだけをlistenする(後述)

宣言的UI部分を作る

まずは宣言的にUIを定義して、それをDOMに反映できるところまでを行いたいと思います。
対応内容は下記のとおりです。

  • Nodeを定義してツリー構造を持てるように
  • メソッドチェインでNodeツリーを定義できるように
  • Nodeツリーの描画

一気に作ったものがこちらです。

main.rs
use web_sys::console;
use std::rc::Rc;
use std::cell::RefCell;

type NodeRef = Rc<RefCell<Node>>;
struct Node {
    tag: String,
    inner_html: Option<String>,
    children: Vec<NodeRef>,
}

impl Node {
    fn new(tag: &str) -> Self {
        Self {
            tag: tag.to_string(),
            inner_html: None,
            children: vec![],
        }        
    }
    
    fn text(mut self, text: &str) -> Self {
        self.inner_html = Some(text.to_string());
        self
    }
    
    fn child(mut self, child: NodeRef) -> Self {
        self.children.push(child);
        self
    }
    
    fn into_ref(self) -> NodeRef {
        Rc::new(RefCell::new(self))
    }
}

fn ui() -> NodeRef {
    Node::new("div")
        .child(
            Node::new("div")
                .text("count: 0")
                .into_ref()
        )
        .child(
            Node::new("button")
                .text("+")
                .into_ref()
        )
        .into_ref()
}

fn render_node(node: &NodeRef) -> web_sys::Element {
    let node = node.borrow();
    let document = web_sys::window().unwrap().document().unwrap();
    let elem = document.create_element(&node.tag).unwrap();
    
    match &node.inner_html {
        Some(html) => {
            elem.set_inner_html(html);
        },
        None => {
            for child in &node.children {
                let child_elem = render_node(child);
                elem.append_child(&child_elem).unwrap();
            }
        }
    }
    
    elem
}

fn main() {
    let window = web_sys::window().unwrap();
    let document = window.document().unwrap();
    let root_elm = document.get_element_by_id("app-root").unwrap();
    
    let ui_tree = ui();
    let rendered_elem = render_node(&ui_tree);
    root_elm.append_child(&rendered_elem).unwrap();
}

カウントの表示と、ボタンが無事表示されました。(まだ、ボタンを押しても何も起きません)
image.png
image.png

イベントを実装する

UIを作ってブラウザに反映することはできましたが、ブラウザ側からイベントを受け取らないことには動きのあるアプリケーションを作ることはできません。

いくつかの既存フレームワークを見てみましたが、どのようにイベントを実装するのかはそれぞれの色が出るところのようです。例えばJavaScriptのイベントが発火したタイミングでRust側のNodeがすでに無いなどいくつか実行時エラーの種がありそうです。

今回私がフレームワーク自作をしたくなった理由の一つですが、このイベントハンドリングを一旦一箇所で受けてから、各ノードのイベントハンドラを呼び出す方針に出来ないかと思いました。これは、ゲームやWin32 APIから着想を得ています。(ゲームではinput→update→renderを毎フレーム呼び出すことで動いていたと思います)

具体的な実装としては、個々のDOM Nodeを個別にlistenするのではなく、rootだけをlistenしておいてRust側でメッセージを処理するようなイメージです。こうしておけば、RustとJavaScriptで認識の差異があった場合はイベントが空振りするだけなので、致命的なエラーになりづらく実装もシンプルなのではないかという試みです。

    let msg_proc = move |event: Event| {
        console::log_1(&format!("Event received: {:?}", event.type_()).into());
    };

    let closure = Closure::wrap(Box::new(msg_proc) as Box<dyn FnMut(Event)>);
    root_elm.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).unwrap();
    closure.forget();

色々書いていますが、やっていることは下記のとおりです。

  1. ブラウザからのイベントを受け取るコールバック関数msg_procを定義する
  2. root_elmのclickイベントをlistenする

これを動かして、ボタンやラベルをクリックするとconsole.logが出ます。
image.png

Closure::wrapについて

私も解説できるほどわかっていないのですが、callbackをwasm_bindgen経由でJavaScriptへ渡すときに、これに包んで渡す必要があります。
ちなみに上にあるmove |event: event| { ... }はRustのクロージャで、Closurewasm_bindgenのクロージャです。

closure.forget()について

ブラウザで起きたイベントをRust側のメソッドでハンドリングするには、JavaScript側にRustのメソッドがあるアドレスを渡しておいて、イベントが起きたときにはcallbackとして関数を呼んでもらう必要があります。
しかし、Rustはスコープを抜けたときにメモリを解放する仕様のため、イベントが起きた頃にはクロージャは解放されており呼ぶことができないということが発生してしまいます。

この対策として意図的にメモリリークを起こして、クロージャが解放されないようにする必要があります。(厳密にはwasm-bindgenが内部で色々やってくれているようです)

ちなみにこの行をコメントアウトしてクリックすると実際に実行時エラーになります。
image.png
Rustを使っているのに実行時エラーが起きてしまうので、イベント周りは注意したいところです。

stateを実装する

イベントを受け取れたので、クリックするたびにカウントアップするstateを作ってみたいと思います。
ひとまずシンプルに、mainメソッドにstateを持たせてuiメソッドに渡すようにしたいと思います。

type StateRef = Rc<RefCell<State>>;
struct State {
    counter: i32,
}

fn ui(state: StateRef) -> NodeRef {
    let state = state.borrow();
    
    Node::new("div")
        .child(
            Node::new("div")
                .text(&format!("Count: {}", state.counter))
                .into_ref()
        )
        .child(
            Node::new("button")
                .text("+")
                .into_ref()
        )
        .into_ref()
}

fn main() {
    let window = web_sys::window().unwrap();
    let document = window.document().unwrap();
    let root_elm = document.get_element_by_id("app-root").unwrap();
    
    let state = Rc::new(RefCell::new(State { counter: 0 }));
    
    let root_elm_for_first = root_elm.clone();
    let state_for_first = state.clone();

    let msg_proc = move |event: Event| {
        console::log_1(&format!("Event received: {:?}", event.type_()).into());
        
        // stateの更新
        let state_clone = state.clone();
        state_clone.borrow_mut().counter += 1;

        // 再レンダリング
        let ui_tree = ui(state.clone());
        let rendered_elem = render_node(&ui_tree);
        root_elm.set_inner_html(""); // 既存の内容をクリア
        root_elm.append_child(&rendered_elem).unwrap();
    };

    let closure = Closure::wrap(Box::new(msg_proc) as Box<dyn FnMut(Event)>);
    root_elm_for_first.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).unwrap();
    closure.forget();
    
    let ui_tree = ui(state_for_first.clone());
    let rendered_elem = render_node(&ui_tree);
    root_elm_for_first.append_child(&rendered_elem).unwrap();
}

やったことは以下のような感じです。

  1. State構造体を定義して、mainメソッドで持つ
  2. uiメソッドに渡して、count: 0 固定だったところをstate.counterを見るように変更
  3. msg_proc内でstateを更新
  4. 再レンダリング

msg_procのmovexxxx_for_first

Rustのクロージャにはクロージャ外の変数にアクセスするキャプチャという仕組みが存在します。このキャプチャ時に所有権ごと奪う場合につけるのがmoveです。
ここで所有権をもらっておかないと、mainメソッドを抜けたときにstateなどが解放されてしまい、実行時エラーになってしまうということだと理解しています。
実際にmoveを消すと、コンパイルは通りますがクリック時にエラーになります。

イベントが起きてクロージャが実行されるためには、その前にUIを描画してユーザが操作しないといけないため、初回描画を行う必要があります。
しかし、stateなどはクロージャに所有権を持っていかれてしまっているため、Rustコンパイラに怒られてしまいます。
この問題を解決するために、初回描画用にxxxx_for_firstとしてcloneしています。

クリック時のcallbackを宣言的UIで定義できるように

今は、msg_proc内でstateの更新をしているため、ボタン以外をクリックしてもカウントアップしてしまいます。
ボタンのUI定義をしているところでクリック時に呼ばれるメソッドを定義できるようにします。
※細かい実装は最後に載せているため、ここでは主要部分だけを抜き出しています。

struct Node {
    event_key: Uuid,
    tag: String,
    inner_html: Option<String>,
    on_click: Option<Box<dyn FnMut()>>,
    children: Vec<NodeRef>,
}

fn ui(state: StateRef) -> NodeRef {
    let state1 = state.clone();
    let state2 = state.clone();
    
    Node::new("div")
        .child(
            Node::new("div")
                .text(&format!("count: {}", state1.borrow().counter))
                .into_ref()
        )
        .child(
            Node::new("button")
                .text("+")
                .on_click(move || {
                    state2.borrow_mut().counter += 1;
                })
                .into_ref()
        )
        .into_ref()
}

fn trigger_callback(uuid_str: &str, node: &NodeRef) -> bool {
    let mut node_borrowed = node.borrow_mut();
    if node_borrowed.event_key.to_string() == uuid_str {
        if let Some(callback) = &mut node_borrowed.on_click {
            callback();
            return true;
        }
    }

    for child in &node_borrowed.children {
        if trigger_callback(uuid_str, child) {
            return true;
        }
    }

    false
}
  1. どのNodeがイベントを発したのかがわかるようにevent_keyをNodeに持たせ、data-uuidとしてレンダリングしておく
  2. Nodeにon_clickを持たせて、UIと一緒に定義できるようにする
  3. イベントが上がって来たらどのNodeのイベントか探してon_clickイベントを発火させる

これで、ボタン以外をクリックしたときにカウントアップされてしまう問題を解決できました。更に、ボタンを複数置いて別のイベントを扱うことができるようになりました。

サンプルアプリを作る

今回作ったフレームワークを使って実際に簡単なアプリを作ってみました。

image.png
image.png

サンプルアプリのコード
main.rs
use wasm_bindgen::prelude::*;
use web_sys::console;
use std::rc::Rc;
use std::cell::RefCell;
use web_sys::Event;
use uuid::Uuid;

type NodeRef = Rc<RefCell<Node>>;
struct Node {
    event_key: Uuid,
    tag: String,
    inner_html: Option<String>,
    on_click: Option<Box<dyn FnMut()>>,
    children: Vec<NodeRef>,
}

impl Node {
    fn new(tag: &str) -> Self {
        Self {
            event_key: Uuid::new_v4(),
            tag: tag.to_string(),
            inner_html: None,
            on_click: None,
            children: vec![],
        }        
    }
    
    fn text(mut self, text: &str) -> Self {
        self.inner_html = Some(text.to_string());
        self
    }

    fn on_click<F: 'static + FnMut()>(mut self, callback: F) -> Self {
        self.on_click = Some(Box::new(callback));
        self
    }

    fn child(mut self, child: NodeRef) -> Self {
        self.children.push(child);
        self
    }
    
    fn into_ref(self) -> NodeRef {
        Rc::new(RefCell::new(self))
    }
}

type StateRef = Rc<RefCell<State>>;
struct State {
    counter: i32,
    items: Vec<String>,
}

fn ui(state: StateRef) -> NodeRef {
    let state1 = state.clone();
    let state2 = state.clone();
    let state3 = state.clone();
    let state4 = state.clone();
    let state5 = state.clone();
    
    Node::new("div")
        .child(
            Node::new("h1")
                .text("アドカレ2025サンプルアプリ")
                .into_ref()
        )
        .child(
            Node::new("div")
                .child(
                    Node::new("h2")
                        .text("Counter")
                        .into_ref()
                )
                .child(
                    Node::new("div")
                        .text(&format!("count: {}", state1.borrow().counter))
                        .into_ref()
                )
                .child(
                    Node::new("button")
                        .text("+")
                        .on_click(move || {
                            console::log_1(&"Button clicked!".into());
                            state2.borrow_mut().counter += 1;
                        })
                        .into_ref()
                )
                .child(
                    Node::new("button")
                        .text("-")
                        .on_click(move || {
                            console::log_1(&"Button clicked!".into());
                            state3.borrow_mut().counter -= 1;
                        })
                        .into_ref()
                )
                .into_ref()
        )
        .child(
            Node::new("div")
                .child(
                    Node::new("h2")
                        .text("Items")
                        .into_ref()
                )
                .child(
                    Node::new("button")
                        .text("Add Item")
                        .on_click({
                            move || {
                                let new_item = format!("Item {}", state4.borrow().items.len() + 1);
                                state4.borrow_mut().items.push(new_item);
                            }
                        })
                        .into_ref()
                )
                .child(
                    Node::new("button")
                        .text("Clear Items")
                        .on_click({
                            move || {
                                state5.borrow_mut().items.clear();
                            }
                        })
                        .into_ref()
                )
                .child(
                    {
                        let mut list_node = Node::new("ul");
                        for item in &state.borrow().items {
                            list_node = list_node.child(
                                Node::new("li")
                                    .text(item)
                                    .into_ref()
                            );
                        }
                        list_node.into_ref()
                    }
                )
                .into_ref()
        )
        .into_ref()
}

fn render_node(node: &NodeRef) -> web_sys::Element {
    let node = node.borrow();
    let document = web_sys::window().unwrap().document().unwrap();
    let elem = document.create_element(&node.tag).unwrap();
    elem.set_attribute("data-uuid", &node.event_key.to_string()).unwrap();
    
    match &node.inner_html {
        Some(html) => {
            elem.set_inner_html(html);
        },
        None => {
            for child in &node.children {
                let child_elem = render_node(child);
                elem.append_child(&child_elem).unwrap();
            }
        }
    }
    
    elem
}

fn trigger_callback(uuid_str: &str, node: &NodeRef) -> bool {
    let mut node_borrowed = node.borrow_mut();
    if node_borrowed.event_key.to_string() == uuid_str {
        if let Some(callback) = &mut node_borrowed.on_click {
            callback();
            return true;
        }
    }

    for child in &node_borrowed.children {
        if trigger_callback(uuid_str, child) {
            return true;
        }
    }

    false
}

fn main() {
    let window = web_sys::window().unwrap();
    let document = window.document().unwrap();
    let root_elm = document.get_element_by_id("app-root").unwrap();

    let state = Rc::new(RefCell::new(State { counter: 0, items: Vec::new() }));

    let root_elm_for_first = root_elm.clone();
    let state_for_first = state.clone();

    let root_node = ui(state_for_first.clone());
    let root_node_for_first = root_node.clone();

    let msg_proc = move |event: Event| {
        console::log_1(&format!("Event received: {:?}", event.type_()).into());
        
        // イベント発火
        let target = event.target().unwrap();
        let target_elem = target.dyn_into::<web_sys::Element>().unwrap();
        let event_key = target_elem.get_attribute("data-uuid").unwrap();
        console::log_1(&format!("Event key: {}", event_key).into());
        
        trigger_callback(&event_key, &root_node);
        
        // 再レンダリング
        let new_tree = ui(state.clone());
        let rendered_elem = render_node(&new_tree);
        // root_nodeをnew_treeで置き換え
        std::mem::swap(&mut *root_node.borrow_mut(), &mut *new_tree.borrow_mut());

        root_elm.set_inner_html(""); // 既存の内容をクリア
        root_elm.append_child(&rendered_elem).unwrap();
    };

    let closure = Closure::wrap(Box::new(msg_proc) as Box<dyn FnMut(Event)>);
    root_elm_for_first.add_event_listener_with_callback("click", closure.as_ref().unchecked_ref()).unwrap();
    closure.forget();
    
    let rendered_elem = render_node(&root_node_for_first);
    root_elm_for_first.append_child(&rendered_elem).unwrap();
}
Cargo.toml
[package]
name = "adcal2025"
version = "0.1.0"
edition = "2024"

[dependencies]
web-sys = { version = "0.3.82", features = ["console", "Window", "Document", "Element", "Event"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
uuid = { version="1", features = ["v4", "js"]}
index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>アドカレ</title>
    <link data-trunk rel="rust" />
  </head>
  <body>
    <div id="app-root"></div>
  </body>
</html>
Trunk.toml
[build]
public_url = "/"

[serve]
address = "127.0.0.1"
port = 8080

今回実装しなかった機能について

実はSPA自作については少し前に試験実装をしていて、現在はフレームワークを切り出しながら清書している最中になります。今回アドカレを書くにあたって最初の試験実装を参考にシンプルに書き直しています。
今回書けませんでしたがすでに実装している機能もせっかくなので紹介したいと思います。

  • stateをglobal的ではなく、use_state的に扱う
  • click以外のイベントへの対応
    • Rust側で各イベントを再定義して、click以外のイベントもlistenするようにする
  • ルーティング機能
  • テキストボックスなどのbinding
    • 現状ツリー全体を再描画する仕様になっている影響で、changeイベントなどを拾っていると一文字打つごとに再レンダリングが走ってしまい、フォーカスが外れるような状態になってしまいます。そこで、再描画が発生しない形で入力を受け付けて後から中身が参照できるようにbindという仕組みを作っています。
  • RESTアクセスのような非同期処理

まとめ

今回作ったのは薄いフレームワークだったとはいえ、意外とシンプルに作れて面白かったです。
今回採用したイベントハンドリングの方式でどこまで行けるのか今後も育てていきたいと思います。

おわりに

DONUTSでは新卒中途問わず積極的に採用活動を行っています。
詳細はこちらをご確認ください。

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?