域名 AXUM.RS 将于 2025 年 10 月到期。我们无意再对其进行续费,如果你有意接续这个域名,请与我们取得联系。
  • AXUM.RS 现仅需人民币 3000 元(大写:叁仟元整。接受适度议价
  • 按照行业规则,AXUM.RS 到期后,大概率会进入长时间的赎回期,该期间内,如果你想拥有该域名,将要付出高额的费用
  • 我们已启用 AXUM.EU.ORG 域名,并将持续运营
  • 仅接受微信或支付宝交易
如果你对 AXUM.RS 有兴趣,请和我们进行联系:

博客

本章我们将实现博客,你将学习到:dioxus 路由、获取远程数据、条件渲染、列表渲染等知识。

本案例将使用 JSON PLACHOLDER 提供的 API,使用 dioxus 实现一个简单的博客。

创建并初始化项目

创建项目:

dx new blog

然后,按照上一章的讲解,进行文件清理,并集成 Tailwind CSS

// src/main.rs

use dioxus::prelude::*;

const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");

fn main() {
    dioxus::launch(App);
}

#[component]
fn App() -> Element {
    rsx! {
        document::Link { rel: "icon", href: FAVICON }
        document::Link { rel: "stylesheet", href: MAIN_CSS }
        div { class:"text-red-600 text-xl",  "Hello, 世界" }
    }
}

路由

为了启用 dioxus 的路由功能,我们需要在 Cargo.toml 的 dioxus 依赖中,加上 router features:

cargo add dioxus --features router

也可以手动编辑 Cargo.toml

[dependencies]
dioxus = { version = "0.6.0", features = ["router"] }

定义路由枚举

dioxus 的路由需要由一个带有 Routable 宏的枚举来定义;每个具体的路由使用 #[route(path)] 来声明。

#[derive(Routable, Clone)]
enum Route {
    #[route("/")]
    Home {},
    #[route("/about")]
    About {},
}

我们定义了2个路由:

  • /:首页,对应 Home 组件
  • /about:关于,对应 About 组件

这两个组件很简单:

#[component]
fn Home() -> Element {
    rsx! {
        div { class: "text-red-600 text-xl", "首页" }
    }
}

#[component]
fn About() -> Element {
    rsx! {
        div { class: "text-blue-600 text-xl", "关于" }
    }
}

开启路由

定义好了路由,我们需要在 App 组件中开启路由:

#[component]
fn App() -> Element {
    rsx! {
        document::Link { rel: "icon", href: FAVICON }
        document::Link { rel: "stylesheet", href: MAIN_CSS }

        Router::<Route> {}
    }
}

Router::<Route>{} 开启路由。Router 是一个泛型函数,我们把定义的 Route 枚举作为泛型参数进行调用。

动态路由

dioxus 还支持动态路由:

#[derive(Routable, Clone)]
enum Route {
    ...
    #[route("/detail/:id")]
    Detail { id: i32 },
}

#[component]
fn Detail(id: i32) -> Element {
    rsx! {
        div { class: "text-cyan-600 text-xl", "#{id} 的详情" }
    }
}

嵌套路由

dioxus 也支持嵌套路由,我们将在第3个案例中进行讲解。

路由链接

dioxus 提供了 Link 组件来实现路由链接:

#[component]
fn Detail(id: i32) -> Element {
    rsx! {
        div { class: "text-cyan-600 text-xl", "#{id} 的详情" }

        ul {
            li {
                Link { to: Route::Home {}, "首页" }
            }
        }
    }
}

获取远程数据

cargo add reqwest --features json
cargo add serde --features derive

下面,我们尝试获取 https://jsonplaceholder.typicode.com/posts/1 的数据:

#[derive(serde::Deserialize, Debug, Clone)]
pub struct Post {
    #[serde(rename = "userId")]
    pub user_id: u32,
    pub id: u32,
    pub title: String,
    pub body: String,
}

#[component]
fn BlogPost() -> Element {
    let mut post = use_signal(|| None::<Post>);

    let fetch_post = move |_| async move {
        let resp = reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
            .await
            .unwrap()
            .json::<Post>()
            .await
            .unwrap();
        post.set(Some(resp));
    };

    if let Some(post) = post.cloned() {
        rsx! {
            div { "ID: {post.id}" }
            div { "标题:{post.title}" }
        }
    } else {
        rsx! {
            button {
                class: "px-3 py-1 bg-gray-600 text-white",
                onclick: fetch_post,
                "获取数据"
            }
        }
    }
}

以上方式美中不足的是,为了将状态渲染到页面中,对该状态进行了 Clone 操作,如果状态维护的是一个很大的结构体,将会浪费资源。我们可以使用智能指针改进:

#[derive(serde::Deserialize, Debug)]
pub struct Post {
    #[serde(rename = "userId")]
    pub user_id: u32,
    pub id: u32,
    pub title: String,
    pub body: String,
}

#[component]
fn BlogPost() -> Element {
    let mut post = use_signal(|| None::<Rc<Post>>);

    let fetch_post = move |_| async move {
        let resp = reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
            .await
            .unwrap()
            .json::<Post>()
            .await
            .unwrap();
        post.set(Some(Rc::new(resp)));
    };

    if let Some(post) = post.cloned() {
        rsx! {
            div { "ID: {post.id}" }
            div { "标题:{post.title}" }
        }
    } else {
        rsx! {
            button {
                class: "px-3 py-1 bg-gray-600 text-white",
                onclick: fetch_post,
                "获取数据"
            }
        }
    }
}
  • 结构体 Post 不实现 Clone
  • 状态不是简单的 Option<Post>,而是 Rc<Option<Post>>
  • 由于使用了 Rc 智能指针,即使在渲染页面时调用了 Clone,也只是简单的增加了 Rc 的引用次数,而不是克隆整个结构体

使用 use_resource()

在获取远程资源的场景下,dioxus 提供比 use_signal 更优的 hook:use_resource()。我们用 use_resource()改进一下:

我们简单了解一下 use_resource()

  • 它会自动执行作为其参数的那个闭包
  • 它的结果中包含了Option
  • 使用 restart() 方法,可以重新执行那个闭包

上面的代码又用到了 Clone,其实 use_resource() 提供了一种方式来清除这个 Clone 操作:

#[component]
fn BlogPost() -> Element {
    let mut post = use_resource(|| async move {
        let resp = reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
            .await
            .unwrap()
            .json::<Post>()
            .await
            .unwrap();
        resp
    });

    match &*post.read_unchecked() {
        Some(p) => rsx! {
            div { "ID: {p.id}" }
            div { "标题:{p.title}" }
            button {
                class: "px-3 py-1 bg-gray-600 text-white",
                onclick: move |_| post.restart(),
                "获取数据"
            }
        },

        None => rsx! {
            div { "Loading..." }
        },
    }
}

use_resource()其实是一个 Future,而 read_unchecked() 更为优雅的用法是根据这个 Future 的结果进行处理:

#[component]
fn BlogPost() -> Element {
    let mut post = use_resource(|| async move {
        reqwest::get("https://jsonplaceholder.typicode.com/posts/1")
            .await
            .unwrap()
            .json::<Post>()
            .await
    });

    match &*post.read_unchecked() {
        Some(Ok(p)) => rsx! {
            div { "ID: {p.id}" }
            div { "标题:{p.title}" }
            button {
                class: "px-3 py-1 bg-gray-600 text-white",
                onclick: move |_| post.restart(),
                "获取数据"
            }
        },
        Some(Err(e)) => rsx! {
            div { "Error: {e}" }
        },
        None => rsx! {
            div { "Loading..." }
        },
    }
}

有了这些基础知识,我们可以开始编写博客程序了。

模型定义

本案例主要有2个模型,分别是博文 Post 和用户 User

博文模型:

#[derive(Serialize, Deserialize, Debug)]
pub struct Post {
    #[serde(rename = "userId")]
    pub user_id: u32,
    pub id: u32,
    pub title: String,
    pub body: String,
}

用户模型:

#[derive(Serialize, Deserialize, Debug)]
pub struct Geo {
    pub lat: String,
    pub lng: String,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Address {
    pub street: String,
    pub suite: String,
    pub city: String,
    pub zipcode: String,
    pub geo: Geo,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Company {
    pub name: String,
    
    #[serde(rename = "catchPhrase")]
    pub catch_phrase: String,
    
    pub bs: String,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct User {
    pub id: u32,
    pub name: String,
    pub username: String,
    pub email: String,
    pub address: Address,
    pub phone: String,
    pub website: String,
    pub company: Company,
}

博客列表

#[component]
pub fn List() -> Element {
    let posts = use_resource(|| async move {
        reqwest::get("https://jsonplaceholder.typicode.com/posts")
            .await
            .unwrap()
            .json::<Vec<model::Post>>()
            .await
    });
    match &*posts.read_unchecked() {
        Some(Ok(ps)) => rsx! {
            ul { class: "list-disc list-inside space-y-2",
                for post in ps {
                    li {
                        a {
                            class: "hover:text-red-600 capitalize",
                            href: format!("/post/{}", post.id),
                            "{post.title}"
                        }
                    }
                }
            }
        },
        Some(Err(e)) => rsx! {
            div { "获取博文数据出错:{e}" }
        },
        None => rsx! {
            Loading {}
        },
    }
}
  • 通过 use_resource 获取远程 API 数据
  • 匹配 read_unchecked() 的结果,根据不同结果渲染不同UI
  • dioxus 支持两种渲染列表的方式
    • 使用 for ... in
    • 使用 map()

博客详情

#[component]
pub fn Detail(id: u32) -> Element {
    let post = use_resource(move || async move {
        reqwest::get(format!("https://jsonplaceholder.typicode.com/posts/{}", id))
            .await
            .unwrap()
            .json::<model::Post>()
            .await
    });

    match &*post.read_unchecked() {
        Some(Ok(p)) => rsx! {
            div { class: "space-y-4",
                h1 { class: "text-xl font-semibold capitalize", "{p.title}" }
                p { class: "text-lg", "{p.body}" }
            }
        },
        Some(Err(e)) => rsx! {
            div { "获取博文数据出错:{e}" }
        },
        None => rsx! {
            Loading {}
        },
    }
}

用户

用户模型我们已经定义好了,用户相关的页面,作为作业留给读者完成。

用户详情:https://jsonplaceholder.typicode.com/users/1。最后的 1 为用户ID。

路由

use crate::component::post::{Detail as PostDetail, List as PostList};
use dioxus::prelude::*;

#[derive(Routable, Clone)]
pub enum Route {
    #[route("/")]
    PostList {},

    #[route("/post/:id")]
    PostDetail { id: u32 },
}

主组件

#[component]
fn App() -> Element {
    rsx! {
        document::Link { rel: "icon", href: FAVICON }
        document::Link { rel: "stylesheet", href: MAIN_CSS }
        div { class: "bg-gray-100 min-h-screen",
            header { class: "container mx-auto flex justify-start items-end gap-x-8 py-6",
                a { class: "flex items-center gap-x-2", href: "/",
                    img { src: LOGO_IMG, class: "w-8 object-convert" }
                    h2 { class: "text-2xl font-bold", "博客" }
                }
                nav { class: "",
                    ul {
                        li {
                            a {
                                class: "text-xl hover:text-red-800",
                                href: "/",
                                "博文"
                            }
                        }
                    }
                }
            }
            main { class: "container mx-auto p-6 bg-white", Router::<Route> {} }
        }
    }
}
要查看完整内容,请先登录