- 支持试读
开启 dioxus 之旅
本章将讨论 dioxus 的安装、项目创建、运行和构建。 - 支持试读
dioxus 组件与 rsx
本章将开始第一个案例:计数器的编写。首先,我们需要学习 dioxus 组件的编写和 rsx 语法。 - 支持试读
计数器
本章我们将开始编写计数器。你将学习到:事件处理、状态管理、组件通讯、集成 Tailwind CSS 等知识。 - 支持试读
博客
本章我们将实现博客,你将学习到:dioxus 路由、获取远程数据、条件渲染、列表渲染等知识。 全局状态
本章我们开始最后一个案例:阅后即焚的开发。在真正进行开发之前,我们还需要掌握一些基础知识,本章我们将学习 dioxus 的全局状态。布局与嵌套路由
本章我们将讨论 dioxus 的布局与嵌套路由。表单处理
本章我们将学习 dioxus 的表单处理。和 React 类似,dioxus 也分为受控表单和非受控表单。阅后即焚前台UI
本章我们开始编写阅后即焚的前台UI。- 支持试读
阅后即焚API
本章我们使用 AXUM 开发阅后即焚的前台 API。 整合阅后即焚前端和API
本章将对阅后即焚前端和API进行整合。- 支持试读
编译和部署
本章我们将讨论 dioxus web 的编译和部署到 NGINX。 【加餐】dioxus 服务端渲染
本章使用前文的『博客』中的用户数据为案例,来讲解 dioxus 服务端渲染。【加餐】dioxus 和 axum 实现文件上传
本章将讨论使用 dioxus、reqwest 和 axum 实现文件上传。
博客
- 114
 - 2025-04-30 21:19:14
 
本章我们将实现博客,你将学习到: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 枚举作为泛型参数进行调用。
动态路由
#[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 {}, "首页" }
            }
        }
    }
}
获取远程数据
dioxus 也是 rust 项目,所以也是使用诸如 reqwest 等库来获取远程数据。所以,我们首先要添加依赖:
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,
                "获取数据"
            }
        }
    }
}
- 首先定义了一个 
Post结构体,和 API 返回的 JSON 进行映射 - 在 
BlogPost组件中- 使用 
use_signal维护一个状态:Option<Post>。初始状态,它的值为None,一旦从远程 API 成功获取到数据,将其设置为具体的值 fetch_post是一个异步的闭包,它用于从远程 API 获取数据- 一旦成功获取到数据,调用状态的 
set方法,将其更新到状态中 
- 一旦成功获取到数据,调用状态的 
 - 根据状态值返回不同的页面
- 如果状态有具体的值,将其渲染到页面
- 注意,这里需要使用状态的 
cloned()方法,比如本例中的post.cloned(),以便避免出现生命周期问题 
 - 注意,这里需要使用状态的 
 - 如果状态值为 
None,渲染一个按钮。该按钮会调用fetch_post函数,从远程 API 获取数据 
 - 如果状态有具体的值,将其渲染到页面
 
 - 使用 
 
以上方式美中不足的是,为了将状态渲染到页面中,对该状态进行了 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()
#[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();
        Rc::new(resp)
    });
    if let Some(p) = post.cloned() {
        rsx! {
            div { "ID: {p.id}" }
            div { "标题:{p.title}" }
            button {
                class: "px-3 py-1 bg-gray-600 text-white",
                onclick: move |_| post.restart(),
                "获取数据"
            }
        }
    } else {
        rsx! {
            div { "Loading..." }
        }
    }
}
- 它会自动执行作为其参数的那个闭包
 - 它的结果中包含了
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() 
 - 使用 
 
博客详情
- 通过 
use_resource获取远程 API 数据 - 匹配 
read_unchecked()的结果,根据不同结果渲染不同UI 
用户
用户模型我们已经定义好了,用户相关的页面,作为作业留给读者完成。
用户列表:https://jsonplaceholder.typicode.com/users
用户详情: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> {} }
        }
    }
}
