3
1

RustのReactライクなフロントエンドフレームワーク「Yew」を使ってみる

Last updated at Posted at 2023-10-12

はじめに

この記事は、以下の記事の派生というかちょっとYewと言うフレームワークに寄った話を書きます。

というわけで、バックエンドもフロントもデスクトップアプリも全部Rustで出来たら最高じゃね?

カレンダーアプリ

バックエンド(AWS Lambda)

フロントエンド(AWS Amplify)

作り方はそれぞれ見てもらうとして、とりあえずYewの使い方みたいなところを書いていく

Yewを使うと何が嬉しいのか

以下の2点かなと思ったりする。

  1. Rustでフロントを記述できる(WASM)
  2. Reactっぽく書ける

はい。

Yewの基本

  • JavaScriptと通信をするためのFFI機構
    • これがあれば、Rustで書きたい部分はRustで書いて、JSでしか書けない部分はJSを使えば解決できる。
    • 例えばサードパーティのJSライブラリを使って表現を豊かにしたい場合などに、RustからJSのグルーコードを呼び出すようなイメージ
  • 関数コンポーネント
    • 様々なUIをRust上で単一の関数として表現できる。このあたりはReactと同じ設計思想になっている。
      • 一応クラスっぽい作り方もできるけど、Reactの設計思想を強く受け継いでるので関数コンポーネントのほうが使い勝手が良い。
    • ノードを一つしか返せないのもReactっぽい。
    • 匿名フラグメントが使えるのでdivタグが無用にネストすることもない。(名前付きフラグメントは使えない)
    • フック(use_effectとか)なんかも使える。9割位React
      • Reducerも使える。
  • だいたいのルールはReactに沿っていけば簡単に理解できる。
    • ここまでめっちゃReactって言ってるけどほぼ使ったこと無い。ググって出てくるのがReactのマニュアルなんだけど、それを参考にしてコード書いてたからRust版ReactがYewだと思う。

コードペタリ(再掲)

Cargo.toml
[package]
name = "web_front"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
strum = "0.19"
strum_macros = "0.19"
serde = { version = "1.0", features = ["derive"] }
serde_derive = "1"
serde_json = "1.0.91"
web-sys = "0.3.61"
yew = { version = "0.20", features = ["csr"] }
wasm-bindgen = "0.2"
wasm-logger = "0.2.0"
log = "0.4.17"
wasm-bindgen-futures = "0.4"
implicit-clone = "0.3.5"
gloo = "0.8.0"
gloo-storage = "0.2.2"
gloo-net = "0.2.6"
chrono = "0.4.26"
calender.rs
use chrono::prelude::*;
use serde::*;
use web_sys::{EventTarget, HtmlElement, HtmlInputElement};
use yew::prelude::*;

#[derive(PartialEq, Properties)]
pub struct CheckLabel {
    uncheck: String,
    checked: String,
}
impl CheckLabel {
    fn new<S: Into<String> + Clone>(label: S) -> Self {
        CheckLabel::new_interact(label.clone().into(), label.into())
    }
    fn new_interact<S: Into<String>>(uncheck: S, checked: S) -> Self {
        CheckLabel {
            uncheck: uncheck.into(),
            checked: checked.into(),
        }
    }
}

#[derive(PartialEq, Properties)]
pub struct CheckboxComponentProps {
    label: CheckLabel,
    local_storage_id: Option<String>,
    state_change: Callback<bool>,
}

#[function_component]
pub fn CheckboxComponent(props: &CheckboxComponentProps) -> Html {
    let CheckboxComponentProps {
        label,
        local_storage_id,
        state_change,
    } = props;
    let state = use_state_eq(|| false);
    let closure_state = state.clone();
    let check = {
        let local_storage_id1 = local_storage_id.clone();
        let state_change = state_change.clone();
        Callback::from(move |e: MouseEvent| {
            let target: Option<EventTarget> = e.target();
            let input = target.and_then(|t| t.dyn_into::<HtmlInputElement>().ok());
            if let Some(input) = input {
                closure_state.set(input.checked());
                state_change.emit(input.checked());
                if let Some(lsid) = &local_storage_id1 {
                    gloo_storage::LocalStorage::set(lsid, input.checked());
                }
            }
        })
    };
    let storage_state = state.clone();
    let local_storage_id = local_storage_id.clone();
    use_effect_with_deps(
        move |_| {
            if let Some(lsid) = &local_storage_id {
                if let Ok(check) = gloo_storage::LocalStorage::get(lsid.clone()) {
                    storage_state.set(check);
                }
            }
        },
        (),
    );
    html! {
        <label>
            <input id="checkbox1" type="checkbox" onclick={check} checked={*state}/>
            <span>{if *state {&label.checked}else{&label.uncheck}}</span>
        </label>
    }
}

#[derive(PartialEq, Properties)]
pub struct CounterButtonsProps {}

/// reducer's Action
enum CounterAction {
    SetCounter(isize),
    AddOne,
    SubOne,
}

/// reducer's State
#[derive(PartialEq)]
struct CounterState {
    counter: isize,
}
impl Default for CounterState {
    fn default() -> Self {
        Self { counter: 0 }
    }
}

use gloo_storage::Storage;
impl Reducible for CounterState {
    /// Reducer Action Type
    type Action = CounterAction;

    /// Reducer Function
    fn reduce(self: std::rc::Rc<Self>, action: Self::Action) -> std::rc::Rc<Self> {
        let next_ctr = match action {
            CounterAction::AddOne => self.counter + 1,
            CounterAction::SubOne => self.counter - 1,
            CounterAction::SetCounter(cnt) => cnt,
        };
        let _ = gloo_storage::LocalStorage::set("counter", next_ctr);
        Self { counter: next_ctr }.into()
    }
}

#[function_component]
pub fn CounterButtons(props: &CounterButtonsProps) -> Html {
    let CounterButtonsProps {} = props;
    let reducer = use_reducer_eq(CounterState::default);
    let add = {
        let reducer = reducer.clone();
        Callback::from(move |_| reducer.dispatch(CounterAction::AddOne))
    };
    let sub = {
        let reducer = reducer.clone();
        Callback::from(move |_| reducer.dispatch(CounterAction::SubOne))
    };

    let effect_reducer = reducer.clone();
    use_effect_with_deps(
        move |_| {
            let ctr: isize = match gloo_storage::LocalStorage::get("counter") {
                Ok(ctr) => ctr,
                Err(_) => {
                    let _ = gloo_storage::LocalStorage::set("counter", 0);
                    0
                }
            };
            effect_reducer.dispatch(CounterAction::SetCounter(ctr));
        },
        (),
    );
    html! {
        <>
            <input type="button" class="waves-effect waves-light btn" value={"+1"} onclick={add}/>
            <input type="button" class="waves-effect waves-light btn" value={"-1"} onclick={sub}/>
            {reducer.counter}
        </>
    }
}

#[derive(PartialEq, Properties)]
pub struct NavigateProps {}

// #[derive(Serialize, Deserialize, PartialEq, PartialOrd)]
#[derive(PartialEq)]
pub struct NavigateState {
    selected_tab: AttrValue,
}
impl NavigateState {
    fn new() -> Self {
        NavigateState {
            selected_tab: "".to_owned().into(),
        }
    }
}
#[function_component]
pub fn Navigate(props: &NavigateProps) -> Html {
    let NavigateProps {} = props;
    let state = use_state_eq(|| NavigateState::new());
    let click = {
        let clone_state = state.clone();
        Callback::from(move |e: MouseEvent| {
            if let Some(target) = e.target().and_then(|t| t.dyn_into::<HtmlElement>().ok()) {
                let enabled = if let Some(parent) = target.parent_element() {
                    let name = parent.class_name();
                    if name.split_ascii_whitespace().find(|s| s == &"disabled") != None {
                        false
                    } else {
                        true
                    }
                } else {
                    false
                };
                if enabled {
                    if let Some(attribute) = target.get_attribute("href") {
                        clone_state.set(NavigateState {
                            selected_tab: attribute.clone().into(),
                        });
                        gloo_storage::LocalStorage::set("selected_tab", attribute);
                    }
                }
            }
        })
    };
    let load = state.clone();
    use_effect_with_deps(
        move |_| {
            if let Ok(selected_tab) = gloo_storage::LocalStorage::get("selected_tab") {
                let selected_tab: String = selected_tab;
                load.set(NavigateState {
                    selected_tab: selected_tab.into(),
                });
            }
        },
        (),
    );
    html! {
        <>
            <nav class="nav-extended blue lighten-2">
              <div class="nav-wrapper">
                  <a href="#" class="brand-logo right">{"Rust app"}</a>
                  <a href="#" data-target="mobile-demo" class="sidenav-trigger"><i class="material-icons">{"menu"}</i></a>
                  <ul id="nav-mobile" class="left hide-on-med-and-down">
                  <li><a href="#">{"dummy menu1"}</a></li>
                  <li><a href="#">{"dummy menu2"}</a></li>
                  <li><a href="#">{"dummy menu3"}</a></li>
                  </ul>
              </div>
              <div class="nav-content">
                  <ul class="tabs tabs-transparent">
                  <li class="tab"><a href="#test1" onclick={click.clone()}>{"Test 1"}</a></li>
                  <li class="tab"><a href="#test2" onclick={click.clone()}>{"Test 2"}</a></li>
                  <li class="tab disabled"><a href="#test3"  onclick={click.clone()}>{"Disabled Tab"}</a></li>
                  <li class="tab"><a href="#test4" onclick={click.clone()}>{"Calender App"}</a></li>
                  </ul>
              </div>
            </nav>

            <ul class="sidenav" id="mobile-demo">
                <li><a href="sass.html">{"Sass"}</a></li>
                <li><a href="badges.html">{"Components"}</a></li>
                <li><a href="collapsible.html">{"JavaScript"}</a></li>
            </ul>
        </>
    }
}

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_name = Initialize)]
    fn init();
}

#[derive(PartialEq, Properties)]
pub struct ScriptInitProps {}

#[function_component]
pub fn ScriptInit(props: &ScriptInitProps) -> Html {
    let ScriptInitProps {} = props;
    use_effect_with_deps(
        move |_| {
            init();
            // 元いたタブに戻る
            if let Ok(tab) = gloo_storage::LocalStorage::get("selected_tab") {
                let tab: String = tab;
                let h = gloo::utils::document()
                    .location()
                    .unwrap()
                    .hash()
                    .unwrap_or_default();
                if tab != h {
                    gloo::utils::document().location().unwrap().set_hash(&tab);
                    gloo::utils::document().location().unwrap().reload();
                }
            }
            || {}
        },
        (),
    );
    html! {}
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub enum HolidayKind {
    Weekday = 0,
    PublicHoliday = 1,
    NationalHoliday = 2,
}
impl std::fmt::Display for HolidayKind {
    fn fmt(&self, f: &mut __private::Formatter<'_>) -> std::fmt::Result {
        let d = match &self {
            HolidayKind::NationalHoliday => "NationalHoliday",
            HolidayKind::PublicHoliday => "PublicHoliday",
            HolidayKind::Weekday => "Weekday",
        };
        write!(f, "{}", d)
    }
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
struct DayInfo {
    day: u32,
    day_of_week: u32,
    weeks_of_month: u32,
    holiday_str: String,
    holiday_kind: HolidayKind,
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct ApiResponse {
    year: i32,
    month: u32,
    days: Vec<DayInfo>,
}

impl Default for ApiResponse {
    fn default() -> Self {
        ApiResponse {
            year: 0,
            month: 1,
            days: vec![DayInfo::default()],
        }
    }
}

impl Default for DayInfo {
    fn default() -> Self {
        DayInfo {
            day: 0,
            day_of_week: 0,
            weeks_of_month: 0,
            holiday_str: "".to_owned(),
            holiday_kind: HolidayKind::Weekday,
        }
    }
}

#[derive(PartialEq, Properties)]
pub struct CalenderProps {
    year: i32,
    month: u32,
}

fn day_color(day: &DayInfo) -> String {
    format!(
        "color: {};",
        match day.holiday_kind {
            HolidayKind::Weekday => "black",
            HolidayKind::NationalHoliday => "red",
            HolidayKind::PublicHoliday => {
                if day.day_of_week == 0 {
                    "red"
                } else {
                    "blue"
                }
            }
        }
    )
}

fn day_to_point(day: &DayInfo, start: u32) -> (u32, u32) {
    if day.day == 0 {
        return (0, 0);
    }
    (day.day_of_week * 100, ((day.day + start - 1) / 7) * 80)
}
fn day_to_point_add(point: (u32, u32), point_add: (u32, u32)) -> (u32, u32) {
    (point.0 + point_add.0, point.1 + point_add.1)
}

fn point_to_css(point: (u32, u32)) -> String {
    format!("position:absolute; left:{}px; top:{}px;", point.0, point.1)
}

use gloo_net::http::Request;
#[function_component]
pub fn Calender(props: &CalenderProps) -> Html {
    let calender_api = "https://<エンドポイントのホスト>/default/calender-api";
    let cprops = CalenderProps {
        year: props.year,
        month: props.month,
    };
    let storage = format!("calender_{}_{}", cprops.year, cprops.month);
    let response = use_state_eq(|| ApiResponse::default());
    let resp = response.clone();
    use_effect_with_deps(
        move |_| match gloo_storage::LocalStorage::get(&storage) {
            Ok(calender) => resp.set(calender),
            Err(_) => {
                wasm_bindgen_futures::spawn_local(async move {
                    let res: ApiResponse = Request::post(calender_api)
                        .header("Content-Type", "application/json")
                        .body(format!(
                            "{{\"year\":{},\"month\":{}}}",
                            cprops.year, cprops.month
                        ))
                        .send()
                        .await
                        .unwrap()
                        .json()
                        .await
                        .unwrap();
                    let _ = gloo_storage::LocalStorage::set(&storage, &res);
                    resp.set(res);
                });
            }
        },
        (),
    );
    let data = response;
    let start = data.days[0].day_of_week;
    html! {
        <>
            <div>
            {[("日","red"),("月","black"),("火","black"),("水","black"),("木","black"),("金","black"),("土","blue")].iter().enumerate().map(|(i,wd)|html!(
                <span style={format!("color:{}; {}",wd.1,point_to_css((i as u32*100,0)))}>{wd.0}</span>
            )).collect::<Html>()}
            </div>
            <div style="position:relative; top:30px">
            {data.days.iter().map(|res|{html!{
                <div>
                <span style={format!("{} {}",day_color(&res),point_to_css(day_to_point(&res,start)))}>{res.day}</span>
                <span style={format!("{} {}",day_color(&res),point_to_css(day_to_point_add(day_to_point(&res,start),(0,20))))}>{&res.holiday_str}</span>
                </div>}}).collect::<Html>()}
            </div>
        </>
    }
}

#[derive(Default, PartialEq, Properties)]
pub struct AppProps {}

#[function_component]
pub fn App(props: &AppProps) -> Html {
    let AppProps {} = props;
    let state_change = Callback::from(move |checked| {});
    let now = Local::now();
    html! {
        <div class="container">
            <Navigate />
            <div id="test1" class="col s12">
                <CheckboxComponent state_change={state_change} label={CheckLabel::new("チェックボックス")} local_storage_id="check" />
            </div>
            <div id="test2" class="col s12">
                <CounterButtons />
            </div>
            <div id="test3" class="col s12">{"Test 3"}</div>
            <div id="test4" style="position:relative" class="col s12">
                <Calender year={now.year()} month={now.month()} />
            </div>
            <ScriptInit />
        </div>
    }
}

use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn rust_entry() {
    wasm_logger::init(wasm_logger::Config::default());
    yew::Renderer::<App>::new().render();
}

まとめ

Rustマジよくね???

3
1
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
3
1