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

开发代办事项App

本章我们将代办事项App的开发,掌握 dioxus fullstack 的基本知识。

创建项目

dx new todo-app

参照以下过程进行选择:

✔ 🤷   Which sub-template should be expanded? · Bare-Bones
✔ 🤷   Do you want to use Dioxus Fullstack? · true
✔ 🤷   Do you want to use Dioxus Router? · false
✔ 🤷   Do you want to use Tailwind CSS? · false
✔ 🤷   Which platform do you want DX to serve by default? · Web

dioxus fullstack 初印象

我们来看一下 dx 工具给我们生成的代码。我们聚焦到 Echo()echo_server(),它们实现了一个简单的回显功能,即用户通过前端的表单输入任何内容,后端都原样返回:

/// Echo component that demonstrates fullstack server functions.
#[component]
fn Echo() -> Element {
    let mut response = use_signal(|| String::new());

    rsx! {
        div {
            id: "echo",
            h4 { "ServerFn Echo" }
            input {
                placeholder: "Type here to echo...",
                oninput:  move |event| async move {
                    let data = echo_server(event.value()).await.unwrap();
                    response.set(data);
                },
            }

            if !response().is_empty() {
                p {
                    "Server echoed: "
                    i { "{response}" }
                }
            }
        }
    }
}

/// Echo the user input on the server.
#[server(EchoServer)]
async fn echo_server(input: String) -> Result<String, ServerFnError> {
    Ok(input)
}
  • Echo 组件。Echo() 是一个前端组件
    • 使用 use_signal() 定义了一个 response 状态,用于维护从服务端返回的响应数据
    • inputoninput 事件:
      • let data = echo_server(event.value()).await.unwrap(),调用后端函数 echo_server()
      • 将后端函数的返回结果,设置到 response 状态中
    • 如果 response 状态不为空,则显示该状态的数据
  • echo_server API。它是一个后端函数,直接将传入的参数原样返回给客户端

dioxus fullstack 的实现原理

感觉很神奇不是吗?它是怎么做到在前端像调用普通函数那样,直接调用后端函数的?

  • dioxus 前端代码最终编译为 WASM
  • dioxus fullstack 是通过 RPC(远程过程调用)来实现前端代码调用后端函数的

编写UI

#[component]
pub fn Header() -> Element {
    rsx! {
        div { class: "flex justify-between items-center gap-x-2",
            div { class: "grow",
                input {
                    placeholder: "输入代办事项",
                    class: "block w-full px-3 py-1.5 outline-none ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500",
                }
            }
            div { class: "shrink-0",
                button { class: "px-3 py-1.5 text-white bg-blue-500  hover:bg-blue-600",
                    "添加"
                }
            }

        }
    }
}

#[component]
pub fn Body() -> Element {
    rsx! {
        TodoList {}
    }
}

#[component]
fn TodoList() -> Element {
    rsx! {
        ul { class: "space-y-2",
            for i in 0..10 {
                TodoItem { key: "{i}" }
            }
        }
    }
}

#[component]
fn TodoItem() -> Element {
    rsx! {
        li { class: "flex items-center justify-between px-2 py-1 group hover:bg-gray-300 rounded",
            div { "TodoItem" }
            div { class: "flex items-center justify-end gap-x-1 group-hover:opacity-100 group-hover:visible invisible opacity-0 transition-all",
                button { class: "px-1 py-0.5 text-sm bg-green-600 text-white hover:bg-green-700",
                    "完成"
                }
                button { class: "px-1 py-0.5 text-sm bg-red-600 text-white hover:bg-red-700",
                    "删除"
                }
            }
        }
    }
}

没啥好讲的,最基本的 rsx。我们来看看后端的编写。

注意,我们手动添加了 tailwind css,具体操作请参见《计数器》章节

依赖管理

开始后端编写之前,我们要明确一点:很多 crate 在 dioxus 的前端中是不能使用的,只有 dioxus 后端才能使用,比如 tokiosqlx 等。这就要求我们需要对前后端依赖进行管理。

情景一:前后端共用的依赖

此情景中,就是添加普通依赖,比如 serde

可以使用以下命令添加:

cargo add serde --features derive

或者在 Cargo.toml 手动添加:

[dependencies]
serde = { version = "1.0.219", features = ["derive"] }

情景二:后端使用的依赖

此情景中,添加依赖时,需要加上 optional,让其成为可选依赖,比如:

或者在 Cargo.toml 手动添加:

[dependencies]
xid = { version = "1.1.1", optional = true }

然后,需要添加一个 server feature,并在该feature中,启用要使用的依赖:

[features]
server = ["dioxus/server", "dep:xid"]

情景三:前端使用的依赖

和情景二类似,不同的是,要做 web feature 中启用:

[features]
web = ["dioxus/web", "dep:reqwest"]

对于本案例而言,前后端都需要 serde,服务端需要用到 tokio 和 xid,所以最终的 Cargo.toml 如下:

[dependencies]
dioxus = { version = "0.6.0", features = ["fullstack"] }
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.45.0", features = ["full"], optional = true }
xid = { version = "1.1.1", optional = true }

[features]
default = ["web"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
server = ["dioxus/server", "dep:tokio", "dep:xid"]

数据存储

为了不分散注意力,本案例不涉及数据库,而是使用内存进行存储。我们来看下数据存储的实现:

pub struct Store(Arc<Mutex<Vec<models::Todo>>>);

impl Store {
    pub fn new() -> Self {
        Self(Arc::new(Mutex::new(Vec::new())))
    }
    pub fn add(&self, todo: models::Todo) {
        self.0.lock().unwrap().push(todo);
    }

    pub fn list(&self) -> Vec<models::Todo> {
        self.0.lock().unwrap().clone()
    }

    pub fn find(&self, id: &str) -> Option<models::Todo> {
        self.0
            .lock()
            .unwrap()
            .iter()
            .find(|todo| todo.id == id)
            .cloned()
    }

    pub fn del(&self, id: &str) {
        self.0.lock().unwrap().retain(|todo| todo.id != id);
    }

    pub fn clear(&self) {
        self.0.lock().unwrap().clear();
    }

    pub fn len(&self) -> usize {
        self.0.lock().unwrap().len()
    }

    pub fn done(&self, id: &str) {
        self.0
            .lock()
            .unwrap()
            .iter_mut()
            .find(|todo| todo.id == id)
            .map(|todo| todo.is_done = true);
    }
}

在 dioxus fullstack 开发中,#[cfg(feature = "server")] 非常重要!

  • 它告诉 rust 编译器,该代码只在 server feature 中编译
  • 它避免了 tokio 等,无法在 dioxus 前端使用的 crate 被错误的编译,造成整个应用无法运行
  • 它避免了敏感数据暴露在前端的风险。诸如数据库连接信息、TOKEN 等敏感数据只能在后端使用,而不能暴露给前端
    • 我们在《使用 axum 和 dioxus 构建全栈 Web 应用》专题中讲到,dioxus 前端是无法在运行期间获取到环境变量的(需要使用 env!宏,在编译时指定 )。和 dioxus 前端不同,dioxus 后端是可以使用 std::env::var() ,在运行期获取环境变量

后端实现

是时候来实现后端功能了!

#[server]
async fn add_todo(title: String) -> Result<String, ServerFnError> {
    if title.is_empty() {
        return Ok("".to_string());
    }
    if title.len() > 50 {
        return Ok("".to_string());
    }
    let todo = models::Todo::new(title);
    let id = todo.id.clone();
    let db = db::get_db().await;
    if db.len() >= 10 {
        return Ok("".to_string());
    }
    db.add(todo);
    Ok(id)
}

#[server]
pub async fn delete_todo(id: String) -> Result<(), ServerFnError> {
    let db = db::get_db().await;
    db.del(&id);
    Ok(())
}

#[server]
pub async fn done_todo(id: String) -> Result<(), ServerFnError> {
    let db = db::get_db().await;
    db.done(&id);
    Ok(())
}

#[server]
pub async fn list_todos() -> Result<Vec<models::Todo>, ServerFnError> {
    let db = db::get_db().await;
    let mut todos = db.list();
    todos.sort_by(|a, b| b.id.cmp(&a.id));
    Ok(todos)
}
  • add_todo:添加一个待办事项
    • 为了限制演示站点的资源(因为我们使用内存做存储),这里对代办事项进行了限制:单条代办事项不能超过50个字符,所以待办事项不能超过10条。
  • delete_todo:删除指定的代办事项
  • done_todo:将指定的待办事项设置为完成
  • list_todos:获取待办事项列表

以上所有函数都调用了 db::get_db() 来获取数据存储,我们来看一下它是怎么实现的

虽然 dioxus fullstack 的后端使用 axum 实现,但并不能像常规 axum 那样,通过 State 或者 Extension 来共享状态。你可以通过自定义 extract 来共享状态,但实现起来略显复杂。

如果你使用过 Go/java 等语言,你会看到,它们通常使用单例模式来共享某一个资源,比如数据库连接池。rust 当然也支持这种方式,而本专题也采用这种方式。我们来看看怎么实现的:

use tokio::sync::OnceCell;

static DB: OnceCell<Store> = OnceCell::const_new();

pub async fn init_db() -> Store {
    Store::new()
}

pub async fn get_db() -> &'static Store {
    DB.get_or_init(init_db).await
}
  • 使用 OnceCell 定义了静态变量 DB
    • 它将在整个应用中共享(通过 get_db()函数)
    • OnceCell 可以确保只初始化一次,并且是线程安全的
  • init_db() 用于初始化操作,对于本案例而言,就是调用 Store::new() 方法
  • get_db() 获取 OnceCell 维护的对象的引用,本案例中,获取的是数据存储的引用
  • 有个 OnceCell 的详细介绍,请参阅其官方文档

集成前后端

万事俱备,只欠集成。是时候进行前后端集成了:

#[component]
pub fn TodoApp() -> Element {
    let todo_list = use_resource(list_todos);
    rsx! {
        Header { todo_list }
        TodoList { todo_list }
    }
}

#[component]
fn TodoList(todo_list: Resource<Result<Vec<models::Todo>, ServerFnError>>) -> Element {
    match &*todo_list.read_unchecked() {
        Some(Ok(list)) => {
            if list.is_empty() {
                rsx! {
                    div { "暂无代办事项" }
                }
            } else {
                rsx! {
                    ul { class: "space-y-2",
                        for item in list {
                            TodoItem {
                                key: "{item.id}",
                                item: item.clone(),
                                todo_list,
                            }
                        }
                    }
                }
            }
        }

        Some(Err(e)) => rsx! {
            div { "发生错误: {e}" }
        },
        None => rsx! {
            div { "正在加载..." }
        },
    }
}

#[component]
fn TodoItem(
    item: models::Todo,
    todo_list: Resource<Result<Vec<models::Todo>, ServerFnError>>,
) -> Element {
    let item_done = item.clone();
    rsx! {
        li { class: "flex items-center justify-between px-2 py-1 group hover:bg-gray-300 rounded",
            div { class: if item.is_done { "line-through" }, "{item.title}" }
            div { class: "flex items-center justify-end gap-x-1 group-hover:opacity-100 group-hover:visible invisible opacity-0 transition-all",
                if !item.is_done {
                    button {
                        class: "px-1 py-0.5 text-sm bg-green-600 text-white hover:bg-green-700",
                        onclick: move |_| {
                            let id = item_done.id.clone();
                            async move {
                                let _ = done_todo(id).await.unwrap();
                                todo_list.restart();
                            }
                        },
                        "完成"
                    }
                }
                button {
                    class: "px-1 py-0.5 text-sm bg-red-600 text-white hover:bg-red-700",
                    onclick: move |_| {
                        let id = item.id.clone();
                        async move {
                            let _ = delete_todo(id).await.unwrap();
                            todo_list.restart();
                        }
                    },
                    "删除"
                }
            }
        }
    }
}

#[component]
pub fn Header(mut todo_list: Resource<Result<Vec<models::Todo>, ServerFnError>>) -> Element {
    let mut title = use_signal(|| String::new());
    rsx! {
        div { class: "flex justify-between items-center gap-x-2",
            div { class: "grow",
                input {
                    placeholder: "输入代办事项",
                    value: "{title}",
                    oninput: move |e| title.set(e.value()),
                    class: "block w-full px-3 py-1.5 outline-none ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500",
                }
            }
            div { class: "shrink-0",
                button {
                    class: "px-3 py-1.5 text-white bg-blue-500  hover:bg-blue-600",
                    onclick: move |_| async move {
                        if title().is_empty() {
                            return;
                        }
                        if title().len() > 50 {
                            return;
                        }
                        let _ = add_todo(title()).await.unwrap();
                        title.set(String::new());
                        todo_list.restart();
                    },
                    "添加"
                }
            }

        }
    }
}
  • TodoApp 组件:整个应用入口主键
    • 使用 use_resource(todo_lists) 定义了一个,到 todo_list() 后端程序的资源
    • 调用 HeaderTodoList 组件,并将上一步定义的资源传递给两个子组件
  • TodoList 组件:渲染待办事项列表
    • 使用 for 来遍历所以待办事项
    • 具体待办事项由 TodoItem 组件渲染
  • TodoItem组件:渲染单个待办事项
  • Header 组件:渲染头部,用于添加代办事项
  • 所有组件都共享了 use_resource(todo_lists),原因在于,无论是添加、删除或者将某个待办事项设定为完成之后,都要重新获取代办事项列表,通过共享该资源,就能在操作完成后,重新获取列表数据。不知道你是否记得,dioxus 有全局状态,你可以试试看,能否使用全局状态,消除每个组件都要通过 Props 来传递该资源的问题。

lib.rsmain.rs

最后,我们看一下 lib.rs

pub mod components;
#[cfg(feature = "server")]
pub mod db;
pub mod models;

我们可以看到,db 模块被 #[cfg(feature = "server")] 修饰,也就是说,该模块对前端不可见。

main.rs 也很干净:

use dioxus::prelude::*;
use todo_app::components::TodoApp;

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 }
        document::Stylesheet { href: MAIN_CSS }

        div { class: "container mx-auto max-w-lg shadow-lg p-3 space-y-3", TodoApp {} }
    }
}

编译和部署

编译

使用以下命令编译:

dx bundle --release --platform web

编译成功后,将在 target/dx/todo-app/release 生成 web 目录,该目录下将包含:

  • public :前端代码
  • server:后端可执行文件

部署到 nginx

将编译成功后的文件(上一步生成的 web 目录)上传到服务器上,然后运行 ./server 即可,它会自动处理前端代码(就是说,上一步生成的public 目录要同时上传到服务器,但不需要使用 nginx 来为它创建站点)。而 nginx 只需要反代它监听的端口(dioxus 默认端口是 8080,我们将在下一个案例中讲解如何自定义端口)

server {
       listen [::]:80;
       server_name dx-todo.ace.dpdns.org;
        location / {
    		return 301 https://$http_host$request_uri;
  		}
}

server {
  listen  [::]:443 ssl http2;
  server_name dx-todo.ace.dpdns.org;

  ssl_certificate /opt/ssl/dx-todo.ace.dpdns.org.pem;
  ssl_certificate_key /opt/ssl/dx-todo.ace.dpdns.org.key;

  location / {
    proxy_redirect off;
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}
要查看完整内容,请先登录