Open14

dioxus web やってみる

mizchimizchi

dioxus は Rust のGUIアプリケーションフレームワーク。
React を模した仮想DOM の APIで、 desktop アプリや wasm 吐き出しができる。

https://github.com/DioxusLabs/dioxus

公式チュートリアルは desktop 版だったのでブラウザ版を探したら、たどり辛いところにあった。

https://dioxuslabs.com/reference/web/index.html

Setup

# rust や cargo のセットアップは略
$ cargo install trunk
$ rustup target add wasm32-unknown-unknown

# 公式ドキュメントになにもないが、 cargo add コマンドは cargo-edit が必要
# rust ユーザーなら常識かもだが、久しぶりなので知らなかった…
$ cargo install cargo-edit

# bundler として trunk というビルダーをインストール
$ cargo install --locked trunk

trunk も知らなかった。

https://trunkrs.dev/

API としては vite や parcel みたいに index.html を入力にとり、SPA を出力するバンドラ。

mizchimizchi

cargo でプロジェクトを作成

$ cargo new --bin demo && cd demo
$ cargo add dioxus --features web
index.html
<html>
  <head>
    <meta content="text/html;charset=utf-8" http-equiv="Content-Type" name="viewport" content="width=device-width, initial-scale=1.0" charset="UTF-8">
  </head>
  <body>
    <div id="main"> </div>
  </body>
</html>

rust のコード

src/main.rs
use dioxus::prelude::*;

fn main() {
    dioxus::web::launch(app);
}

fn app(cx: Scope) -> Element {
    cx.render(rsx!{
        div { "hello, wasm!" }
    })
}

rsx! のマクロで JSX のようなDOMツリーを宣言する。

この状態で trunk serve コマンドを叩くと http://localhost:8080 に開発用サーバーが立つ

mizchimizchi

ビルド

リリースの際は --release ビルドを行う

$ trunk build --release

dist に出力される

$  ls -lh dist/* | awk '{print $9,$5}'
dist/index-11dc12392903d87.js 35K
dist/index-11dc12392903d87_bg.wasm 315K
dist/index.html 463B

Hello World 時点でビルドサイズは315K, gzip して 105K ほどだった。

巨大すぎるほどというわけではないが、ライブラリとして提供したり、普通のウェブサイトに埋め込むには厳しい。

mizchimizchi

Counter の実装

use dioxus::prelude::*;
fn main() {
    dioxus::web::launch(app);
}

fn app(cx: Scope) -> Element {
    let mut count = use_state(&cx, || 0);
    cx.render(rsx!{
        div {}, "count: { count }"
        button { onclick: move |_| count += 1, "++" }
    })
}
mizchimizchi

いろいろな rsx

use dioxus::prelude::*;

fn main() {
    dioxus::web::launch(app);
}

#[derive(Props, PartialEq)]
struct MyProps<'a> {
    name: &'a str
}

fn MyComponent<'a>(cx: Scope<'a, MyProps<'a>>) -> Element {
    cx.render(rsx! {
        div {
            "sub"
        }
    })
}

fn app(cx: Scope) -> Element {
    let mut count = use_state(&cx, || 0);
    let user_name = Some("bob");
    let names = ["jim", "bob", "jane", "doe"];

    let name_list = names.iter().map(|name| rsx!(
        li { "{name}" }
    ));

    let el = rsx!(
        div {
            user_name.map(|name| rsx!{
                h1 { "Hello, world! {name}" }
            })
            name_list
        }
    );
    // let sub_app = sub(cx);
    cx.render(rsx!{
        // nested
        div {
            h1 { "Counter" }
            // bind
            "count: { count }"
            button { onclick: move |_| count += 1, "+1" }    
        }
        hr {}
        div {
            el
        }
        MyComponent {
            name: "bob"
        }
    })
}
mizchimizchi

インライン要素

#![allow(unused, non_upper_case_globals, non_snake_case)]
use dioxus::prelude::*;

fn main() {
    dioxus::web::launch(app);
}

fn app(cx: Scope) -> Element {
    let el = rsx!(
        div {
            user_name.map(|name| rsx!{
                h1 { "Hello, world! {name}" }
            })
            name_list
        }
    );
    cx.render(rsx!{
        el
    })
}

外部コンポーネント定義

#![allow(unused, non_upper_case_globals, non_snake_case)]
use dioxus::prelude::*;

fn main() {
    dioxus::web::launch(app);
}

// Component
#[derive(Props, PartialEq)]
struct MyProps<'a> {
    name: &'a str
}
fn MyComponent<'a>(cx: Scope<'a, MyProps<'a>>) -> Element {
    cx.render(rsx! {
        div {
            "my_component: {cx.props.name}"
        }
    })
}

fn app(cx: Scope) -> Element {
    cx.render(rsx!{
        MyComponent {
            name: "bob"
        }
    })
}
mizchimizchi

Hooks

React の useState 相当のローカルステートがある。

fn Example(cx: Scope) -> Element {
    let name = use_state(&cx, || "...".to_string());
    cx.render(rsx!(
        "YN: {name}"
        div {
            button { onclick: move |_| name.set("yes".to_string()), "yes"}
            button { onclick: move |_| name.set("no".to_string()), "no"}    
        }
    ))
}
mizchimizchi

グローバルステート。中身は実質 Arc?

src/main.rs
#![allow(unused, non_upper_case_globals, non_snake_case)]
use dioxus::prelude::*;

fn main() {
    dioxus::web::launch(app);
}

struct GlobalState { name: String }
fn Leaf(cx: Scope) -> Element {
    match use_context::<GlobalState>(&cx) {
        Some(state) => {
            let name = &state.read().name;
            cx.render(rsx! {
                div {
                    "Leaf: {name}"
                }
            })
        },
        None => {
            cx.render(rsx! {
                div {
                    "Leaf: no state"
                }
            })
        }
    }
}

fn app(cx: Scope) -> Element {
    use_context_provider(&cx, || GlobalState { name: String::from("Toby") });
    let count = use_state(&cx, || 0);
    cx.render(rsx!{
        Leaf {}
    })
}

Rc や Arc ってスレッド周りの処理が入ってビルド時に処理が膨らむ気がしたが、ビルドしてみたところ .wasm が331k。さして増えるわけではなさそう。

higumachanhigumachan

他人のスクラップに書き込むのは恐縮なのですが、僕もdioxusやRust Frontendを調査したりしてる部分があるので共有させていただきます。(ノイズになるなら削除してください)

Rc や Arc ってスレッド周りの処理が入ってビルド時に処理が膨らむ気がしたが、ビルドしてみたところ .wasm が331k。さして増えるわけではなさそう。

Rcは実はマルチスレッドとは実はそんなに関係がなかったりします。

RcはC++で言うところのshared_pointerに相当して、複数の所有者を持つことが出来るHeap上のデータになっています。(Rcに対しての対はBoxです)

Arcはthreadの間をまたぐことが出来るように実装されたRcの実装になっています。(これも、マルチスレッドの実行系とはそこまで関係は無いです)

mizchimizchi

WIP


use_future

公式サンプルは reqwest を使ったものだが、 web_sys で fetch を使ったものにしてみる。

https://rustwasm.github.io/wasm-bindgen/examples/fetch.html

まずそのまま fetch を実装。

Cargo.toml に web_sys と serde の依存を追加

[package]
name = "demo"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
dioxus = { version = "0.1.6", features = ["web"] }
wasm-logger = "0.2.0"
wasm-bindgen = { version = "0.2.79", features = ["serde-serialize"]  }

log = "0.4.14"
console_error_panic_hook = "*"
im-rc = "15.0.0"
wasm-bindgen-futures = "0.4.29"
serde = { version = "1.0.80", features = ["derive"] }
serde_derive = "^1.0.59"

[dependencies.web-sys]
version = "0.3.4"
features = [
  'Headers',
  'Request',
  'RequestInit',
  'RequestMode',
  'Response',
  'Window'
]
src/lib.rs
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};

/// A struct to hold some data from the github Branch API.
///
/// Note how we don't have to define every member -- serde will ignore extra
/// data when deserializing
#[derive(Debug, Serialize, Deserialize)]
pub struct Branch {
    pub name: String,
    pub commit: Commit,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Commit {
    pub sha: String,
    pub commit: CommitDetails,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct CommitDetails {
    pub author: Signature,
    pub committer: Signature,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Signature {
    pub name: String,
    pub email: String,
}

// #[wasm_bindgen]
pub async fn fetch(repo: String) -> Result<JsValue, JsValue> {
    let mut opts = RequestInit::new();
    opts.method("GET");
    opts.mode(RequestMode::Cors);

    let url = format!("https://api.github.com/repos/{}/branches/master", repo);

    let request = Request::new_with_str_and_init(&url, &opts)?;

    request
        .headers()
        .set("Accept", "application/vnd.github.v3+json")?;

    let window = web_sys::window().unwrap();
    let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;

    // `resp_value` is a `Response` object.
    assert!(resp_value.is_instance_of::<Response>());
    let resp: Response = resp_value.dyn_into().unwrap();

    // Convert this other `Promise` into a rust `Future`.
    let json = JsFuture::from(resp.json()?).await?;

    // Use serde to parse the JSON into a struct.
    let branch_info: Branch = json.into_serde().unwrap();

    // Send the `Branch` struct back to JS as an `Object`.
    Ok(JsValue::from_serde(&branch_info).unwrap())
}

これを main.rs から使う。

#![allow(unused, non_upper_case_globals, non_snake_case)]
use dioxus::prelude::*;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::spawn_local;
use web_sys::console::{log, log_1};

fn main() {
    log_1(&JsValue::from_str("Hello, world!"));
    dioxus::web::launch(app);
}


fn app(cx: Scope) -> Element {
    let x = use_future(&cx, move || demo::fetch("mizchi/mints".to_string()));
    cx.render(rsx!{
        div {
            // TODO: x を参照する。
        }
    })
}

430k ぐらいに増えた。おそらく serde シリアライザの分

higumachanhigumachan

他人のスクラップに書き込むのは恐縮なのですが、僕もdioxusやRust Frontendを調査したりしてる部分があるので共有させていただきます。(ノイズになるなら削除してください)

公式サンプルは reqwest を使ったものだが、 web_sys で fetch を使ったものにしてみる。

ビルド時にターゲットをみて自動的に切り替わる設定になっているのでreqwestは基本的にはwasm + web-sys上でも動きます。(試してみるっていう話でやってたならすいません)
僕がyewでwebアプリを書いたときは普通にreqwestでリクエスト送れていました。

mizchimizchi

useFuture で useEffect 相当の処理をする

#![allow(non_snake_case)]
use dioxus::prelude::*;
use wasm_bindgen::prelude::*;
use web_sys::console;
fn main() {
    dioxus::web::launch(App);
}

fn App(cx: Scope) -> Element {
    // console.log("[render]");
    console::log_1(&"[render]".into());

    // const [count, setCount] = useState(0);
    let count = use_state(&cx, || 0 as i32);
    // const [resource, setResource] = useState(0);
    let resource = use_state(&cx, || 0 as i32);

    // const countHandler = () => { setCount(count + 1); };
    let count_handler = move |_| {
        let text = format!("count_handler {}", count.get());
        web_sys::console::log_1(&JsValue::from_str(&text));
        count.set(count.get() + 1);
    };
    // const resource_handler = () => { if(count > 0) { setCount(count - 1); setResource(resource + 1) } };
    let resource_handler = move |_| {
        if *count.get() > 0 {
            count.set(count.get() - 1);
            resource.set(resource.get() + 1);
        }
    };

    // useEffect(() => { console.log("onmount") }, [])
    use_future(&cx, (), |()| async move {
        console::log_1(&"on mount".into());
    });

    // useEffect(() => { console.log("onmount") }, [count])
    use_future(&cx, count, |count| async move {
        console::log_1(&format!("count:changed {}", count.get()).into());
    });

    let resource_disabled = if *count.get() > 0 { "false" } else { "true" };
    /*
    return <div>
        <h1>Counter</h1>
        <div>
            count: {count}
            <button onClick={countHandler}>+1</button>
        </div>
        <div>
            resource: {resource}
            <button disabled={resource_disabled} onClick={resource_handler}>
                consume counter
            </button>
        </div>
    </div>
     */
    cx.render(rsx!{
        div {
            h1 { "Counter" }
            div {
                "count: { count }"
                button { onclick: count_handler, "+1" }
            }
            div {
                "resource: { resource }"
                button {
                    onclick: resource_handler,
                    disabled: "{ resource_disabled }",
                    "consume counter"
                }
            }
        }
    })
}
mizchimizchi

React エンジニアのための dioxus 入門みたいなの書くといい気がしてきた。

mizchimizchi

web-sys から setTimeout を呼ぼうとしてみたがだいぶだるい

    /*
        useEffect(() => {
            setTimeout(() => {
                console.log("timeout");
            }, 100);
        }, []);
    */
    use_future(&cx, (), |()| async move {
        let callback = Closure::wrap(
            Box::new(
                move || console::log_1(&"timeout".into()),
            ) as Box<dyn Fn()>
        );
        let _ = web_sys::window().unwrap().set_timeout_with_callback_and_timeout_and_arguments_0(
            callback.as_ref().unchecked_ref(),
            100
        );
        callback.forget();
    });