使用 Rust 搭建单页面应用


原文链接:Single Page Applications using Rust

原文作者:Shesh's blog

在 WebAssembly ( wasm ) 的帮助下,各种并非基于 JavaScript 编写的代码都可以运行在浏览器上。也许你还没有注意到,目前所有的主流浏览器都支持 wasm;与此同时,全球超过90%的用户 所使用的的浏览器支持运行 wasm。

既然 Rust 可以编译得到 wasm,那么我们是否能不用写一行 JavaScript 代码,只使用 Rust 实现一个 SPAs ( 单页面应用 ) 呢 ?简单来说,是完全没有问题的。请继续往下看以了解更多的信息;如果你现在已经无法按捺自己激动的心情,想要立刻看到最终的效果的话,请访问 Demo

我们将会搭建一个名为 " RustMart " 的简单的电子商务网站,该网站由两个页面组成:

  • 主页 - 展示所有的商品列表,支持用户添加商品至购物车
  • 商品详情页 - 点击商品卡片时,展示该商品详细信息

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-1.png)

我使用这个例子进行讲解,是因为它可以对于构建现代 SPA 所需的最低限度的功能集合进行测试:

  • 多个页面间进行导航跳转而不需要重新加载页面

  • 无需重新加载页面就可发起网络请求

  • 不同页面间可以复用 UI 组件

  • 更新 UI 不同层次结构中的组件

创建应用


如果尚未安装 Rust ,请先按照该 链接 的指引进行安装。

安装如下 Rust 工具:

$ cargo install wasm-pack          # 将 Rust 编译为 wasm,并生成 JS 可互操作代码
$ cargo install cargo-make         # 任务 Runner
$ cargo install simple-http-server # 提供 assets 服务的简单服务器

创建新项目:

$ cargo new --lib rustmart && cd rustmart

我们将使用 Yew 库来构建 UI 组件。让我们先把 Yem 和 wasm 依赖注入到 Cargo.html :

[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" 的组件,该组件可以将 <span>Hello World!</span> 渲染进 DOM 中。稍后,我们会学习更多关于 Yew 组件的知识。

在新的终端中,启动服务任务;并通过自己的浏览器访问 http://localhost:3000

$ cargo make serve

![](/Users/robertren/Desktop/img/Wasm & Rust/image-1.png)

至此,我们成功创建了一个完全基于 Rust 实现的 "Hello World"。

在继续深入学习之前,让我们先学习了解一下组件以及其他 SPA 概念。

组件化思想


在前端开发中,使用组件来构建 UI 与单向数据流是一种模式转变。这是我们处理 UI 方式的巨大进步;一旦熟悉了这种处理方式,就很难回到之前的命令式处理 DOM 方式了。

在如 React, Vue, Flutter 等库中,一个 组件 具有如下特性:

  • 可以组合成更大更复杂的组件
  • Props - 可以向子组件传递数据和回调函数
  • State - 操作其自身的状态
  • AppState - 操作全局状态
  • 可以监听生命周期事件,如 "Instantiated","Mounted in DOM" 等
  • 支持执行次要功能,如获取远端数据,操作 localstorage 等

在下述情况之一发生时,组件会进行更新 ( 重新渲染 ) :

  • 父组件重新渲染
  • Props 发生变化
  • State 发生变化
  • AppState 发生变化

因此,我们通过更新数据 ( Props,State,AppState ) 来更新基于这些数据进行展示的 UI,而非在用户进行交互,网络请求等时命令式对 UI 进行更新。这就是所谓的 " UI 是状态的函数 " 。

不同库的具体实现方式存在差异,但是以上内容应该可以给您一个大体的认识。如果你刚刚接触这一块的内容,您可能需要一定时间,才能理解并习惯这种思考方式。

主页实现


首先,让我们完成主页的开发。我们先将主页作为一个整体的组件进行开发,稍后再将其拆分为更小的可复用组件。

让我们创建如下组件:

// 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中:

  // 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:

  • 我们需要存储一个从服务器端所获得的产品列表。
  • 存储用户添加到购物车的产品。

我们先创建一个简单的结构体,用于保存产品详细信息:

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

然后,我们再创建一个新的结构体 State ,该结构体包含 Product,用于保存服务器返回的产品列表:

struct State {
    products: Vec<Product>,
}

这里是主页组件的所有修改:

  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 生命周期函数将会被调用,我们可以在这里进行初始状态的设置。就目前而言,我们也就创建了一个模拟的产品列表数据,并将该数据赋值给 state 中的 products字段作为初始状态。稍后,我们将会通过网络请求获取这个产品列表。

当组件被渲染时,view 生命周期函数将会被调用。我们在这里遍历状态中的 products 字段并生成产品卡片。如果您熟悉 React 的话,就会发现这和 render 方法是一样的,同时 html!JSX 也是相似的。

将一些随机图片保存为 static/products/apple.pngstatic/products/banana.png ,然后您即可获得如下效果:

![](/Users/robertren/Desktop/img/Wasm & Rust/image-2.png)

现在,让我们来实现 ”添加到购物车“ 这一功能:

  • 我们通过一个新的字段 cart_products 存储添加到购物车的产品。
  • 添加逻辑,使得点击 "add to cart" 按钮时,更新 acrt_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 接口,从而我们可以在用户将产品添加到购物车时,将克隆得到的 Product 存储到 CartProduct 中。
  • update - 这个方法是我们用于更新组件状态或执行次要功能(例如网络请求)的地方。该方法通过使用包含组件支持所有动作的 Message 枚举来进行调用。当该方法返回 true 时,组件会重新渲染。在上述代码中,当用户点击 ”Add To Cart“ 按钮时,我们发送 Msg::AddToCart 消息给 update。在 update 内部,若 cart_producct 不存在,则会将产品添加到其中;存在,则会增加其数量。
  • link - 其允许我们去注册回调方法,通过这些回调方法,我们可以触发 update 生命周期函数。

如果您之前使用过 Redux ,您会发现 update 类似于 Reducer ( 从状态更新角度来看 ) 和Action Creator(从副作用角度来看),Message 类似于 Action , Link 类似于 Dispatch

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-2.png)

界面 UI 如下所示,请尝试点击"Add To Cart" 按钮,然后看看 "Cart Value" 的变化:

![](/Users/robertren/Desktop/img/Wasm & Rust/image-3.png)

数据获取


这里,我们将会将产品数据从 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" 模块来暴露出常见的浏览器 API,如 fetch, localstorage 等。我们可以通过使用 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"] }

我们将 ProductCartProduct 提取到 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,
}

好了,到现在,我们已经将两个结构体和其字段公开,并据此派生了 DeserializeSerialize 接口。

我们将使用 API 模块模式,通过创建一个独立的叫做 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()
}

FetchService 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();
  }

最终,让我们来更新我们的主页组件:

+ 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 中 硬编码写的产品列表替换为一个空的数组。同时,我们向 update 发送 Msg::GetProducts 消息,其将会调用 api 模块中的 get_products 方法。返回的 FetchTask 存放于 task 中。

  • 当网络请求成功时,Msg::GetProductsSuccess 消息与对应的产品列表或相应的错误将会被调用。

  • 这两个消息分别设置了状态中的 productsget_products_error 字段。在请求完成后,他们还会将状态中的 get_products_loaded 赋值为真。

  • view 方法中,我们基于组件的状态,使用条件渲染来渲染正在加载视图,错误视图或产品视图。

    ![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-3.png)

拆分为可复用组件


让我们将产品卡片组件提取到其自身的模块中,以便我们在其他页面中进行复用。

// 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
      }
  }

我们可以看到,除了 PropertiesCallbackreform 外,都很直观。

  • Properties - 正如在本文一开始就提到的,”Properties“ 或 ”Props“ 作为一个组件的输入。如果您将组件比作函数的话,Props 正如函数的入参。

  • 对于ProductCard 组件来说,我们将 Product 结构体和 on_add_to_cart 回调传入其中。该组件自身并不维护任何状态,因此当用户点击添加到购物车按钮时,该组件通知其父组件对 cart_products 状态进行更新。这个回调以 Callback<T> 类型呈现,想要从子组件中调用它的话,需要我们在回调中调用 emitreform 方法。

    ![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-4.png)

样式


由于我们没有添加任何的样式,我们的页面展示效果如下所示。

在 Yew 中,我们可以使用 class 属性行内样式。让我们加一些样式让我们的页面好看起来。

首先,让我们新建一个 CSS 文件 static/styles.css ,并将其引入至 static/index.html。这样我们就可以在我们的组件中使用 class 了。

  // 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>
  }
  // src/components/product_card.rs

  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>
  }

在添加样式,并且新增了一些产品数据后,我们现在的页面展示效果如下:

![](/Users/robertren/Desktop/img/Wasm & Rust/image-4.png)

本文并不对 CSS 的更改展开描述,如有需要,请参考 GitHub 仓库

路由


在服务端渲染的页面中(如 Jinja, ERB,JSP 等),用户所见到的每个页面,都是不同的模板文件。例如,当用户访问 "/login" 时,服务端渲染 "login.html" ;用户访问 "/settings" 时,服务端渲染 "settings.html" 页面。不同的页面使用不同的地址,也有助于我们进行收藏和分享。

因为单页面应用仅有一个页面(这也是为什么称为"单页面"),我们需要能够复现上述的行为。通过使用 Router ,可以实现这一目标。路由可以将不同的路径(包含 query 参数,片段等)映射匹配到不同的页面组件,同时还支持无需重新加载就可以在不同页面间跳转。

就我们的应用而言,我们使用以下映射:

/            => 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,
}

目前,我们的应用只有一个路由。后续我们会新增更多的路由。

接下来,让我们创建一个名为 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();
  }

到现在,我们的组件的路由结构如下所示;

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-5.png)

产品详情页


现在,既然我们已经创建好了路由,让我们使用它实现页面间的跳转。我们的应用作为一个单页面应用,需要注意避免在页面跳转时发生页面重新加载。

首先,我们在 /product/:id 下为产品详情页新增一个路由。当用户点击产品卡片时,就会跳转到产品详情页,同时会将 id 作为参数一起传递给产品详情页。

  // 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} ,导致渲染的页面是产品详情页而非主页。

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/>
          }
      }
  }

现在,让我们修改产品卡片的有关逻辑,使用户点击产品名称,图片,价格等内容时,跳转到新的页面:

  // 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 中我们使用了 classes 代替了 class

接下来,让我们创建一些名为 static/products/1.jsonstatic/products/2.json 的 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()
+ }

最终,我们的产品详情组件代码如下:

// 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>
            }
        }
    }
}

和主页组件一样,我们将这个文件添加到模块树上:

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

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

产品详情页展示效果如下:

![](/Users/robertren/Desktop/img/Wasm & Rust/image-5.png)

现在,我们就可以在不同页面间进行来回跳转而不需要重新加载页面了!

状态管理


有些读者可能会注意到,我们在产品详情页点击 ” 添加到购物车 “ 按钮时,购物车并没有更新。究其原因,是因为用于存储购物车 cart_products 产品列表数据的状态目前是在主页组件中:

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-6.png)

通过以下任一方法,我们可以实现两个组件之间共享状态:

  • 将这个状态提升至这两个组件的公共祖先中
  • 将这个状态移至全局 app 状态中

我们在路由实现部分,将根组件替换为了 App ,此时 App 就成了 ProductDetail 产品详情页和 Home 主页的公共祖先。我们可以将 cart_products 状态移入其中,并将这个状态作为 props 传递给 ProductDetail 产品详情页和 Home 主页。

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-7.png)

这种解决方法适用于层次较浅的组件结构中,但是当组件层次结构较深(在更大型的单页面应用中很常见)时,我们需要通过多层组件(这些组件可能并不需要使用这个参数)一层层的将 state 传递给有使用需要的组件。这也被叫做 "Prop Drilling"。

就我们这个简单的项目来说,你可以看到 cart_productsApp 传递到 AddToCart 时,会经过不使用 cart_productsProductDetailHome。想象一下,同样的场景,更为复杂的组件结构,是不是有些不寒而栗呢?

这就是全局状态所解决的问题。其如下所示:

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-8.png)

我们可以注意到,在全局状态和需要使用这个状态的组件之间,他们是直接链接的。

不幸的是,就目前而言,Yew 对此还没有一个足够优秀的解决方案。推荐的解决方法是使用 Agents 通过 pubsub (发布订阅) 来对状态变化进行广播。这是我所不愿意做的,因为它会很快变得非常混乱。我希望在将来,会有类似 React 的 Context ,Redux 或 Mobx 的解决方案出现。

接下来,让我们通过提升状态来解决我们的问题。

状态提升


在这一部分,我们将会通过将 cart_products 状态提升至 App 并将 NavbarAtcButton 抽离为独立的组件,来对我们之前的代码进行重构:

![](/Users/robertren/Desktop/img/Wasm & Rust/rust-wasm-yew-single-page-application-9.png)

// 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 发生变化时,我们就需要更新 当前组件中的 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;

ProductCard 产品卡片和 ProductDetail 产品详情组件中使用新的 ActButton 组件:

  // 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>
              }
          }
      }
  }

现在,我们终于可以在产品详情页,向购物车中添加产品了;同时,也能在所有页面看到顶部导航栏了。

![](/Users/robertren/Desktop/img/Wasm & Rust/image-6.png)

​ 至此,我们就已经通过 Rust 成功搭建了一个单页面应用!

​ 我已经将 Demo 放到 这里,代码则存放于这个 Github 仓库中。如果您有疑问或者建议,请通过sheshbabu@gmail.com与我联系。

总结


Yew 社区在设计如 html!Component 等抽象方案上做的很棒的工作了,以至于像我这样熟悉 React 的人可以很快的开始开发。诚然,它也有一些缺点诸如 FetchTasks,缺少可预测的状态管理,文档较少等;但是这些缺点一旦改进,它有潜力成为 React,Vue 等框架的很好的替代品。

非常感谢您的阅读!欢迎关注我的 Twitter 以阅读更多类似的文章 :)

个人的一点小想法


WebAssembly 最近几年的火热,在于其可以使以各种语言编写的代码,都可以以接近原生的速度在浏览器中运行,对于前后端开发者都带来了很大的想象空间。就如本文,Rust 原本是后端语言,但是通过种种工具库,我们可以将代码编译为 WebAssembly,从而从 0 开始实现一个 SPA 单页面应用。从前端角度来说,在性能优化,web软件等等方面,都可以期待一下。

个人的一点小想法,博君一粲,欢迎探讨。

Q.E.D.


Take it easy