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

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

https://qiita.com/segfo/items/5e7a53acde0229ed4562

https://yew.rs/ja/

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

# カレンダーアプリ
バックエンド（AWS Lambda）

https://qiita.com/segfo/items/59706ec9abacae367bd3

フロントエンド（AWS Amplify）

https://qiita.com/segfo/items/5e7a53acde0229ed4562

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

# Yewを使うと何が嬉しいのか
以下の2点かなと思ったりする。
1. Rustでフロントを記述できる（WASM）
2. Reactっぽく書ける

はい。

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

# コードペタリ(再掲)

``` toml: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"
```


``` rust: 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マジよくね？？？