はじめに
この記事は、以下の記事の派生というかちょっとYewと言うフレームワークに寄った話を書きます。
というわけで、バックエンドもフロントもデスクトップアプリも全部Rustで出来たら最高じゃね?
カレンダーアプリ
バックエンド(AWS Lambda)
フロントエンド(AWS Amplify)
作り方はそれぞれ見てもらうとして、とりあえずYewの使い方みたいなところを書いていく
Yewを使うと何が嬉しいのか
以下の2点かなと思ったりする。
- Rustでフロントを記述できる(WASM)
- Reactっぽく書ける
はい。
Yewの基本
- JavaScriptと通信をするためのFFI機構
- これがあれば、Rustで書きたい部分はRustで書いて、JSでしか書けない部分はJSを使えば解決できる。
- 例えばサードパーティのJSライブラリを使って表現を豊かにしたい場合などに、RustからJSのグルーコードを呼び出すようなイメージ
-
関数コンポーネント
- 様々なUIをRust上で単一の関数として表現できる。このあたりはReactと同じ設計思想になっている。
- 一応クラスっぽい作り方もできるけど、Reactの設計思想を強く受け継いでるので関数コンポーネントのほうが使い勝手が良い。
- ノードを一つしか返せないのもReactっぽい。
- 匿名フラグメントが使えるのでdivタグが無用にネストすることもない。(名前付きフラグメントは使えない)
-
フック(use_effectとか)なんかも使える。9割位React
- Reducerも使える。
- 様々なUIをRust上で単一の関数として表現できる。このあたりはReactと同じ設計思想になっている。
- だいたいのルールは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マジよくね???