使用 Rust 搭建单页面应用
原文链接:Single Page Applications using Rust
原文作者:Shesh's blog
在 WebAssembly ( wasm ) 的帮助下,各种并非基于 JavaScript 编写的代码都可以运行在浏览器上。也许你还没有注意到,目前所有的主流浏览器都支持 wasm;与此同时,全球超过90%的用户 所使用的的浏览器支持运行 wasm。
既然 Rust 可以编译得到 wasm,那么我们是否能不用写一行 JavaScript 代码,只使用 Rust 实现一个 SPAs ( 单页面应用 ) 呢 ?简单来说,是完全没有问题的。请继续往下看以了解更多的信息;如果你现在已经无法按捺自己激动的心情,想要立刻看到最终的效果的话,请访问 Demo。
我们将会搭建一个名为 " RustMart " 的简单的电子商务网站,该网站由两个页面组成:
- 主页 - 展示所有的商品列表,支持用户添加商品至购物车
- 商品详情页 - 点击商品卡片时,展示该商品详细信息

我使用这个例子进行讲解,是因为它可以对于构建现代 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

至此,我们成功创建了一个完全基于 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.png
和 static/products/banana.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
。

界面 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" 模块来暴露出常见的浏览器 API,如 fetch, localstorage 等。我们可以通过使用 FetchService
来创建网络请求。使用它需要安装 anyhow
和 serde
库:
[package]
name = "rustmart"
version = "0.1.0"
authors = ["sheshbabu <sheshbabu@gmail.com>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
yew = "0.17"
wasm-bindgen = "0.2"
+ anyhow = "1.0.32"
+ serde = { version = "1.0", features = ["derive"] }
我们将 Product
和 CartProduct
提取到 src/types.rs
中,以便我们跨文件分享使用它们:
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Product {
pub id: i32,
pub name: String,
pub description: String,
pub image: String,
pub price: f64,
}
#[derive(Clone, Debug)]
pub struct CartProduct {
pub product: Product,
pub quantity: i32,
}
好了,到现在,我们已经将两个结构体和其字段公开,并据此派生了 Deserialize
和 Serialize
接口。
我们将使用 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
消息与对应的产品列表或相应的错误将会被调用。 -
这两个消息分别设置了状态中的
products
和get_products_error
字段。在请求完成后,他们还会将状态中的get_products_loaded
赋值为真。 -
在
view
方法中,我们基于组件的状态,使用条件渲染来渲染正在加载视图,错误视图或产品视图。
拆分为可复用组件
让我们将产品卡片组件提取到其自身的模块中,以便我们在其他页面中进行复用。
// 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
方法。
样式
由于我们没有添加任何的样式,我们的页面展示效果如下所示。
在 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>
}
在添加样式,并且新增了一些产品数据后,我们现在的页面展示效果如下:

本文并不对 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();
}
到现在,我们的组件的路由结构如下所示;

产品详情页
现在,既然我们已经创建好了路由,让我们使用它实现页面间的跳转。我们的应用作为一个单页面应用,需要注意避免在页面跳转时发生页面重新加载。
首先,我们在 /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.json
,static/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;
产品详情页展示效果如下:

现在,我们就可以在不同页面间进行来回跳转而不需要重新加载页面了!
状态管理
有些读者可能会注意到,我们在产品详情页点击 ” 添加到购物车 “ 按钮时,购物车并没有更新。究其原因,是因为用于存储购物车 cart_products
产品列表数据的状态目前是在主页组件中:

通过以下任一方法,我们可以实现两个组件之间共享状态:
- 将这个状态提升至这两个组件的公共祖先中
- 将这个状态移至全局 app 状态中
我们在路由实现部分,将根组件替换为了 App
,此时 App
就成了 ProductDetail
产品详情页和 Home
主页的公共祖先。我们可以将 cart_products
状态移入其中,并将这个状态作为 props
传递给 ProductDetail
产品详情页和 Home
主页。

这种解决方法适用于层次较浅的组件结构中,但是当组件层次结构较深(在更大型的单页面应用中很常见)时,我们需要通过多层组件(这些组件可能并不需要使用这个参数)一层层的将 state 传递给有使用需要的组件。这也被叫做 "Prop Drilling"。
就我们这个简单的项目来说,你可以看到 cart_products
从 App
传递到 AddToCart
时,会经过不使用 cart_products
的 ProductDetail
和 Home
。想象一下,同样的场景,更为复杂的组件结构,是不是有些不寒而栗呢?
这就是全局状态所解决的问题。其如下所示:

我们可以注意到,在全局状态和需要使用这个状态的组件之间,他们是直接链接的。
不幸的是,就目前而言,Yew 对此还没有一个足够优秀的解决方案。推荐的解决方法是使用 Agents
通过 pubsub (发布订阅) 来对状态变化进行广播。这是我所不愿意做的,因为它会很快变得非常混乱。我希望在将来,会有类似 React 的 Context ,Redux 或 Mobx 的解决方案出现。
接下来,让我们通过提升状态来解决我们的问题。
状态提升
在这一部分,我们将会通过将 cart_products
状态提升至 App
并将 Navbar
和 AtcButton
抽离为独立的组件,来对我们之前的代码进行重构:

// 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>
}
}
}
}
现在,我们终于可以在产品详情页,向购物车中添加产品了;同时,也能在所有页面看到顶部导航栏了。

至此,我们就已经通过 Rust 成功搭建了一个单页面应用!
我已经将 Demo 放到 这里,代码则存放于这个 Github 仓库中。如果您有疑问或者建议,请通过sheshbabu@gmail.com与我联系。
总结
Yew 社区在设计如 html!
,Component
等抽象方案上做的很棒的工作了,以至于像我这样熟悉 React 的人可以很快的开始开发。诚然,它也有一些缺点诸如 FetchTasks,缺少可预测的状态管理,文档较少等;但是这些缺点一旦改进,它有潜力成为 React,Vue 等框架的很好的替代品。
非常感谢您的阅读!欢迎关注我的 Twitter 以阅读更多类似的文章 :)
个人的一点小想法
WebAssembly 最近几年的火热,在于其可以使以各种语言编写的代码,都可以以接近原生的速度在浏览器中运行,对于前后端开发者都带来了很大的想象空间。就如本文,Rust 原本是后端语言,但是通过种种工具库,我们可以将代码编译为 WebAssembly,从而从 0 开始实现一个 SPA 单页面应用。从前端角度来说,在性能优化,web软件等等方面,都可以期待一下。
个人的一点小想法,博君一粲,欢迎探讨。
Q.E.D.