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에 익숙하다면, viewrender와 동일하며 html!매크로는 JSX와 비슷하다.

임의의 이미지를 static/products/apple.pngstatic/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)시켜줘 유저가 장바구니에 추가 할 때마다 복제된 ProductCartProduct에 저장하도록 할 수 있게 했다.
  • update - 이 메서드는 컴포넌트의 State업데이트 로직이 존재하거나 사이드 이펙트(네트워크 요청 같은)작업이 위치하는 곳이다. 컴포넌트가 지원하는 모든 액션을 포함하는 Message 열거형을 사용해 발생시킨다. 이 메서드에서 true를 반환할 때 컴포넌트는 리렌더링 한다. 위 코드에서 "장바구니에 추가" 버튼을 누르면 Msg::AddToCart 메세지를 update로 보낸다. update에서는 제품이 cart_product에 없으면 추가하며, 존재할 경우 수량을 증가시킨다.
  • link - update 생명주기 함수를 발생시킬 수 있는 콜백 함수를 등록시킨다.

Redux를 이전에 사용해 본 적이 있다면, update리듀서(state 업데이트)와 액션 생성자(사이드 이펙트)에 유사하며, Message액션, linkDispatch와 유사하다.

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를 사용해 네트워크 요청을 생성한다. 사용을 위해서는 anyhowserde가 필요한데, 한번 설치해보자.

  [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"] }

ProductCartProductsrc/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으로 만들어 준 뒤, DeserializeSerialize 트레잇을 파생시켜줬다.

또한, 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메서드를 호출시켰다. 반환된 FetchTasktask에 저장된다.
  • 요청이 성공하면 Msg::GetProductsSuccess메세지가 상품 리스트와 함께 호출되며, 실패할 경우 에러와 함께 Msg::getProductsError가 호출된다.
  • 이 두 메세지는 productsget_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>
          }
      }
  }

Anchorclass대신 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 컴포넌트는 ProductDetailHome의 공통 조상 컴포넌트이다. 따라서, cart_products 상태를 옮기면 이 상태를 prop으로 ProductDetailHome에 전달해 줄 수 있게 된다.

12

이 방법은 컴포넌트들이 얕은 계층을 가질 경우 잘 동작하지만, 계층 구조가 깊은 경우(더 큰 규모의 SPA의 경우) 원하는 컴포넌트에 도달하기 위해서는 여러 계층의 컴포넌트(이 prop을 사용하지 않을 수도 있는)를 통해 이 상태를 전달해야 한다. 이것을 "Prop Drilling"이라 한다.

현재 구성에서도 cart_products가 상태에 사용되지 않더라도 App에서 AppToCart 컴포넌트로 전달 되기 위해 ProductDetailHome 컴포넌트를 거치는 것을 볼 수 있다. 같은 시나리오가 깊은 계층을 갖는 컴포넌트에서 일어난다고 생각해보라.

이런 문제를 전역 상태를 통해 해결할 수 있다. 여기 어떻게 동작하는지 함께 살펴보자.

13

각 컴포넌트와 전역 상태가 어떻게 직접 연결이 되어 있는지를 주의 깊게 보자.

불행하게도, Yew는 좋은 해결책이 아직 존재하지 않는 것처럼 보인다. 권장되는 해결책은 pubsub을 통해 상태 변경을 브로드 캐스팅 하기 위해 Agents를 사용하는 것이다. 이것은 빠르게 앱을 더럽힐 수 있어 사용을 하지 않고 있다. 미래에는 React의 Context나 Redux, Mobx 같은 것을 볼 수 있길 기대한다.

그럼 상태를 호이스팅 해서 이 문제를 해결해보자.

상태 호이스팅

cart_products 상태를 App으로 이동시키고 NavBarAtcButton를 별도 컴포넌트로 구성하는 방식으로 리팩토링 해보자

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 컴포넌트를 ProductCardProductDetail에서 사용하자.

  // 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_productsHome에서 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