Rust๋กœ SPA(Single Page Applications)๋งŒ๋“ค๊ธฐ


์›๋ฌธ: http://www.sheshbabu.com/posts/rust-wasm-yew-single-page-application/

WebAssembly(wasm)์„ ์‚ฌ์šฉํ•˜๋ฉด ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ ์™ธ์˜ ์–ธ์–ด๋กœ ์ž‘์„ฑ๋œ ์ฝ”๋“œ๋ฅผ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค. ๋Œ€๋ถ€๋ถ„์˜ ์ฃผ์š” ๋ธŒ๋ผ์šฐ์ €์—์„œ wasm์„ ์ง€์›ํ•˜๊ณ  ์ „ ์„ธ๊ณ„์ ์œผ๋กœ 90% ์ด์ƒ์˜ ์‚ฌ์šฉ์ž๊ฐ€ wasm์„ ๋™์ž‘์‹œํ‚ค๋Š” ๋ธŒ๋ผ์šฐ์ €๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.

Rust๊ฐ€ wasm์œผ๋กœ ์ปดํŒŒ์ผ์ด ๋˜๋‹ˆ, ์ž๋ฐ”์Šคํฌ๋ฆฝํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜์ง€ ์•Š๊ณ  ์ˆœ์ˆ˜ํ•˜๊ฒŒ Rust๋กœ SPAs(Single Page Applications)๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋Š”๊ฐ€? ๋ผ๊ณ  ๋ฌป๋Š”๋‹ค๋ฉด ๋Œ€๋‹ต์€ ๊ทธ๋ ‡๋‹ค!์ด๋‹ค. ๊ตฌํ˜„ ๊ฒฐ๊ณผ๊ฐ€ ๊ถ๊ธˆํ•˜๋‹ค๋ฉด ๋ฐ๋ชจ ์‚ฌ์ดํŠธ์— ๋ฐฉ๋ฌธํ•˜๊ฑฐ๋‚˜ ๋” ๊ธ€์„ ์ž์„ธํžˆ ์ฝ์–ด๋ณด๋ฉด ์ข‹์„ ๊ฒƒ ๊ฐ™๋‹ค.

์—ฌ๊ธฐ์„œ๋Š” ๋‘ ํŽ˜์ด์ง€๋กœ ๊ตฌ์„ฑ๋œ "RustMart"๋ผ๋Š” ๊ฐ„๋‹จํ•œ ์ด์ปค๋จธ์Šค ์‚ฌ์ดํŠธ๋ฅผ ๋งŒ๋“ค์–ด ๋ณผ ๊ฒƒ์ด๋‹ค.

  • HomePage - ์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ๋Š” ๋ชจ๋“  ์ƒํ’ˆ์ด ๋‚˜์—ด๋œ ํŽ˜์ด์ง€
  • ProductDetailPage - ์ƒํ’ˆ์„ ํด๋ฆญํ•˜๋ฉด ์ƒํ’ˆ์˜ ์ •๋ณด๊ฐ€ ๋‚˜์˜ค๋Š” ํŽ˜์ด์ง€

1

๋ชจ๋˜ SPA ํŽ˜์ด์ง€๋ฅผ ๊ตฌ์„ฑํ•˜๋Š” ๋ฐ ํ•„์š”ํ•œ ์ตœ์†Œํ•œ์˜ ๊ธฐ๋Šฅ๋งŒ ์ด ์˜ˆ์ œ๋ฅผ ๊ตฌ์„ฑํ•˜๋Š”๋ฐ ์‚ฌ์šฉํ–ˆ๋‹ค.

  • ํŽ˜์ด์ง€ ์ƒˆ๋กœ ๊ณ ์นจ ์—†์ด ์—ฌ๋Ÿฌ ํŽ˜์ด์ง€๋ฅผ ์ด๋™
  • ํŽ˜์ด์ง€ ์ƒˆ๋กœ ๊ณ ์นจ ์—†์ด ๋„คํŠธ์›Œํฌ ์š”์ฒญ ๋ณด๋‚ด๊ธฐ
  • ์—ฌ๋Ÿฌ ํŽ˜์ด์ง€์—์„œ ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ณต์œ ํ•˜๋ฉด์„œ ์žฌ์‚ฌ์šฉํ•˜๊ธฐ
  • UI ๊ณ„์ธต์—์„œ ์—ฌ๋Ÿฌ ๊ณ„์ธต์˜ ์ปดํฌ๋„ŒํŠธ๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๊ธฐ

์ค€๋น„

Rust๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค๋ฉด ์ด ๋งํฌ์—์„œ ์„ค์น˜ํ•˜์ž.

๋‹ค์Œ Rust ๋„๊ตฌ๋“ค ๋˜ํ•œ ์„ค์น˜ํ•˜์ž.

$ cargo install wasm-pack          # Rust๋ฅผ ์ปดํŒŒ์ผ ํ•ด Wasm๊ณผ JS Interop ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑ
$ cargo install cargo-make         # ํƒœ์Šคํฌ ๋Ÿฌ๋„ˆ
$ cargo install simple-http-server # assets์„ ์‹คํ–‰ํ•˜๋Š” Simple Server

๊ทธ๋ฆฌ๊ณ  ์ƒˆ ํ”„๋กœ์ ํŠธ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.

$ cargo new --lib rustmart && cd rustmart

์ด ํ”„๋กœ์ ํŠธ์—์„œ๋Š” UI์ปดํผ๋„ŒํŠธ๋ฅผ ๋นŒ๋“œํ•˜๊ธฐ ์œ„ํ•ด Yew ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. wasm ๋””ํŽœ๋˜์‹œ๋ฅผ Cargo.toml์— ์ถ”๊ฐ€ํ•ด๋ณด์ž.

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

[dependencies]
yew = "0.17"
wasm-bindgen = "0.2"

Makefile.toml๋„ ์ƒ์„ฑํ•ด ๋‹ค์Œ ๋‚ด์šฉ์„ ์ถ”๊ฐ€ํ•œ๋‹ค.

[tasks.build]
command = "wasm-pack"
args = ["build", "--dev", "--target", "web", "--out-name", "wasm", "--out-dir", "./static"]
watch = { ignore_pattern = "static/*" }

[tasks.serve]
command = "simple-http-server"
args = ["-i", "./static/", "-p", "3000", "--nocache", "--try-file", "./static/index.html"]

๋นŒ๋“œ๋ฅผ ์‹œ์ž‘ํ•ด๋ณด์ž

$ cargo make build

Rust๊ฐ€ ์ฒ˜์Œ์ธ ์‚ฌ๋žŒ๋“ค์„ ์œ„ํ•ด ๋ช‡ ๊ฐ€์ง€ ๊ฐ€์ด๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ์œผ๋‹ˆ ๋„์›€์ด ๋˜๊ธธ ๋ฐ”๋ž€๋‹ค.

Hello world

"Hello world"์˜ˆ์ œ๋ฅผ ๋งŒ๋“ค์–ด๋ณด์ž.

static/index.html์„ ๋งŒ๋“ค๊ณ  ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•ด๋ณด์ž.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>RustMart</title>
    <script type="module">
      import init from "/wasm.js";
      init();
    </script>
    <link rel="shortcut icon" href="#" />
  </head>
  <body></body>
</html>

๊ทธ๋ฆฌ๊ณ  ๋‹ค์Œ ์ฝ”๋“œ ๋˜ํ•œ src/lib.rs์— ์ถ”๊ฐ€ํ•ด๋ณด์ž

// src/lib.rs
use wasm_bindgen::prelude::*;
use yew::prelude::*;

struct Hello {}

impl Component for Hello {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
        Self {}
    }

    fn update(&mut self, _: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        html! { <span>{"Hello World!"}</span> }
    }
}

#[wasm_bindgen(start)]
pub fn run_app() {
    App::<Hello>::new().mount_to_body();
}

๋งŽ์€ ์ผ์ด ์ง„ํ–‰๋˜์ง€๋งŒ "Hello"๋ผ๋Š” ์ƒˆ๋กœ์šด ์ปดํผ๋„ŒํŠธ๋ฅผ ์ƒ์„ฑํ•ด DOM์— <span>Hello World!</span>์„ ์ƒ์„ฑํ•˜๋Š” ์ž‘์—…์„ ํ•œ๋‹ค๋Š” ๊ฒƒ์„ ์•Œ ์ˆ˜ ์žˆ๋‹ค. Yew ์ปดํฌ๋„ŒํŠธ์— ๋Œ€ํ•ด์„œ๋Š” ์ดํ›„์— ์‚ดํŽด๋ณด๋„๋ก ํ•˜๊ฒ ๋‹ค.

ํ„ฐ๋ฏธ๋„์—์„œ ํƒœ์Šคํฌ๋ฅผ ์‹คํ–‰ํ•œ ๋’ค ๋ธŒ๋ผ์šฐ์ €์—์„œ http://localhost:3000์— ์ ‘์†ํ•ด ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

$ cargo make serve
2

์ž‘๋™ํ–ˆ๋‹ค! ๋‹จ์ง€ "Hello world"์ผ ๋ฟ์ด์ง€๋งŒ ๋ชจ๋“  ์ฝ”๋“œ๊ฐ€ Rust๋กœ ์ž‘์„ฑ๋๋‹ค.

๋” ์ง„ํ–‰ํ•˜๊ธฐ ์ „์— ๋จผ์ € ๋‹ค๋ฅธ SPA์˜ ์ปจ์…‰๊ณผ ์ปดํฌ๋„ŒํŠธ์— ๋Œ€ํ•ด ์‚ดํŽด๋ณด๋„๋ก ํ•˜์ž

์ปดํฌ๋„ŒํŠธ๋กœ ์ƒ๊ฐํ•˜๊ธฐ

์ปดํฌ๋„ŒํŠธ๋ฅผ ํ•ฉ์„ฑํ•˜๊ณ  ๋ฐ์ดํ„ฐ๋ฅผ ๋‹จ๋ฐฉํ–ฅ์œผ๋กœ ์ „์†กํ•˜๋ฉฐ UI๋ฅผ ๋งŒ๋“œ๋Š” ๊ฒƒ์œผ๋กœ ํ”„๋ก ํŠธ์—”๋“œ ์„ธ๊ณ„์˜ ํฐ ํŒจ๋Ÿฌ๋‹ค์ž„ ๋ณ€ํ™”๊ฐ€ ์žˆ์—ˆ๋‹ค. UI์— ๋Œ€ํ•ด ์ถ”๋ก ํ•˜๋Š” ๋ฐฉ์‹์ด ํฌ๊ฒŒ ๋ฐœ์ „๋˜์—ˆ์œผ๋ฉฐ ์ด๋Ÿฐ ๋ฐฉ์‹์— ์ต์ˆ™ํ•ด์ง€๋ฉด ๋ช…๋ นํ˜•(imperative) DOM ์กฐ์ž‘์— ๋Œ์•„๊ฐ€๊ธฐ ๋งค์šฐ ์–ด๋ ต๊ฒŒ ๋œ๋‹ค.

React๋‚˜ Vue, Yew, Flutter ๋“ฑ ์—ฌ๋Ÿฌ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ์˜ Component๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ ๊ฐ–๋Š”๋‹ค.

  • ๋” ํฐ ์ปดํฌ๋„ŒํŠธ๋กœ ํ•ฉ์„ฑํ•˜๋Š” ๊ธฐ๋Šฅ
  • Props - ํ•ด๋‹น ์ปดํฌ๋„ŒํŠธ์—์„œ ์ž์‹ ์ปดํฌ๋„ŒํŠธ๋กœ ๋ฐ์ดํ„ฐ๋‚˜ ์ฝœ๋ฐฑ์„ ์ „๋‹ฌ
  • State - ์ปดํฌ๋„ŒํŠธ์˜ ๋กœ์ปฌ ์ƒํƒœ๋ฅผ ์กฐ์ž‘
  • AppState - ์ „์—ญ ์ƒํƒœ๋ฅผ ์กฐ์ž‘
  • "Instantiated", "Mounted in DOM" ๋“ฑ ๋ผ์ดํ”„ ์‚ฌ์ดํด ์ด๋ฒคํŠธ๊ฐ€ ์กด์žฌ
  • remote data๋ฅผ ๋ฐ›์•„์˜ค๊ฑฐ๋‚˜, ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€๋ฅผ ์กฐ์ž‘ํ•˜๋Š” ๋“ฑ์˜ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ˆ˜ํ–‰

๋˜ํ•œ, ์ปดํฌ๋„ŒํŠธ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ƒํ™ฉ์— ์—…๋ฐ์ดํŠธ(๋ฆฌ๋ Œ๋”๋ง) ๋œ๋‹ค.

  • ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ฆฌ๋ Œ๋”๋ง ๋œ ๊ฒฝ์šฐ
  • Props์˜ ๋ณ€๊ฒฝ
  • State์˜ ๋ณ€๊ฒฝ
  • AppState์˜ ๋ณ€๊ฒฝ

์ •๋ฆฌํ•ด๋ณด๋ฉด, ๋ช…์‹œ์ (imperatively)์œผ๋กœ UI๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๋Š” ๋Œ€์‹  ์‚ฌ์šฉ์ž์˜ ์ƒํ˜ธ ์ž‘์šฉ์ด๋‚˜ ๋„คํŠธ์›Œํฌ ์š”์ฒญ ๋“ฑ์ด ๋ฐœ์ƒํ•  ๋•Œ ๋ฐ์ดํ„ฐ(Props, State, AppState)๋ฅผ ์—…๋ฐ์ดํŠธํ•˜๊ณ  ์ด ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ UI๋Š” ์—…๋ฐ์ดํŠธ๋œ๋‹ค. ์ด ๋ง์€ ๋ˆ„๊ตฐ๊ฐ€๊ฐ€ "UI๋Š” ์ƒํƒœ์˜ ํ•จ์ˆ˜๋‹ค(UI is a function of state)"๋ผ๊ณ  ํ•˜๋Š” ๊ฒƒ์„ ์˜๋ฏธํ•œ๋‹ค๊ณ  ํ•  ์ˆ˜ ์žˆ๋‹ค.

์ •ํ™•ํ•œ ์„ธ๋ถ€ ์‚ฌํ•ญ์€ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๋งˆ๋‹ค ๋‹ค๋ฅด์ง€๋งŒ, ์ผ๋ฐ˜์ ์œผ๋กœ ์‚ฌ์šฉ๋˜๊ณ  ์žˆ๋Š” ์•„์ด๋””์–ด์ด๋‹ค. ๋งŒ์•ฝ ์ด๋Ÿฐ ์•„์ด๋””์–ด๊ฐ€ ์ฒ˜์Œ์ด๋ผ๋ฉด ์ต์ˆ™ํ•ด์ง€๋Š”๋ฐ ์‹œ๊ฐ„์ด ์กฐ๊ธˆ ๊ฑธ๋ฆด ์ˆ˜ ์žˆ๋‹ค.

HomePage

ํ™ˆํŽ˜์ด์ง€๋ฅผ ๋จผ์ € ๋งŒ๋“ค์–ด๋ณด์ž. ์šฐ์„  ๋‹จ์ผ ์ปดํฌ๋„ŒํŠธ๋กœ ๋งŒ๋“  ๋’ค ์ดํ›„์— ๋ถ„ํ•ดํ•˜๋ฉฐ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ปดํฌ๋„ŒํŠธ๋กœ ๋งŒ๋“ค ๊ฒƒ์ด๋‹ค

์•„๋ž˜ ํŒŒ์ผ์„ ๋งŒ๋“ค์–ด๋ณด์ž.

// src/pages/home.rs
use yew::prelude::*;

pub struct Home {}

impl Component for Home {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
        Self {}
    }

    fn update(&mut self, _: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        html! { <span>{"Home Sweet Home!"}</span> }
    }
}
// src/pages/mod.rs
mod home;

pub use home::Home;

src/lib.rs๋ฅผ ๋‹ค์Œ์ฒ˜๋Ÿผ ๋ฐ”๊ฟ” ์ž‘์„ฑํ•œ ๋’ค HomePage ์ปดํฌ๋„ŒํŠธ๋ฅผ import ํ•ด๋ณด์ž

  // src/lib.rs
+ mod pages;

+ use pages::Home;
  use wasm_bindgen::prelude::*;
  use yew::prelude::*;

- struct Hello {}

- impl Component for Hello {
-     type Message = ();
-     type Properties = ();

-     fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
-         Self {}
-     }

-     fn update(&mut self, _: Self::Message) -> ShouldRender {
-         true
-     }

-     fn change(&mut self, _: Self::Properties) -> ShouldRender {
-         true
-     }

-     fn view(&self) -> Html {
-         html! { <span>{"Hello World!"}</span> }
-     }
- }

  #[wasm_bindgen(start)]
  pub fn run_app() {
-   App::<Hello>::new().mount_to_body();
+   App::<Home>::new().mount_to_body();
  }

์ด๋ ‡๊ฒŒ ์ž‘์„ฑํ•˜๋ฉด "Hello World!" ๋Œ€์‹  "Home Sweet Home!"์ด ๋ธŒ๋ผ์šฐ์ €์— ๋ Œ๋”๋ง ๋œ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

์ด์ œ ์ด ์ปดํฌ๋„ŒํŠธ์˜ State๋ฅผ ๊ตฌ์„ฑํ•ด๋ณด์ž.

  • ์„œ๋ฒ„๋กœ๋ถ€ํ„ฐ ๋ถˆ๋Ÿฌ์˜จ ์ƒํ’ˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ์ €์žฅํ•ด์•ผ ํ•œ๋‹ค.
  • ์‚ฌ์šฉ์ž๊ฐ€ ์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ๋‹ด์€ ์ƒํ’ˆ์„ ์ €์žฅํ•ด์•ผ ํ•œ๋‹ค.

๋‹จ์ˆœํ•˜๊ฒŒ Product ์ •๋ณด๋ฅผ ์ €์žฅํ•  ๊ตฌ์กฐ์ฒด๋ฅผ ๋งŒ๋“ค์—ˆ๋‹ค.

struct Product {
    name: String,
    description: String,
    image: String,
    price: f64,
}

๋‹ค์Œ์œผ๋กœ ์„œ๋ฒ„๋กœ ๋ถ€ํ„ฐ ๋ถˆ๋Ÿฌ์˜จ ์ƒํ’ˆ์˜ ์ •๋ณด๋ฅผ ๋‹ด๋Š” ์ƒˆ ํ•„๋“œ products๋ฅผ ๋งŒ๋“ค์–ด State์— ์„ ์–ธํ•ด์ค€๋‹ค.

struct State {
    products: Vec<Product>,
}

๋ฐ”๋€ HomePage ์ปดํฌ๋„ŒํŠธ๋ฅผ ์‚ดํŽด๋ณด์ž

  use yew::prelude::*;

+ struct Product {
+     id: i32,
+     name: String,
+     description: String,
+     image: String,
+     price: f64,
+ }

+ struct State {
+     products: Vec<Product>,
+ }

- pub struct Home {}
+ pub struct Home {
+     state: State,
+ }

  impl Component for Home {
      type Message = ();
      type Properties = ();

      fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
+       let products: Vec<Product> = vec![
+           Product {
+               id: 1,
+               name: "Apple".to_string(),
+               description: "An apple a day keeps the doctor away".to_string(),
+               image: "/products/apple.png".to_string(),
+               price: 3.65,
+           },
+           Product {
+               id: 2,
+               name: "Banana".to_string(),
+               description: "An old banana leaf was once young and green".to_string(),
+               image: "/products/banana.png".to_string(),
+               price: 7.99,
+           },
+       ];

-       Self {}
+       Self {
+           state: State {
+               products,
+           },
+       }
      }

      fn update(&mut self, _: Self::Message) -> ShouldRender {
          true
      }

      fn change(&mut self, _: Self::Properties) -> ShouldRender {
          true
      }

      fn view(&self) -> Html {
+        let products: Vec<Html> = self
+            .state
+            .products
+            .iter()
+            .map(|product: &Product| {
+                html! {
+                  <div>
+                    <img src={&product.image}/>
+                    <div>{&product.name}</div>
+                    <div>{"$"}{&product.price}</div>
+                  </div>
+                }
+            })
+            .collect();
+
+        html! { <span>{products}</span> }
-        html! { <span>{"Home!"}</span> }
      }
  }

create ์ƒ๋ช… ์ฃผ๊ธฐ ๋ฉ”์„œ๋“œ๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ƒ์„ฑ๋  ๋•Œ ํ˜ธ์ถœ๋˜๋ฉฐ ์—ฌ๊ธฐ์„œ ์ดˆ๊ธฐ ์ƒํƒœ๋ฅผ ์„ค์ •ํ•œ๋‹ค. ์ž ์‹œ ๋™์•ˆ ์ƒํ’ˆ ๋ชฉ๋ก์„ ๋ชจํ‚นํ•ด์„œ ์ดˆ๊ธฐ ์ƒํƒœ๊ฐ’์œผ๋กœ ์ง€์ •ํ–ˆ๋‹ค. ์ดํ›„์— ์ด ์ƒํ’ˆ ๋ฆฌ์ŠคํŠธ๋Š” ์š”์ฒญ์„ ํ†ตํ•ด ๊ฐ’์„ ๊ฐ€์ ธ์˜ฌ ๊ฒƒ์ด๋‹ค.

view ์ƒ๋ช… ์ฃผ๊ธฐ ๋ฉ”์„œ๋“œ๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋ Œ๋”๋œ ๋’ค ๋ฐœ์ƒํ•œ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ๋Š” ์ƒํ’ˆ ์นด๋“œ๋ฅผ ์ƒ์„ฑํ•˜๊ธฐ ์œ„ํ•ด products๋ฅผ ๋ฐ˜๋ณตํ–ˆ๋‹ค. ๋งŒ์•ฝ React์— ์ต์ˆ™ํ•˜๋‹ค๋ฉด, view๋Š” render์™€ ๋™์ผํ•˜๋ฉฐ html!๋งคํฌ๋กœ๋Š” JSX์™€ ๋น„์Šทํ•˜๋‹ค.

์ž„์˜์˜ ์ด๋ฏธ์ง€๋ฅผ static/products/apple.png์™€ static/products/apple.png๋กœ ์ €์žฅํ•˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ UI๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ๋‹ค.

3

์ด์ œ "์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€" ๊ธฐ๋Šฅ์„ ๋งŒ๋“ค์–ด๋ณด์ž.

  • ์ถ”๊ฐ€๋˜๋Š” ์ƒํ’ˆ๋“ค์€ cart_products๋ผ๋Š” ์ƒˆ๋กœ์šด state ํ•„๋“œ์— ์ €์žฅํ•œ๋‹ค.
  • "์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ๊ฐ ์ƒํ’ˆ์— ๋ Œ๋”๋ง ํ•œ๋‹ค.
  • "์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ํด๋ฆญํ–ˆ์„ ๋•Œ cart_products๋ฅผ ์—…๋ฐ์ดํŠธ ํ•˜๋Š” ๋กœ์ง์„ ๋งŒ๋“ ๋‹ค.
  use yew::prelude::*;

+ #[derive(Clone)]
  struct Product {
      id: i32,
      name: String,
      description: String,
      image: String,
      price: f64,
  }

+ struct CartProduct {
+     product: Product,
+     quantity: i32,
+ }

  struct State {
      products: Vec<Product>,
+     cart_products: Vec<CartProduct>,
  }

  pub struct Home {
      state: State,
+     link: ComponentLink<Self>,
  }

+ pub enum Msg {
+     AddToCart(i32),
+ }

  impl Component for Home {
-   type Message = ();
+   type Message = Msg;
    type Properties = ();

-   fn create(_: Self::Properties, _: ComponentLink<Self>) -> Self {
+   fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
        let products: Vec<Product> = vec![
            Product {
                id: 1,
                name: "Apple".to_string(),
                description: "An apple a day keeps the doctor away".to_string(),
                image: "/products/apple.png".to_string(),
                price: 3.65,
            },
            Product {
                id: 2,
                name: "Banana".to_string(),
                description: "An old banana leaf was once young and green".to_string(),
                image: "/products/banana.png".to_string(),
                price: 7.99,
            },
        ];
+       let cart_products = vec![];

        Self {
            state: State {
                products,
+               cart_products,
            },
+           link,
        }
    }

-   fn update(&mut self, _: Self::Message) -> ShouldRender {
+   fn update(&mut self, message: Self::Message) -> ShouldRender {
+       match message {
+           Msg::AddToCart(product_id) => {
+               let product = self
+                   .state
+                   .products
+                   .iter()
+                   .find(|p: &&Product| p.id == product_id)
+                   .unwrap();
+               let cart_product = self
+                   .state
+                   .cart_products
+                   .iter_mut()
+                   .find(|cp: &&mut CartProduct| cp.product.id == product_id);
+
+               if let Some(cp) = cart_product {
+                   cp.quantity += 1;
+               } else {
+                   self.state.cart_products.push(CartProduct {
+                       product: product.clone(),
+                       quantity: 1,
+                   })
+               }
+               true
+           }
+       }
-       true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        let products: Vec<Html> = self
            .state
            .products
            .iter()
            .map(|product: &Product| {
+              let product_id = product.id;
                html! {
                  <div>
                    <img src={&product.image}/>
                    <div>{&product.name}</div>
                    <div>{"$"}{&product.price}</div>
+                   <button onclick=self.link.callback(move |_| Msg::AddToCart(product_id))>{"Add To Cart"}</button>
                  </div>
                }
            })
            .collect();

+       let cart_value = self
+           .state
+           .cart_products
+           .iter()
+           .fold(0.0, |acc, cp| acc + (cp.quantity as f64 * cp.product.price));

-       html! { <span>{products}</span> }
+       html! {
+         <div>
+           <span>{format!("Cart Value: {:.2}", cart_value)}</span>
+           <span>{products}</span>
+         </div>
+       }
      }
  }
  • clone - Product๊ตฌ์กฐ์ฒด์—์„œ Clone ํŠธ๋ ˆ์ž‡(traits)์„ ํŒŒ์ƒ(derive)์‹œ์ผœ์ค˜ ์œ ์ €๊ฐ€ ์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€ ํ•  ๋•Œ๋งˆ๋‹ค ๋ณต์ œ๋œ Product๋ฅผ CartProduct์— ์ €์žฅํ•˜๋„๋ก ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ๋‹ค.
  • update - ์ด ๋ฉ”์„œ๋“œ๋Š” ์ปดํฌ๋„ŒํŠธ์˜ State์—…๋ฐ์ดํŠธ ๋กœ์ง์ด ์กด์žฌํ•˜๊ฑฐ๋‚˜ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ(๋„คํŠธ์›Œํฌ ์š”์ฒญ ๊ฐ™์€)์ž‘์—…์ด ์œ„์น˜ํ•˜๋Š” ๊ณณ์ด๋‹ค. ์ปดํฌ๋„ŒํŠธ๊ฐ€ ์ง€์›ํ•˜๋Š” ๋ชจ๋“  ์•ก์…˜์„ ํฌํ•จํ•˜๋Š” Message ์—ด๊ฑฐํ˜•์„ ์‚ฌ์šฉํ•ด ๋ฐœ์ƒ์‹œํ‚จ๋‹ค. ์ด ๋ฉ”์„œ๋“œ์—์„œ true๋ฅผ ๋ฐ˜ํ™˜ํ•  ๋•Œ ์ปดํฌ๋„ŒํŠธ๋Š” ๋ฆฌ๋ Œ๋”๋ง ํ•œ๋‹ค. ์œ„ ์ฝ”๋“œ์—์„œ "์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด Msg::AddToCart ๋ฉ”์„ธ์ง€๋ฅผ update๋กœ ๋ณด๋‚ธ๋‹ค. update์—์„œ๋Š” ์ œํ’ˆ์ด cart_product์— ์—†์œผ๋ฉด ์ถ”๊ฐ€ํ•˜๋ฉฐ, ์กด์žฌํ•  ๊ฒฝ์šฐ ์ˆ˜๋Ÿ‰์„ ์ฆ๊ฐ€์‹œํ‚จ๋‹ค.
  • link - update ์ƒ๋ช…์ฃผ๊ธฐ ํ•จ์ˆ˜๋ฅผ ๋ฐœ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜๋ฅผ ๋“ฑ๋ก์‹œํ‚จ๋‹ค.

Redux๋ฅผ ์ด์ „์— ์‚ฌ์šฉํ•ด ๋ณธ ์ ์ด ์žˆ๋‹ค๋ฉด, update๋Š” ๋ฆฌ๋“€์„œ(state ์—…๋ฐ์ดํŠธ)์™€ ์•ก์…˜ ์ƒ์„ฑ์ž(์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ)์— ์œ ์‚ฌํ•˜๋ฉฐ, Message๋Š” ์•ก์…˜, link๋Š” Dispatch์™€ ์œ ์‚ฌํ•˜๋‹ค.

4

UI๋Š” ์œ„์ฒ˜๋Ÿผ ๋ณ€ํ–ˆ๋‹ค. "Add to Cart" ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ "Cart Value"๊ฐ€ ์–ด๋–ป๊ฒŒ ๋ณ€๊ฒฝ๋˜๋Š”์ง€ ์‚ดํŽด๋ด๋ผ.

๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ

์ƒํ’ˆ ๋ฐ์ดํ„ฐ๋ฅผ createํ•จ์ˆ˜์—์„œ static/products/products.json ๋กœ ์˜ฎ๊ธฐ๊ณ , fetch api๋ฅผ ์‚ฌ์šฉํ•ด ์ฟผ๋ฆฌํ•  ๊ฒƒ์ด๋‹ค.

[
  {
    "id": 1,
    "name": "Apple",
    "description": "An apple a day keeps the doctor away",
    "image": "/products/apple.png",
    "price": 3.65
  },
  {
    "id": 2,
    "name": "Banana",
    "description": "An old banana leaf was once young and green",
    "image": "/products/banana.png",
    "price": 7.99
  }
]

Yew๋Š” "services"๋ฅผ ํ†ตํ•ด ์ผ๋ฐ˜ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ œ๊ณต๋˜๋Š” fetch๋‚˜ ๋กœ์ปฌ ์Šคํ† ๋ฆฌ์ง€ ๋“ฑ์„ ์ด์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•œ๋‹ค. FetchService๋ฅผ ์‚ฌ์šฉํ•ด ๋„คํŠธ์›Œํฌ ์š”์ฒญ์„ ์ƒ์„ฑํ•œ๋‹ค. ์‚ฌ์šฉ์„ ์œ„ํ•ด์„œ๋Š” anyhow์™€ serde๊ฐ€ ํ•„์š”ํ•œ๋ฐ, ํ•œ๋ฒˆ ์„ค์น˜ํ•ด๋ณด์ž.

  [package]
  name = "rustmart"
  version = "0.1.0"
  authors = ["sheshbabu <sheshbabu@gmail.com>"]
  edition = "2018"

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

  [dependencies]
  yew = "0.17"
  wasm-bindgen = "0.2"
+ anyhow = "1.0.32"
+ serde = { version = "1.0", features = ["derive"] }

Product์™€ CartProduct๋ฅผ src/types.rs๋กœ ์ถ”์ถœํ•ด ์—ฌ๋Ÿฌ ํŒŒ์ผ์—์„œ ๊ณต์œ ํ•ด ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ํ•˜์ž.

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Product {
    pub id: i32,
    pub name: String,
    pub description: String,
    pub image: String,
    pub price: f64,
}

#[derive(Clone, Debug)]
pub struct CartProduct {
    pub product: Product,
    pub quantity: i32,
}

๋‘ ๊ตฌ์กฐ์ฒด์™€ ํ•„๋“œ ๋ชจ๋‘ public์œผ๋กœ ๋งŒ๋“ค์–ด ์ค€ ๋’ค, Deserialize์™€ Serialize ํŠธ๋ ˆ์ž‡์„ ํŒŒ์ƒ์‹œ์ผœ์คฌ๋‹ค.

๋˜ํ•œ, API ๋ชจ๋“ˆ ํŒจํ„ด์™€ fetch ๋กœ์ง์„ ์œ ์ง€ํ•˜๊ธฐ ์œ„ํ•ด src/api.rs์— ๋ถ„๋ฆฌ๋œ ๋ชจ๋“ˆ์„ ๋งŒ๋“ค์–ด ์‚ฌ์šฉํ•  ๊ฒƒ์ด๋‹ค.

// src/api.rs
use crate::types::Product;
use anyhow::Error;
use yew::callback::Callback;
use yew::format::{Json, Nothing};
use yew::services::fetch::{FetchService, FetchTask, Request, Response};

pub type FetchResponse<T> = Response<Json<Result<T, Error>>>;
type FetchCallback<T> = Callback<FetchResponse<T>>;

pub fn get_products(callback: FetchCallback<Vec<Product>>) -> FetchTask {
    let req = Request::get("/products/products.json")
        .body(Nothing)
        .unwrap();

    FetchService::fetch(req, callback).unwrap()
}

FetchSerive api๋Š” ์•ฝ๊ฐ„ ์–ด์ƒ‰ํ•œ ๋ถ€๋ถ„์ด ์กด์žฌํ•œ๋‹ค. ์š”์ฒญ ๊ฐ์ฒด์™€ ์ฝœ๋ฐฑ์„ ์ธ์ˆ˜๋กœ ๋ฐ›์•„ "FetchTask"๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค๋Š” ๊ฒƒ์ด๋‹ค. ์—ฌ๊ธฐ์„œ ๋ฐœ์ƒํ•˜๋Š” ํ•œ ๊ฐ€์ง€ ๋†€๋ผ์šด ๋ฌธ์ œ๋Š” "FetchTask"๊ฐ€ ์‚ฌ๋ผ์ง€๋ฉด ์ด ๋„คํŠธ์›Œํฌ ์š”์ฒญ์ด ์ค‘๋‹จ๋œ๋‹ค๋Š” ๊ฒƒ์ด๋‹ค. ๊ทธ๋ž˜์„œ ์šฐ๋ฆฌ๋Š” ์ด FetchTask๋ฅผ ๋ฐ˜ํ™˜์‹œ์ผœ ์ปดํฌ๋„ŒํŠธ์— ์ €์žฅ์‹œํ‚ฌ ๊ฒƒ์ด๋‹ค.

lib.rs๋ฅผ ์ˆ˜์ •ํ•ด ์ƒˆ๋กœ์šด ๋ชจ๋“ˆ์„ ๋ชจ๋“ˆ ํŠธ๋ฆฌ์— ์ถ”๊ฐ€์‹œํ‚ค์ž.

  // src/lib.rs
+ mod api;
+ mod types;
  mod pages;

  use pages::Home;
  use wasm_bindgen::prelude::*;
  use yew::prelude::*;

  #[wasm_bindgen(start)]
  pub fn run_app() {
      App::<Home>::new().mount_to_body();
  }

๋งˆ์ง€๋ง‰์œผ๋กœ HomePage ์ปดํฌ๋„ŒํŠธ๋ฅผ ์—…๋ฐ์ดํŠธ ํ•˜์ž.

+ use crate::api;
+ use crate::types::{CartProduct, Product};
+ use anyhow::Error;
+ use yew::format::Json;
+ use yew::services::fetch::FetchTask;
  use yew::prelude::*;

- #[derive(Clone)]
- struct Product {
-     id: i32,
-     name: String,
-     description: String,
-     image: String,
-     price: f64,
- }

- struct CartProduct {
-     product: Product,
-     quantity: i32,
- }

  struct State {
      products: Vec<Product>,
      cart_products: Vec<CartProduct>,
+     get_products_error: Option<Error>,
+     get_products_loaded: bool,
  }

  pub struct Home {
      state: State,
      link: ComponentLink<Self>,
+     task: Option<FetchTask>,
  }

  pub enum Msg {
      AddToCart(i32),
+     GetProducts,
+     GetProductsSuccess(Vec<Product>),
+     GetProductsError(Error),
  }

  impl Component for Home {
      type Message = Msg;
      type Properties = ();

      fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
-         let products: Vec<Product> = vec![
-             Product {
-                 id: 1,
-                 name: "Apple".to_string(),
-                 description: "An apple a day keeps the doctor away".to_string(),
-                 image: "/products/apple.png".to_string(),
-                 price: 3.65,
-             },
-             Product {
-                 id: 2,
-                 name: "Banana".to_string(),
-                 description: "An old banana leaf was once young and green".to_string(),
-                 image: "/products/banana.png".to_string(),
-                 price: 7.99,
-             },
-         ];
+         let products = vec![];
          let cart_products = vec![];

+         link.send_message(Msg::GetProducts);

          Self {
              state: State {
                  products,
                  cart_products,
+                 get_products_error: None,
+                 get_products_loaded: false,
              },
              link,
+             task: None,
          }
      }

      fn update(&mut self, message: Self::Message) -> ShouldRender {
          match message {
+             Msg::GetProducts => {
+                 self.state.get_products_loaded = false;
+                 let handler =
+                     self.link
+                         .callback(move |response: api::FetchResponse<Vec<Product>>| {
+                             let (_, Json(data)) = response.into_parts();
+                             match data {
+                                 Ok(products) => Msg::GetProductsSuccess(products),
+                                 Err(err) => Msg::GetProductsError(err),
+                             }
+                         });
+                 self.task = Some(api::get_products(handler));
+                 true
+             }
+             Msg::GetProductsSuccess(products) => {
+                 self.state.products = products;
+                 self.state.get_products_loaded = true;
+                 true
+             }
+             Msg::GetProductsError(error) => {
+                 self.state.get_products_error = Some(error);
+                 self.state.get_products_loaded = true;
+                 true
+             }
              Msg::AddToCart(product_id) => {
                  let product = self
                      .state
                      .products
                      .iter()
                      .find(|p: &&Product| p.id == product_id)
                      .unwrap();
                  let cart_product = self
                      .state
                      .cart_products
                      .iter_mut()
                      .find(|cp: &&mut CartProduct| cp.product.id == product_id);

                  if let Some(cp) = cart_product {
                      cp.quantity += 1;
                  } else {
                      self.state.cart_products.push(CartProduct {
                          product: product.clone(),
                          quantity: 1,
                      })
                  }
                  true
              }
          }
      }

      fn change(&mut self, _: Self::Properties) -> ShouldRender {
          true
      }

      fn view(&self) -> Html {
          let products: Vec<Html> = self
              .state
              .products
              .iter()
              .map(|product: &Product| {
                  let product_id = product.id;
                  html! {
                    <div>
                      <img src={&product.image}/>
                      <div>{&product.name}</div>
                      <div>{"$"}{&product.price}</div>
                      <button onclick=self.link.callback(move |_| Msg::AddToCart(product_id))>{"Add To Cart"}</button>
                    </div>
                  }
              })
              .collect();

          let cart_value = self
              .state
              .cart_products
              .iter()
              .fold(0.0, |acc, cp| acc + (cp.quantity as f64 * cp.product.price));

+         if !self.state.get_products_loaded {
+             html! {
+               <div>{"Loading ..."}</div>
+             }
+         } else if let Some(_) = self.state.get_products_error {
+             html! {
+               <div>
+                 <span>{"Error loading products! :("}</span>
+               </div>
+             }
+         } else {
              html! {
                <div>
                  <span>{format!("Cart Value: {:.2}", cart_value)}</span>
                  <span>{products}</span>
                </div>
              }
+         }
      }
  }

๊ฝค ๋งŽ์€ ๋ณ€๊ฒฝ์ด ์žˆ์—ˆ์ง€๋งŒ, ๋Œ€๋ถ€๋ถ„์€ ์ดํ•ด๋ฅผ ํ•˜๊ณ  ๋„˜์–ด๊ฐ€์•ผ ํ•œ๋‹ค.

  • create๋ฉ”์†Œ๋“œ์—์„œ ๋นˆ ๋ฐฐ์—ด๋กœ ํ•˜๋“œ์ฝ”๋”ฉ ๋˜์–ด์žˆ์—ˆ๋˜ ์ƒํ’ˆ ๋ฆฌ์ŠคํŠธ๊ฐ€ ๋Œ€์ฒด ๋˜์—ˆ๋‹ค. Msg::GetProducts๋ฉ”์„ธ์ง€๋ฅผ ๋ณด๋‚ด update์—์„œ api๋ชจ๋“ˆ์— ์œ„์น˜ํ•œ get_products๋ฉ”์„œ๋“œ๋ฅผ ํ˜ธ์ถœ์‹œ์ผฐ๋‹ค. ๋ฐ˜ํ™˜๋œ FetchTask๋Š” task์— ์ €์žฅ๋œ๋‹ค.
  • ์š”์ฒญ์ด ์„ฑ๊ณตํ•˜๋ฉด Msg::GetProductsSuccess๋ฉ”์„ธ์ง€๊ฐ€ ์ƒํ’ˆ ๋ฆฌ์ŠคํŠธ์™€ ํ•จ๊ป˜ ํ˜ธ์ถœ๋˜๋ฉฐ, ์‹คํŒจํ•  ๊ฒฝ์šฐ ์—๋Ÿฌ์™€ ํ•จ๊ป˜ Msg::getProductsError๊ฐ€ ํ˜ธ์ถœ๋œ๋‹ค.
  • ์ด ๋‘ ๋ฉ”์„ธ์ง€๋Š” products์™€ get_products_error ํ•„๋“œ๋ฅผ ๊ฐ๊ฐ ์„ค์ •ํ•œ๋‹ค. ๋˜ํ•œ ์š”์ฒญ์ด ์™„๋ฃŒ๋œ ํ›„ get_products_loaded ์ƒํƒœ ๋˜ํ•œ true๋กœ ๋ณ€๊ฒฝํ•œ๋‹ค.
  • view๋ฉ”์„œ๋“œ์—์„œ๋Š” ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง์„ ํ†ตํ•ด ์ปดํฌ๋„ŒํŠธ์˜ ์ƒํƒœ์— ๋”ฐ๋ผ ๋กœ๋”ฉ, ์—๋Ÿฌ, ์ƒํ’ˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ณด์—ฌ์ค€๋‹ค.
5

์žฌ์‚ฌ์šฉ ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌํ•˜๊ธฐ

"product cart" ์ปดํฌ๋„ŒํŠธ๋ฅผ ๋ชจ๋“ˆํ™” ์‹œ์ผœ ๋‹ค๋ฅธ ํŽ˜์ด์ง€์—์„œ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก ๋ถ„๋ฆฌํ•ด๋ณด์ž.

// src/components/product_card.rs
use crate::types::Product;
use yew::prelude::*;

pub struct ProductCard {
    props: Props,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub product: Product,
    pub on_add_to_cart: Callback<()>,
}

impl Component for ProductCard {
    type Message = ();
    type Properties = Props;

    fn create(props: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Self { props }
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        true
    }

    fn view(&self) -> Html {
        let onclick = self.props.on_add_to_cart.reform(|_| ());

        html! {
          <div>
            <img src={&self.props.product.image}/>
            <div>{&self.props.product.name}</div>
            <div>{"$"}{&self.props.product.price}</div>
            <button onclick=onclick>{"Add To Cart"}</button>
          </div>
        }
    }
}
// src/components/mod.rs
mod product_card;

pub use product_card::ProductCard;
  // src/lib.rs
  mod api;
+ mod components;
  mod pages;
  mod types;

  // No changes
  // src/pages/home.rs

  use crate::api;
+ use crate::components::ProductCard;
  use crate::types::{CartProduct, Product};
  use anyhow::Error;
  use yew::format::Json;
  use yew::prelude::*;
  use yew::services::fetch::FetchTask;

  // No changes

  impl Component for Home {
      // No changes

      fn view(&self) -> Html {
          let products: Vec<Html> = self
              .state
              .products
              .iter()
              .map(|product: &Product| {
                  let product_id = product.id;
                  html! {
-                   <div>
-                     <img src={&product.image}/>
-                     <div>{&product.name}</div>
-                     <div>{"$"}{&product.price}</div>
-                     <button onclick=self.link.callback(move |_| Msg::AddToCart(product_id))>{"Add To Cart"}</button>
-                   </div>
+                   <ProductCard product={product} on_add_to_cart=self.link.callback(move |_| Msg::AddToCart(product_id))/>
                  }
              })
              .collect();

          // No changes
      }
  }

properties, Callback ๊ทธ๋ฆฌ๊ณ  reform์„ ์ œ์™ธํ•˜๊ณ ๋Š” ๊ฝค ๋ช…๋ฃŒํ•˜๋‹ค.

  • properties - ๊ธ€ ์‹œ์ž‘ ๋ถ€๋ถ„์—์„œ ์–ธ๊ธ‰ํ–ˆ๋“ฏ, "Properties"๋‚˜ "Props"๋ชจ๋‘ ์ปดํฌ๋„ŒํŠธ์— ์ž…๋ ฅ๋œ๋‹ค. ๋งŒ์•ฝ ์ปดํฌ๋„ŒํŠธ๋ฅผ ํ•จ์ˆ˜๋กœ ์ƒ๊ฐํ•˜๊ณ  ์žˆ๋‹ค๋ฉด, Props๋Š” ํ•จ์ˆ˜์˜ ์ธ์ž์ผ ๊ฒƒ์ด๋‹ค.
  • ProductCard์ปดํฌ๋„ŒํŠธ์˜ ๊ฒฝ์šฐ, Product ๊ตฌ์กฐ์ฒด์™€ on_add_to_cart ์ฝœ๋ฐฑ์„ ๋„˜๊ฒจ์ค€๋‹ค. ์ด ์ปดํฌ๋„ŒํŠธ๋Š” ์ƒํƒœ๊ฐ€ ์—†์œผ๋ฏ€๋กœ, "์žฅ๋ฐ”๊ตฌ๋‹ˆ๋กœ ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅผ ๊ฒฝ์šฐ ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ์˜ cart_products ์ƒํƒœ๋ฅผ ์—…๋ฐ์ดํŠธํ•œ๋‹ค. ์ด ์ฝœ๋ฐฑ์€ Callback<T> ํƒ€์ž…์œผ๋กœ ์‚ฌ์šฉ๋˜๋ฉฐ ์ž์‹ ์ปดํฌ๋„ŒํŠธ์—์„œ ํ˜ธ์ถœํ•˜๋ ค๋ฉด ์ฝœ๋ฐฑ์—์„œ emit๋˜๋Š” reform ๋ฉ”์†Œ๋“œ๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค.

7

๊พธ๋ฏธ๊ธฐ

์ด UI๋Š” ์Šคํƒ€์ผ์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜๊ธฐ ๋•Œ๋ฌธ์— ๋ผˆ๋Œ€๋งŒ ์กด์žฌํ•œ๋‹ค.

6

Yew๋ฅผ ์‚ฌ์šฉํ•ด class ์š”์†Œ์™€ ์ธ๋ผ์ธ ์Šคํƒ€์ผ ๋ชจ๋‘ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. ๋ช‡๊ฐ€์ง€ ์Šคํƒ€์ผ์„ ์ถ”๊ฐ€ํ•ด UI๋ฅผ ๋ณด๊ธฐ ์ข‹๊ฒŒ ๋ฐ”๊ฟ”๋ณด์ž.

CSS ํŒŒ์ผ์„ static/styles.css๋กœ ๋งŒ๋“ค๊ณ , static/index.html์— ์ถ”๊ฐ€ํ•œ ๋’ค ์ปดํฌ๋„ŒํŠธ์— ํด๋ž˜์Šค๋ฅผ ์ถ”๊ฐ€ํ•ด๋ณด์ž.

  // src/pages/home.rs

  html! {
    <div>
-     <span>{format!("Cart Value: {:.2}", cart_value)}</span>
-     <span>{products}</span>
+     <div class="navbar">
+         <div class="navbar_title">{"RustMart"}</div>
+         <div class="navbar_cart_value">{format!("${:.2}", cart_value)}</div>
+     </div>
+     <div class="product_card_list">{products}</div>
    </div>
  }
  html! {
-   <div>
-     <img src={&self.props.product.image}/>
-     <div>{&self.props.product.name}</div>
-     <div>{"$"}{&self.props.product.price}</div>
-     <button onclick=onclick>{"Add To Cart"}</button>
-   </div>
+   <div class="product_card_container">
+     <img class="product_card_image" src={&self.props.product.image}/>
+     <div class="product_card_name">{&self.props.product.name}</div>
+     <div class="product_card_price">{"$"}{&self.props.product.price}</div>
+     <button class="product_atc_button" onclick=onclick>{"Add To Cart"}</button>
+   </div>
  }

๋ช‡ ๊ฐ€์ง€ ์Šคํƒ€์ผ๊ณผ ์ƒํ’ˆ๋“ค์„ ์ถ”๊ฐ€ํ•œ ๋’ค, UI๋ฅผ ์‚ดํŽด๋ณด์ž.

8

CSS ๋ณ€ํ™”๋Š” ์ด ๊ธ€์—์„œ ๋‹ค๋ฃจ์ง€ ์•Š์•˜๋‹ค. Github ์ €์žฅ์†Œ๋ฅผ ์ฐธ๊ณ ํ•ด๋‹ฌ๋ผ.

๋ผ์šฐํŒ…

์„œ๋ฒ„ ๋ Œ๋”๋ง ํŽ˜์ด์ง€(Jinja, ERB, JSP emdemd)์—์„œ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ๋ณด๋Š” ๊ฐ ํŽ˜์ด์ง€๋Š” ๋‹ค๋ฅธ ํ…œํ”Œ๋ฆฐ ํŒŒ์ผ๋“ค์— ๋งค์นญ๋œ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์‚ฌ์šฉ์ž๊ฐ€ "/login"์œผ๋กœ ์ด๋™ํ•  ๋•Œ, ์„œ๋ฒ„๋Š” "login.html"๋ฅผ ์ด์šฉํ•ด ๋ Œ๋”๋งํ•˜๋ฉฐ "/settings"๋กœ ์ด๋™ํ•  ๊ฒฝ์šฐ, "settings.html" ํŒŒ์ผ์„ ์ด์šฉํ•ด ๋ Œ๋”๋งํ•œ๋‹ค. ๊ฐ๊ฐ ๋‹ค๋ฅธ ํ™”๋ฉด์˜ ํŽ˜์ด์ง€๋ฅผ ๋ Œ๋”๋ง ํ• ๋•Œ ๊ณ ์œ ํ•œ URL์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ๋ถ๋งˆํฌ๋ฅผ ์ง€์ •ํ•˜๊ฑฐ๋‚˜ ๊ณต์œ ํ•  ๋•Œ๋„ ์œ ์šฉํ•˜๋‹ค.

SPA๋Š” ํ•˜๋‚˜์˜ html ํŽ˜์ด์ง€(SPA์˜ "Single Page"๋ฅผ ์˜๋ฏธํ•˜๋Š”)๋งŒ ๊ฐ€์ง€๋ฉฐ, ์œ„์—์„œ ๋งํ•œ ๋™์ž‘์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. ์ด ์ž‘์—…์€ Router๋ฅผ ์‚ฌ์šฉํ•ด ์ˆ˜ํ–‰ํ•œ๋‹ค. Router๋Š” ๋‹ค๋ฅธ URL ๊ฒฝ๋กœ(์ฟผ๋ฆฌ ๋งค๊ฐœ๋ณ€์ˆ˜๋‚˜ fragment๋“ฑ)๋ฅผ ๋‹ค๋ฅธ ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ์— ๋งคํ•‘ํ•˜๊ณ  ์ƒˆ๋กœ ๊ณ ์นจ ์—†์ด ์—ฌ๋Ÿฌ ํŽ˜์ด์ง€๋ฅผ ์˜ฎ๊ฒจ ๋‹ค๋‹ ์ˆ˜ ์žˆ๋Š” ์—ญํ• ์„ ๋•๋Š”๋‹ค.

์ง€๊ธˆ ๋งŒ๋“œ๋Š” ์•ฑ์—์„œ๋Š”, ๋‹ค์Œ์ฒ˜๋Ÿผ ๋งคํ•‘ํ•  ๊ฒƒ์ด๋‹ค.

/            => HomePage
/product/:id => ProductDetailPage

yew-router๋ฅผ ์„ค์น˜ํ•ด๋ณด์ž

  [package]
  name = "rustmart"
  version = "0.1.0"
  authors = ["sheshbabu <sheshbabu@gmail.com>"]
  edition = "2018"

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

  [dependencies]
  yew = "0.17"
+ yew-router = "0.14.0"
  wasm-bindgen = "0.2"
  log = "0.4.6"
  wasm-logger = "0.2.0"
  anyhow = "1.0.32"
  serde = { version = "1.0", features = ["derive"] }

๋ณด๊ธฐ ์‰ฝ๋„๋ก ๋ชจ๋“  ๊ฒฝ๋กœ๋ฅผ ํ•œ ํŒŒ์ผ์— ์ถ”๊ฐ€ํ•ด๋ณด์ž.

// src/route.rs
use yew_router::prelude::*;

#[derive(Switch, Debug, Clone)]
pub enum Route {
    #[to = "/"]
    HomePage,
}

์ž ์‹œ ๋™์•ˆ ํ•˜๋‚˜์˜ ๊ฒฝ๋กœ๋งŒ ์ถ”๊ฐ€ํ•œ๋‹ค. ์ดํ›„์— ์ถ”๊ฐ€ํ•  ๊ฒƒ์ด๋‹ค.

HomePage๋ฅผ ๋Œ€์ฒดํ•  ์ƒˆ๋กœ์šด ๋ฃจํŠธ ์ปดํฌ๋„ŒํŠธ๋ฅผ src/app.rs์— ์ƒ์„ฑํ•˜์ž.

use yew::prelude::*;
use yew_router::prelude::*;

use crate::pages::Home;
use crate::route::Route;

pub struct App {}

impl Component for App {
    type Message = ();
    type Properties = ();

    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Self {}
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        let render = Router::render(|switch: Route| match switch {
            Route::HomePage => html! {<Home/>},
        });

        html! {
            <Router<Route, ()> render=render/>
        }
    }
}

๊ทธ์— ๋”ฐ๋ผ lib.rs ๋˜ํ•œ ์ˆ˜์ •ํ•œ๋‹ค.

  mod api;
+ mod app;
  mod components;
  mod pages;
+ mod route;
  mod types;

- use pages::Home;
  use wasm_bindgen::prelude::*;
  use yew::prelude::*;

  #[wasm_bindgen(start)]
  pub fn run_app() {
      wasm_logger::init(wasm_logger::Config::default());
-     App::<Home>::new().mount_to_body();
+     App::<app::App>::new().mount_to_body();
  }

์•„๋ž˜ ๊ทธ๋ฆผ์€ ์•ฑ์˜ ์ปดํฌ๋„ŒํŠธ ๊ณ„์ธต์ด ์–ด๋–ป๊ฒŒ ๋˜์—ˆ๋Š”์ง€ ์‚ดํŽด ๋ณผ ์ˆ˜ ์žˆ๋‹ค.

9

ProductDetailPage

๋ผ์šฐํ„ฐ๊ฐ€ ์ค€๋น„๋˜์—ˆ์œผ๋‹ˆ, ์ด๋ฅผ ์ด์šฉํ•ด ๋‹ค๋ฅธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋„๋ก ๋งŒ๋“ค์–ด๋ณด์ž. ์ด ์•ฑ์€ SPA์ด๊ธฐ ๋•Œ๋ฌธ์— ์ด๋™ ์ค‘ ํŽ˜์ด์ง€๋ฅผ ์ƒˆ๋กœ๊ณ ์นจ ํ•˜๋Š” ๊ฒƒ์„ ํ”ผํ•ด์•ผ ํ•œ๋‹ค.

/product/:id๊ฒฝ๋กœ๋กœ ProductDetailPage์— ๋Œ€ํ•œ ๊ฒฝ๋กœ๋ฅผ ์ถ”๊ฐ€ํ•˜์ž. ProductCard๋ฅผ ํด๋ฆญํ•˜๋ฉด, id๋ฅผ Prop์œผ๋กœ ๊ฒฝ๋กœ์— ์ „๋‹ฌํ•ด ์„ธ๋ถ€ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•  ๊ฒƒ์ด๋‹ค.

  // src/route.rs
  use yew_router::prelude::*;

  #[derive(Switch, Debug, Clone)]
  pub enum Route {
+     #[to = "/product/{id}"]
+     ProductDetail(i32),
      #[to = "/"]
      HomePage,
  }

์œ„ ๊ฒฝ๋กœ์˜ ์ˆœ์„œ์— ๋”ฐ๋ผ ๋จผ์ € ๋ Œ๋”๋ง ๋˜๋Š” ํŽ˜์ด์ง€๊ฐ€ ๊ฒฐ์ • ๋œ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, /product/2๋Š” /product/{id}์™€ / ๋‘˜ ๋‹ค ๋งค์นญ๋˜์ง€๋งŒ /product/{id}๋ฅผ ๋จผ์ € ์ž‘์„ฑํ•ด์คฌ์œผ๋ฏ€๋กœ, Home ๋Œ€์‹  ProductDetail ํŽ˜์ด์ง€๊ฐ€ ๋ Œ๋”๋ง ๋  ๊ฒƒ์ด๋‹ค.

app.rs๋กœ ๊ฒฝ๋กœ๋ฅผ ์ถ”๊ฐ€ํ•˜์ž.

  use yew::prelude::*;
  use yew_router::prelude::*;

- use crate::pages::{Home};
+ use crate::pages::{Home, ProductDetail};
  use crate::route::Route;

  pub struct App {}

  impl Component for App {
      // No changes

      fn view(&self) -> Html {
          let render = Router::render(|switch: Route| match switch {
+             Route::ProductDetail(id) => html! {<ProductDetail id=id/>},
              Route::HomePage => html! {<Home/>},
          });

          html! {
              <Router<Route, ()> render=render/>
          }
      }
  }

ProductCard๋ฅผ ์ˆ˜์ •ํ•ด ์ƒํ’ˆ ์ด๋ฏธ์ง€๋‚˜ ์ด๋ฆ„, ๊ฐ€๊ฒฉ์„ ํด๋ฆญํ•˜๋ฉด ์ƒˆ๋กœ์šด ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜๋„๋ก ๋งŒ๋“ค์–ด๋ณด์ž.

  // src/components/product_card.rs
+ use crate::route::Route;
  use crate::types::Product;
  use yew::prelude::*;
+ use yew_router::components::RouterAnchor;

  // No changes

  impl Component for ProductCard {
      // No changes

      fn view(&self) -> Html {
+         type Anchor = RouterAnchor<Route>;
          let onclick = self.props.on_add_to_cart.reform(|_| ());

          html! {
              <div class="product_card_container">
+                 <Anchor route=Route::ProductDetail(self.props.product.id) classes="product_card_anchor">
                      <img class="product_card_image" src={&self.props.product.image}/>
                      <div class="product_card_name">{&self.props.product.name}</div>
                      <div class="product_card_price">{"$"}{&self.props.product.price}</div>
+                 </Anchor>
                  <button class="product_atc_button" onclick=onclick>{"Add To Cart"}</button>
              </div>
          }
      }
  }

Anchor์— class๋Œ€์‹  classes๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๋ถ€๋ถ„๋„ ์ฃผ์˜ํ•ด์„œ ์‚ดํŽด๋ณด์ž.

์ด์ œ ๋ชจํ‚น๋œ ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ๋Š” ํŒŒ์ผ๋“ค์„ static/products/1.json, static/products/2.json ๋“ฑ๋“ฑ ์œผ๋กœ ๋งŒ๋“ค์–ด๋ณด์ž.

{
  "id": 1,
  "name": "Apple",
  "description": "An apple a day keeps the doctor away",
  "image": "/products/apple.png",
  "price": 3.65
}

๊ทธ๋ฆฌ๊ณ , api.rs ๋ชจ๋“ˆ์„ ์ˆ˜์ •ํ•ด ์ƒˆ๋กœ์šด ๊ฒฝ๋กœ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค.

 use crate::types::Product;
  use anyhow::Error;
  use yew::callback::Callback;
  use yew::format::{Json, Nothing};
  use yew::services::fetch::{FetchService, FetchTask, Request, Response};

  pub type FetchResponse<T> = Response<Json<Result<T, Error>>>;
  type FetchCallback<T> = Callback<FetchResponse<T>>;

  pub fn get_products(callback: FetchCallback<Vec<Product>>) -> FetchTask {
      let req = Request::get("/products/products.json")
          .body(Nothing)
          .unwrap();

      FetchService::fetch(req, callback).unwrap()
  }

+ pub fn get_product(id: i32, callback: FetchCallback<Product>) -> FetchTask {
+     let req = Request::get(format!("/products/{}.json", id))
+         .body(Nothing)
+         .unwrap();
+
+     FetchService::fetch(req, callback).unwrap()
+ }

๊ทธ๋Ÿผ, ์ตœ์ข… ProductDetail ํŽ˜์ด์ง€๋ฅผ ์‚ดํŽด๋ณด์ž.

// src/pages/product_detail.rs
use crate::api;
use crate::types::Product;
use anyhow::Error;
use yew::format::Json;
use yew::prelude::*;
use yew::services::fetch::FetchTask;

struct State {
    product: Option<Product>,
    get_product_error: Option<Error>,
    get_product_loaded: bool,
}

pub struct ProductDetail {
    props: Props,
    state: State,
    link: ComponentLink<Self>,
    task: Option<FetchTask>,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub id: i32,
}

pub enum Msg {
    GetProduct,
    GetProductSuccess(Product),
    GetProductError(Error),
}

impl Component for ProductDetail {
    type Message = Msg;
    type Properties = Props;

    fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
        link.send_message(Msg::GetProduct);

        Self {
            props,
            state: State {
                product: None,
                get_product_error: None,
                get_product_loaded: false,
            },
            link,
            task: None,
        }
    }

    fn update(&mut self, message: Self::Message) -> ShouldRender {
        match message {
            Msg::GetProduct => {
                let handler = self
                    .link
                    .callback(move |response: api::FetchResponse<Product>| {
                        let (_, Json(data)) = response.into_parts();
                        match data {
                            Ok(product) => Msg::GetProductSuccess(product),
                            Err(err) => Msg::GetProductError(err),
                        }
                    });

                self.task = Some(api::get_product(self.props.id, handler));
                true
            }
            Msg::GetProductSuccess(product) => {
                self.state.product = Some(product);
                self.state.get_product_loaded = true;
                true
            }
            Msg::GetProductError(error) => {
                self.state.get_product_error = Some(error);
                self.state.get_product_loaded = true;
                true
            }
        }
    }

    fn change(&mut self, _: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        if let Some(ref product) = self.state.product {
            html! {
                <div class="product_detail_container">
                    <img class="product_detail_image" src={&product.image}/>
                    <div class="product_card_name">{&product.name}</div>
                    <div style="margin: 10px 0; line-height: 24px;">{&product.description}</div>
                    <div class="product_card_price">{"$"}{&product.price}</div>
                    <button class="product_atc_button">{"Add To Cart"}</button>
                </div>
            }
        } else if !self.state.get_product_loaded {
            html! {
                <div class="loading_spinner_container">
                    <div class="loading_spinner"></div>
                    <div class="loading_spinner_text">{"Loading ..."}</div>
                </div>
            }
        } else {
            html! {
                <div>
                    <span>{"Error loading product! :("}</span>
                </div>
            }
        }
    }
}

HomePage ์ปดํฌ๋„ŒํŠธ์™€ ๋งค์šฐ ์œ ์‚ฌํ•˜๋‹ค. ๊ทธ๋Ÿผ ์ด ํŒŒ์ผ ๋˜ํ•œ module tree์— ์ถ”๊ฐ€ํ•˜์ž.

  // src/pages/mod.rs
  mod home;
+ mod product_detail;

  pub use home::Home;
+ pub use product_detail::ProductDetail;

ํ™”๋ฉด์„ ์‚ดํŽด๋ณด์ž.

10

์ด์ œ ์ƒˆ๋กœ๊ณ ์นจ ์—†์ด ์—ฌ๋Ÿฌ ํŽ˜์ด์ง€๋ฅผ ์ด๋™ํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋˜์—ˆ๋‹ค!

์ƒํƒœ ๊ด€๋ฆฌ

ProductDetail ํŽ˜์ด์ง€์—์„œ ๋ˆˆ์น˜์ฑ˜๋Š”์ง€ ๋ชจ๋ฅด๊ฒ ์ง€๋งŒ, "์žฅ๋ฐ”๊ตฌ๋‹ˆ๋กœ ์ถ”๊ฐ€" ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด๋ฉด ํ˜„์žฌ๋Š” ์ž‘๋™ํ•˜์ง€ ์•Š๋Š”๋‹ค. cart_products๊ฐ€ ํ˜„์žฌ Home ํŽ˜์ด์ง€ ์ปดํฌ๋„ŒํŠธ์— ์กด์žฌํ•˜๊ธฐ ๋•Œ๋ฌธ์ด๋‹ค.

11

๋‘ ์ปดํฌ๋„ŒํŠธ์—์„œ ์ƒํƒœ๋ฅผ ๊ณต์œ ํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ๋‘ ๊ฐ€์ง€๋ฅผ ํ•  ์ˆ˜ ์žˆ๋Š”๋ฐ,

  • ์ƒํƒœ๋ฅผ ๊ณตํ†ต ์กฐ์ƒ์œผ๋กœ ํ˜ธ์ด์ŠคํŒ… ํ•˜๊ฑฐ๋‚˜
  • ์ „์—ญ ์•ฑ ์ƒํƒœ๋กœ ์ด๋™ ์‹œํ‚ค๋Š” ๊ฒƒ์ด๋‹ค.

App ์ปดํฌ๋„ŒํŠธ๋Š” ProductDetail๊ณผ Home์˜ ๊ณตํ†ต ์กฐ์ƒ ์ปดํฌ๋„ŒํŠธ์ด๋‹ค. ๋”ฐ๋ผ์„œ, cart_products ์ƒํƒœ๋ฅผ ์˜ฎ๊ธฐ๋ฉด ์ด ์ƒํƒœ๋ฅผ prop์œผ๋กœ ProductDetail๊ณผ Home์— ์ „๋‹ฌํ•ด ์ค„ ์ˆ˜ ์žˆ๊ฒŒ ๋œ๋‹ค.

12

์ด ๋ฐฉ๋ฒ•์€ ์ปดํฌ๋„ŒํŠธ๋“ค์ด ์–•์€ ๊ณ„์ธต์„ ๊ฐ€์งˆ ๊ฒฝ์šฐ ์ž˜ ๋™์ž‘ํ•˜์ง€๋งŒ, ๊ณ„์ธต ๊ตฌ์กฐ๊ฐ€ ๊นŠ์€ ๊ฒฝ์šฐ(๋” ํฐ ๊ทœ๋ชจ์˜ SPA์˜ ๊ฒฝ์šฐ) ์›ํ•˜๋Š” ์ปดํฌ๋„ŒํŠธ์— ๋„๋‹ฌํ•˜๊ธฐ ์œ„ํ•ด์„œ๋Š” ์—ฌ๋Ÿฌ ๊ณ„์ธต์˜ ์ปดํฌ๋„ŒํŠธ(์ด prop์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š์„ ์ˆ˜๋„ ์žˆ๋Š”)๋ฅผ ํ†ตํ•ด ์ด ์ƒํƒœ๋ฅผ ์ „๋‹ฌํ•ด์•ผ ํ•œ๋‹ค. ์ด๊ฒƒ์„ "Prop Drilling"์ด๋ผ ํ•œ๋‹ค.

ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ๋„ cart_products๊ฐ€ ์ƒํƒœ์— ์‚ฌ์šฉ๋˜์ง€ ์•Š๋”๋ผ๋„ App์—์„œ AppToCart ์ปดํฌ๋„ŒํŠธ๋กœ ์ „๋‹ฌ ๋˜๊ธฐ ์œ„ํ•ด ProductDetail๊ณผ Home ์ปดํฌ๋„ŒํŠธ๋ฅผ ๊ฑฐ์น˜๋Š” ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๋‹ค. ๊ฐ™์€ ์‹œ๋‚˜๋ฆฌ์˜ค๊ฐ€ ๊นŠ์€ ๊ณ„์ธต์„ ๊ฐ–๋Š” ์ปดํฌ๋„ŒํŠธ์—์„œ ์ผ์–ด๋‚œ๋‹ค๊ณ  ์ƒ๊ฐํ•ด๋ณด๋ผ.

์ด๋Ÿฐ ๋ฌธ์ œ๋ฅผ ์ „์—ญ ์ƒํƒœ๋ฅผ ํ†ตํ•ด ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ๋‹ค. ์—ฌ๊ธฐ ์–ด๋–ป๊ฒŒ ๋™์ž‘ํ•˜๋Š”์ง€ ํ•จ๊ป˜ ์‚ดํŽด๋ณด์ž.

13

๊ฐ ์ปดํฌ๋„ŒํŠธ์™€ ์ „์—ญ ์ƒํƒœ๊ฐ€ ์–ด๋–ป๊ฒŒ ์ง์ ‘ ์—ฐ๊ฒฐ์ด ๋˜์–ด ์žˆ๋Š”์ง€๋ฅผ ์ฃผ์˜ ๊นŠ๊ฒŒ ๋ณด์ž.

๋ถˆํ–‰ํ•˜๊ฒŒ๋„, Yew๋Š” ์ข‹์€ ํ•ด๊ฒฐ์ฑ…์ด ์•„์ง ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ธ๋‹ค. ๊ถŒ์žฅ๋˜๋Š” ํ•ด๊ฒฐ์ฑ…์€ pubsub์„ ํ†ตํ•ด ์ƒํƒœ ๋ณ€๊ฒฝ์„ ๋ธŒ๋กœ๋“œ ์บ์ŠคํŒ… ํ•˜๊ธฐ ์œ„ํ•ด Agents๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ์ด๊ฒƒ์€ ๋น ๋ฅด๊ฒŒ ์•ฑ์„ ๋”๋Ÿฝํž ์ˆ˜ ์žˆ์–ด ์‚ฌ์šฉ์„ ํ•˜์ง€ ์•Š๊ณ  ์žˆ๋‹ค. ๋ฏธ๋ž˜์—๋Š” React์˜ Context๋‚˜ Redux, Mobx ๊ฐ™์€ ๊ฒƒ์„ ๋ณผ ์ˆ˜ ์žˆ๊ธธ ๊ธฐ๋Œ€ํ•œ๋‹ค.

๊ทธ๋Ÿผ ์ƒํƒœ๋ฅผ ํ˜ธ์ด์ŠคํŒ… ํ•ด์„œ ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•ด๋ณด์ž.

์ƒํƒœ ํ˜ธ์ด์ŠคํŒ…

cart_products ์ƒํƒœ๋ฅผ App์œผ๋กœ ์ด๋™์‹œํ‚ค๊ณ  NavBar ๋ฐ AtcButton๋ฅผ ๋ณ„๋„ ์ปดํฌ๋„ŒํŠธ๋กœ ๊ตฌ์„ฑํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ฆฌํŒฉํ† ๋ง ํ•ด๋ณด์ž

14

// src/components/navbar.rs
use crate::types::CartProduct;
use yew::prelude::*;

pub struct Navbar {
    props: Props,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub cart_products: Vec<CartProduct>,
}

impl Component for Navbar {
    type Message = ();
    type Properties = Props;

    fn create(props: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Self { props }
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.props = props;
        true
    }

    fn view(&self) -> Html {
        let cart_value = self
            .props
            .cart_products
            .iter()
            .fold(0.0, |acc, cp| acc + (cp.quantity as f64 * cp.product.price));

        html! {
            <div class="navbar">
                <div class="navbar_title">{"RustMart"}</div>
              <div class="navbar_cart_value">{format!("${:.2}", cart_value)}</div>
            </div>
        }
    }
}

Navbar ์ปดํฌ๋„ŒํŠธ์—์„œ change ์ƒ๋ช…์ฃผ๊ธฐ ๋ฉ”์„œ๋“œ๋ฅผ ์–ด๋–ป๊ฒŒ ์‚ฌ์šฉํ–ˆ๋Š”์ง€๋ฅผ ์ฃผ๋ชฉํ•ด๋ณด์ž. ๋ถ€๋ชจ์—์„œ ๋ณด๋‚ธ props๊ฐ€ ๋ณ€๊ฒฝ๋œ๋‹ค๋ฉด, UI๊ฐ€ ๋‹ค์‹œ ๋ฆฌ๋ Œ๋”๋ง ๋˜๋„๋ก ์ปดํฌ๋„ŒํŠธ ๋‚ด๋ถ€์˜ props๋ฅผ ์—…๋ฐ์ดํŠธ ํ•ด์•ผ ํ•œ๋‹ค.

// src/components/atc_button.rs
use crate::types::Product;
use yew::prelude::*;

pub struct AtcButton {
    props: Props,
    link: ComponentLink<Self>,
}

#[derive(Properties, Clone)]
pub struct Props {
    pub product: Product,
    pub on_add_to_cart: Callback<Product>,
}

pub enum Msg {
    AddToCart,
}

impl Component for AtcButton {
    type Message = Msg;
    type Properties = Props;

    fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self { props, link }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::AddToCart => self.props.on_add_to_cart.emit(self.props.product.clone()),
        }
        true
    }

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.props = props;
        true
    }

    fn view(&self) -> Html {
        let onclick = self.link.callback(|_| Msg::AddToCart);

        html! {
          <button class="product_atc_button" onclick=onclick>{"Add To Cart"}</button>
        }
    }
}
  // src/components/mod.rs
+ mod atc_button;
+ mod navbar;
  mod product_card;

+ pub use atc_button::AtcButton;
+ pub use navbar::Navbar;
  pub use product_card::ProductCard;

๊ทธ๋ฆฌ๊ณ  ์ƒˆ๋กœ์šด AtcButton ์ปดํฌ๋„ŒํŠธ๋ฅผ ProductCard์™€ ProductDetail์—์„œ ์‚ฌ์šฉํ•˜์ž.

  // src/components/product_card.rs
+ use crate::components::AtcButton;
  use crate::route::Route;
  use crate::types::Product;
  use yew::prelude::*;
  use yew_router::components::RouterAnchor;

  pub struct ProductCard {
      props: Props,
  }

  #[derive(Properties, Clone)]
  pub struct Props {
      pub product: Product,
-     pub on_add_to_cart: Callback<()>,
+     pub on_add_to_cart: Callback<Product>,
  }

  impl Component for ProductCard {
      // No changes

      fn view(&self) -> Html {
          type Anchor = RouterAnchor<Route>;
-         let onclick = self.props.on_add_to_cart.reform(|_| ());

          html! {
              <div class="product_card_container">
                  <Anchor route=Route::ProductDetail(self.props.product.id) classes="product_card_anchor">
                      <img class="product_card_image" src={&self.props.product.image}/>
                      <div class="product_card_name">{&self.props.product.name}</div>
                      <div class="product_card_price">{"$"}{&self.props.product.price}</div>
                  </Anchor>
-                 <button class="product_atc_button" onclick=onclick>{"Add To Cart"}</button>
+                 <AtcButton product=self.props.product.clone() on_add_to_cart=self.props.on_add_to_cart.clone() />
              </div>
          }
      }
  }
  // src/pages/product_detail.rs
  use crate::api;
+ use crate::components::AtcButton;
  use crate::types::Product;
  use anyhow::Error;
  use yew::format::Json;
  use yew::prelude::*;
  use yew::services::fetch::FetchTask;

  // No changes

  #[derive(Properties, Clone)]
  pub struct Props {
      pub id: i32,
+     pub on_add_to_cart: Callback<Product>,
  }

  impl Component for ProductDetail {
      // No changes

      fn view(&self) -> Html {
          if let Some(ref product) = self.state.product {
              html! {
                  <div class="product_detail_container">
                      <img class="product_detail_image" src={&product.image}/>
                      <div class="product_card_name">{&product.name}</div>
                      <div style="margin: 10px 0; line-height: 24px;">{&product.description}</div>
                      <div class="product_card_price">{"$"}{&product.price}</div>
-                     <button class="product_atc_button">{"Add To Cart"}</button>
+                     <AtcButton product=product.clone() on_add_to_cart=self.props.on_add_to_cart.clone() />
                  </div>
              }
          }

          // No changes
      }
  }

๋งˆ์ง€๋ง‰์œผ๋กœ, cart_products๋ฅผ Home์—์„œ App์œผ๋กœ ์˜ฎ๊ธด๋‹ค.

  // src/app.rs
+ use crate::components::Navbar;
+ use crate::types::{CartProduct, Product};
  use yew::prelude::*;
  use yew_router::prelude::*;

  use crate::pages::{Home, ProductDetail};
  use crate::route::Route;

+ struct State {
+     cart_products: Vec<CartProduct>,
+ }

- pub struct App {}
+ pub struct App {
+     state: State,
+     link: ComponentLink<Self>,
+ }

+ pub enum Msg {
+     AddToCart(Product),
+ }

  impl Component for App {
-     type Message = ();
+     type Message = Msg;
      type Properties = ();

-     fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
+     fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
+         let cart_products = vec![];

-         Self {}
+         Self {
+             state: State { cart_products },
+             link,
+         }
      }

-     fn update(&mut self, _msg: Self::Message) -> ShouldRender {
+     fn update(&mut self, message: Self::Message) -> ShouldRender {
+         match message {
+             Msg::AddToCart(product) => {
+                 let cart_product = self
+                     .state
+                     .cart_products
+                     .iter_mut()
+                     .find(|cp: &&mut CartProduct| cp.product.id == product.id);

+                 if let Some(cp) = cart_product {
+                     cp.quantity += 1;
+                 } else {
+                     self.state.cart_products.push(CartProduct {
+                         product: product.clone(),
+                         quantity: 1,
+                     })
+                 }
+                 true
+             }
+         }
-         true
      }

      fn change(&mut self, _: Self::Properties) -> ShouldRender {
          false
      }

      fn view(&self) -> Html {
+         let handle_add_to_cart = self
+             .link
+             .callback(|product: Product| Msg::AddToCart(product));
+         let cart_products = self.state.cart_products.clone();

-         let render = Router::render(|switch: Route| match switch {
-           Route::ProductDetail(id) => html! {<ProductDetail id=id/>},
-           Route::HomePage => html! {<Home/>},
+         let render = Router::render(move |switch: Route| match switch {
+             Route::ProductDetail(id) => {
+                 html! {<ProductDetail id=id on_add_to_cart=handle_add_to_cart.clone() />}
+             }
+             Route::HomePage => {
+                 html! {<Home cart_products=cart_products.clone() on_add_to_cart=handle_add_to_cart.clone()/>}
+             }
          });

          html! {
+             <>
+                 <Navbar cart_products=self.state.cart_products.clone() />
                  <Router<Route, ()> render=render/>
+             </>
          }
      }
  }
  // src/pages/home.rs
  // No changes

  struct State {
      products: Vec<Product>,
-     cart_products: Vec<CartProduct>,
      get_products_error: Option<Error>,
      get_products_loaded: bool,
  }

+ #[derive(Properties, Clone)]
+ pub struct Props {
+     pub cart_products: Vec<CartProduct>,
+     pub on_add_to_cart: Callback<Product>,
+ }

  pub struct Home {
+     props: Props,
      state: State,
      link: ComponentLink<Self>,
      task: Option<FetchTask>,
  }

  pub enum Msg {
-     AddToCart(i32),
      GetProducts,
      GetProductsSuccess(Vec<Product>),
      GetProductsError(Error),
  }

  impl Component for Home {
      type Message = Msg;
-     type Properties = ();
+     type Properties = Props;

-     fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
+     fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
          let products = vec![];
-         let cart_products = vec![];

          link.send_message(Msg::GetProducts);

          Self {
              props,
              state: State {
                  products,
-                 cart_products,
                  get_products_error: None,
                  get_products_loaded: false,
              },
              link,
              task: None,
          }
      }

      fn update(&mut self, message: Self::Message) -> ShouldRender {
          match message {
              Msg::GetProducts => {
                  self.state.get_products_loaded = false;
                  let handler =
                      self.link
                          .callback(move |response: api::FetchResponse<Vec<Product>>| {
                              let (_, Json(data)) = response.into_parts();
                              match data {
                                  Ok(products) => Msg::GetProductsSuccess(products),
                                  Err(err) => Msg::GetProductsError(err),
                              }
                          });

                  self.task = Some(api::get_products(handler));
                  true
              }
              Msg::GetProductsSuccess(products) => {
                  self.state.products = products;
                  self.state.get_products_loaded = true;
                  true
              }
              Msg::GetProductsError(error) => {
                  self.state.get_products_error = Some(error);
                  self.state.get_products_loaded = true;
                  true
              }
-             Msg::AddToCart(product_id) => {
-                 let product = self
-                     .state
-                     .products
-                     .iter()
-                     .find(|p: &&Product| p.id == product_id)
-                     .unwrap();
-                 let cart_product = self
-                     .state
-                     .cart_products
-                     .iter_mut()
-                     .find(|cp: &&mut CartProduct| cp.product.id == product_id);
-                 if let Some(cp) = cart_product {
-                     cp.quantity += 1;
-                 } else {
-                     self.state.cart_products.push(CartProduct {
-                         product: product.clone(),
-                         quantity: 1,
-                     })
-                 }
-                 true
-             }
          }
      }

-     fn change(&mut self, _: Self::Properties) -> ShouldRender {
+     fn change(&mut self, props: Self::Properties) -> ShouldRender {
+         self.props = props;
          true
      }

      fn view(&self) -> Html {
          let products: Vec<Html> = self
              .state
              .products
              .iter()
              .map(|product: &Product| {
-                 let product_id = product.id;
                  html! {
-                   <ProductCard product={product} on_add_to_cart=self.link.callback(move |_| Msg::AddToCart(product_id))/>
+                   <ProductCard product={product} on_add_to_cart=self.props.on_add_to_cart.clone()/>
                  }
              })
              .collect();

-        let cart_value = self
-            .state
-            .cart_products
-            .iter()
-            .fold(0.0, |acc, cp| acc + (cp.quantity as f64 * cp.product.price));

          if !self.state.get_products_loaded {
              // No changes
          } else if let Some(_) = self.state.get_products_error {
              // No changes
          } else {
              html! {
-               <div>
-                 <div class="navbar">
-                     <div class="navbar_title">{"RustMart"}</div>
-                     <div class="navbar_cart_value">{format!("${:.2}", cart_value)}</div>
-                 </div>
                  <div class="product_card_list">{products}</div>
-               </div>
              }
          }
      }
  }

์ด์ œ ProductDetail ํŽ˜์ด์ง€์—์„œ๋„ ์žฅ๋ฐ”๊ตฌ๋‹ˆ์— ์ถ”๊ฐ€ ํ• ์ˆ˜ ์žˆ์œผ๋ฉฐ ๋ชจ๋“  ํŽ˜์ด์ง€์—์„œ ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ”๋ฅผ ๊ฐ–๊ฒŒ ๋˜์—ˆ๋‹ค.

15 16

Rust๋งŒ์œผ๋กœ ์˜จ์ „ํ•˜๊ฒŒ SPA๋ฅผ ๋งŒ๋“ค์–ด๋‚ด๋Š” ๋ฐ ์„ฑ๊ณตํ–ˆ๋‹ค!

์ด ๋ฐ๋ชจ ๋งํฌ์— ๋“ค์–ด๊ฐ€๋ฉด ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ ์ฝ”๋“œ๋Š” Github ์ €์žฅ์†Œ์—์„œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค. ๋งŒ์•ฝ ์งˆ๋ฌธ์ด๋‚˜ ์ œ์•ˆ์ด ์žˆ๋‹ค๋ฉด, sheshbabu@gmail.com ๋กœ ์—ฐ๋ฝ ๋ฐ”๋ž€๋‹ค.

๋งˆ๋ฌด๋ฆฌ

Yew ์ปค๋ฎค๋‹ˆํ‹ฐ๋Š” html!์ด๋‚˜ Component ๋“ฑ ์—ฌ๋Ÿฌ ์ถ”์ƒํ™”๋ฅผ ์ž˜ ์„ค๊ณ„ํ•ด ๋†” React์— ์ต์ˆ™ํ•œ ๋‚˜์™€ ๊ฐ™์€ ์‚ฌ๋žŒ๋“ค์ด ์ฆ‰์‹œ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ–ˆ๋‹ค. FetchTask์˜ ์ž˜ ๋‹ค๋“ฌ์–ด์ง€์ง€ ์•Š์€ ๋ฉด์ด๋‚˜ ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ(predictable) ์ƒํƒœ ๊ด€๋ฆฌ์˜ ๋ถ€์กฑ, ๋ฌธ์„œ์˜ ๋ถ€์กฑ ๋“ฑ ๋ฌธ์ œ๊ฐ€ ์žˆ์ง€๋งŒ ์ด๋Ÿฌํ•œ ์ด์Šˆ๋“ค์„ ํ•ด๊ฒฐํ•ด ๋‚˜๊ฐ„๋‹ค๋ฉด React๋‚˜ Vue์˜ ์ข‹์€ ๋Œ€์•ˆ์ด ๋  ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ๋‹ค๊ณ  ์ƒ๊ฐํ•œ๋‹ค.

์ฝ์–ด์ค˜์„œ ๊ณ ๋ง™๋‹ค! ์ด๋Ÿฐ ๊ธ€์„ ๋” ๋ฐ›์•„๋ณด๊ณ  ์‹ถ๋‹ค๋ฉด twitter๋ฅผ ํŒ”๋กœ์šฐํ•˜๋ผ!

ํ•œ์ •2020.08.18
Back to list