写 Dioxus Demo 不难,难的是把它写成项目

前言

我前面做那个全栈跨平台笔记应用的时候,有一个很明显的分界点。

前面几期,更多是在解决"怎么把功能做出来":

  • 页面怎么拆
  • 路由怎么跳
  • Server Function 怎么调
  • SQLite 怎么接
  • Web 和 Desktop 怎么一起跑

但功能一旦开始变多,项目的主要矛盾就会变成另一件事:

代码还能不能继续往下写。

这话听着像废话,真到项目里一点都不废。

因为 Dioxus 的 demo 很容易给人一种错觉:页面已经起来了,Server Function 也通了,桌面版和 Web 版还都能跑,那离"项目"应该只差一点 CSS 和一点业务。

实际不是。

实际差得最多的,反而是这些不起眼的东西:

  • UI 组件和页面是不是已经开始互相串门
  • #[server] 里到底是在收参数,还是顺手包办了半个后端
  • 出错时用户看到的是什么,自己排查时看到的又是什么
  • 以后你敢不敢动这段代码

我自己踩过一个特别典型的坑。

一开始我把"新建笔记"和"编辑笔记"都写通了,心情还挺好。结果两天后想补一个"删除后回到列表页"的小需求,顺手一翻代码,发现页面、Server Function、数据库查询、跳转逻辑、提示文案,已经有点拧在一起了。

那一刻我就知道,这项目再不收拾,后面就会越来越像"功能都在,维护靠缘分"。

所以这一篇不继续加功能,专门聊工程化,而且只聊最小可落地的工程化。

不是上来就仓储层、DDD、六边形架构那一套。

而是先把 Dioxus 项目从"能跑 demo"收成"还能继续写"的状态。

Dioxus 最大的优势,真不是某个 API 名字多高级,而是跨平台这件事非常直观。

同一套笔记应用,如果 Web 版和 Desktop 版放在一起对比,观感会特别强。代码还没展开,先看到"一套 Rust 代码两边都跑起来了",这件事本身就很有说服力。

一、Dioxus 一到项目阶段,最容易乱的不是 UI,而是边界

我先把结论放前面:

Dioxus 工程化最重要的事,不是"拆多少层",而是先把 4 条边界立住。

  • components 只关心复用 UI
  • pages 只关心页面编排和页面级状态
  • server 只关心服务端逻辑和外部资源
  • models 只关心输入输出的数据形状

这 4 条边界一旦糊掉,项目就会开始出现很熟悉的味道:

  • 组件里顺手 import 了数据库模型
  • 页面里直接知道 SQL 该怎么查
  • #[server] 里面一边校验、一边落库、一边拼展示文案
  • 同一个 Note 结构同时拿来当表单、数据库行、接口返回、列表项

这些写法不是当天就炸。

它们最烦的地方在于:第一版通常都能跑,而且跑得还挺像那么回事。

可一旦需求开始叠,你就会很快发现,Dioxus 这种"同一套 Rust 代码覆盖客户端和服务端"的项目,最怕的不是代码少,而是角色不清。

尤其你前面如果是从 React、Vue 或 Tauri 过来,很容易下意识把所有东西都往"前端目录"里塞。

但 Dioxus fullstack 不是这个脑回路。

按 Dioxus 官方 Project SetupServer Functions 的说法,dx 会把不同平台的构建隔离开,Server Functions 本质上也是 Axum-compatible endpoint。换句话说,它虽然写在一套 Rust 工程里,但客户端和服务端的边界并没有消失,只是被放到了一个更近的位置。

这也是我为什么越来越觉得,Dioxus 的工程化重点不是"省掉架构",而是"别因为写得顺手,就假装边界不存在"。

二、目录先别花,先让人一眼看懂谁该改哪里

一个更顺手的 Dioxus fullstack 起步结构,可以先长这样:

text 复制代码
src/
├── main.rs
├── lib.rs
├── app.rs
├── components/
│   ├── mod.rs
│   ├── layout.rs
│   ├── note_form.rs
│   └── note_list.rs
├── pages/
│   ├── mod.rs
│   ├── home.rs
│   ├── new_note.rs
│   ├── edit_note.rs
│   └── not_found.rs
├── models/
│   ├── mod.rs
│   ├── note.rs
│   └── form.rs
└── server/
    ├── mod.rs
    ├── db.rs
    ├── errors.rs
    ├── note_repo.rs
    └── note_service.rs

这个结构不炫,但它有一个特别现实的好处:

你加一个需求时,先知道自己该去哪。

举个例子:

  • 改笔记表单的 UI 细节,去 components/note_form.rs
  • 改"新建页"和"编辑页"的流程,去 pages/
  • 改入库和查询逻辑,去 server/
  • 改接口收发和表单数据结构,去 models/

如果一个需求动不动就同时改 7 个文件,那不是说明你项目复杂,多半是说明边界已经串了。

2.1 一个能跑的最小骨架

先给一个能落地的最小骨架。下面这几段拼起来,就是一个很像项目起点的 Dioxus 结构。

src/main.rs

rust 复制代码
fn main() {
    dioxus::launch(app::App);
}

src/lib.rs

rust 复制代码
pub mod app;
pub mod components;
pub mod pages;
pub mod models;
pub mod server;

src/app.rs

rust 复制代码
use dioxus::prelude::*;

use crate::pages::{edit_note::EditNotePage, home::HomePage, new_note::NewNotePage, not_found::NotFoundPage};

#[component]
pub fn App() -> Element {
    rsx! {
        ErrorBoundary {
            handle_error: |error| {
                rsx! {
                    div { class: "app-error",
                        h1 { "页面出错了" }
                        p { "{error}" }
                    }
                }
            },
            Router::<Route> {}
        }
    }
}

#[derive(Routable, Clone, PartialEq)]
pub enum Route {
    #[route("/")]
    HomePage {},
    #[route("/notes/new")]
    NewNotePage {},
    #[route("/notes/:id/edit")]
    EditNotePage { id: i64 },
    #[route("/:..route")]
    NotFoundPage { route: Vec<String> },
}

上面这段故意有两个点先立住:

  • 应用入口里就把 ErrorBoundary 放好
  • 路由是路由,页面是页面,别把一堆页面逻辑塞回 main.rs

2.1.1 先给一个能直接跑起来的最小片段

上面的目录是项目形态。

如果现在还在"先把脑回路跑通"的阶段,可以先写一个能直接 cargo run 的最小例子,再往目录里拆。

Cargo.toml

toml 复制代码
[package]
name = "dioxus_project_shape_demo"
version = "0.1.0"
edition = "2021"

[dependencies]
dioxus = { version = "0.7", features = ["desktop"] }
tracing = "0.1"
tracing-subscriber = "0.3"

src/main.rs

rust 复制代码
use dioxus::prelude::*;

#[derive(Clone, Debug, PartialEq)]
struct NoteFormData {
    title: String,
    content: String,
}

fn validate_note_form(input: &NoteFormData) -> Result<(), &'static str> {
    if input.title.trim().is_empty() {
        return Err("标题不能为空");
    }

    if input.content.trim().is_empty() {
        return Err("正文不能为空");
    }

    Ok(())
}

#[component]
fn App() -> Element {
    let mut title = use_signal(String::new);
    let mut content = use_signal(String::new);
    let mut message = use_signal(String::new);

    rsx! {
        div {
            h1 { "Dioxus Project Shape Demo" }
            input {
                value: "{title}",
                placeholder: "标题",
                oninput: move |evt| title.set(evt.value()),
            }
            textarea {
                value: "{content}",
                placeholder: "正文",
                oninput: move |evt| content.set(evt.value()),
            }
            button {
                onclick: move |_| {
                    let form = NoteFormData {
                        title: title(),
                        content: content(),
                    };

                    match validate_note_form(&form) {
                        Ok(_) => {
                            tracing::info!(title = %form.title, "submit note form");
                            message.set("校验通过,可以继续调 server function".into());
                        }
                        Err(err) => {
                            message.set(err.into());
                        }
                    }
                },
                "提交"
            }
            p { "{message}" }
        }
    }
}

fn main() {
    tracing_subscriber::fmt::init();
    dioxus::launch(App);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn reject_empty_title() {
        let result = validate_note_form(&NoteFormData {
            title: "   ".into(),
            content: "hello".into(),
        });

        assert_eq!(result, Err("标题不能为空"));
    }
}

这段代码虽然还没拆目录,但已经先把 3 件事分开了:

  • NoteFormData 负责数据形状
  • validate_note_form 负责规则
  • App 组件只负责输入和展示

你先把这个最小例子跑顺,再拆成 components / pages / models,心里会踏实很多。

2.2 models 不要偷懒只放一个万能 Note

这是我自己很容易写歪的一点。

很多人一开始为了省事,会只写一个:

rust 复制代码
pub struct Note {
    pub id: i64,
    pub title: String,
    pub content: String,
    pub created_at: String,
    pub updated_at: String,
}

然后这个结构被拿去:

  • 做列表项
  • 做详情页
  • 做编辑表单
  • 做创建接口入参
  • 做数据库查询返回

短期很爽,后面很疼。

更稳一点的写法,是把"显示给谁看"和"这次提交什么"分开。

比如:

rust 复制代码
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NoteSummary {
    pub id: i64,
    pub title: String,
    pub excerpt: String,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NoteDetail {
    pub id: i64,
    pub title: String,
    pub content: String,
}

#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NoteFormData {
    pub title: String,
    pub content: String,
}

这样做最直接的好处不是"更优雅",而是:

你后面改列表展示,不会顺手把表单也改炸。

三、错误处理别再到处 unwrap,项目里最怕的是"白屏但没线索"

Dioxus 这块我越写越有感触。

Rust 本身当然鼓励你认真处理错误,但很多 UI demo 一跑起来,人还是会忍不住回到老路:

  • unwrap() 再说
  • expect("不可能失败") 再说
  • 先把 String 当错误类型顶一顶再说

demo 阶段这么干,问题还不算特别大。

可一到项目阶段,这种写法最恶心的地方是:

用户看到的是挂了,开发者看到的是不够定位。

而 Dioxus 0.7 其实已经把两条线都给你了:

  • 组件渲染错误可以往最近的 ErrorBoundary
  • fullstack 场景里 server function 也可以返回更明确的错误类型和状态

3.1 页面级错误:先把 ErrorBoundary 放到一个像样的位置

官方 Error Handling 文档里提到,Dioxus 组件返回的 Element 本质上就是 Result<VNode, RenderError>,组件里碰到错误是可以直接往错误边界冒的。

这件事最大的价值不是"语法很酷",而是你终于不用在页面树里到处写"如果炸了就显示这一段兜底文案"。

举个例子,下面这种写法就很适合放在页面边界:

rust 复制代码
use anyhow::{Context, Result};
use dioxus::prelude::*;

#[component]
fn NoteContent(raw_markdown: String) -> Element {
    let html = markdown::to_html(&raw_markdown)
        .parse::<String>()
        .context("Markdown 渲染失败")?;

    rsx! {
        article {
            dangerous_inner_html: "{html}"
        }
    }
}

这里我故意写得简单一点。

重点不是 markdown::to_html 这个 API,而是这个思路:

组件如果真的可能在渲染阶段失败,就别硬吞,交给 ErrorBoundary。

然后在更上层统一兜底:

rust 复制代码
rsx! {
    ErrorBoundary {
        handle_error: |error| rsx! {
            section { class: "error-panel",
                h2 { "这块内容没渲染出来" }
                p { "{error}" }
            }
        },
        NoteContent { raw_markdown: content }
    }
}

3.2 服务端错误:#[server] 这层一定要薄

我现在对 Dioxus Server Function 的一个判断越来越明确:

#[server] 最好的状态,不是自己什么都干,而是只做一层薄薄的入口。

因为按官方文档的定义,Server Function 说到底就是一个可直接生成 HTTP endpoint 的 Rust 函数,本质上还是 endpoint。既然它本质上是入口层,那它就不该长成一个混合怪物:

  • 上来先校验
  • 再连库
  • 再写 SQL
  • 再拼 DTO
  • 再决定提示文案
  • 再顺手记日志

这一套全塞进去,第一版是很快,第二版就开始烦。

更顺一点的拆法是这样:

src/server/note_service.rs

rust 复制代码
use anyhow::{bail, Result};

use crate::models::note::{NoteDetail, NoteFormData};

pub async fn save_note(input: NoteFormData) -> Result<NoteDetail> {
    if input.title.trim().is_empty() {
        bail!("标题不能为空");
    }

    Ok(NoteDetail {
        id: 1,
        title: input.title,
        content: input.content,
    })
}

src/server/mod.rs

rust 复制代码
use dioxus::prelude::*;

use crate::models::note::{NoteDetail, NoteFormData};

pub mod note_service;

#[server]
pub async fn save_note(input: NoteFormData) -> Result<NoteDetail, ServerFnError> {
    note_service::save_note(input)
        .await
        .map_err(|err| ServerFnError::new(err.to_string()))
}

上面这个拆法,工程意义非常大:

  • #[server] 负责收口协议边界
  • 真正业务逻辑在 note_service
  • 单元测试也优先打 note_service

别小看这一步。

它直接决定你以后测的是"业务规则",还是"宏展开之后那层很薄的壳子"。

3.3 fullstack 错误别只图省事全返回 String

我知道很多 demo 都喜欢这么写:

rust 复制代码
#[server]
async fn delete_note(id: i64) -> Result<(), ServerFnError> {
    // ...
    Err(ServerFnError::new("删除失败"))
}

也不是不行,但这很容易让错误信息越来越平。

前端最后拿到的,常常只剩一句:

删除失败

删为什么失败?

  • 没找到
  • 已经删过
  • 参数错了
  • 数据库炸了

全糊在一起。

官方 Fullstack Error Handling 文档其实已经把方向给出来了:server function 可以返回 ServerFnErrorStatusCodeHttpError,也可以返回自定义错误。

更稳一点的做法是:

  • 先区分用户错误和系统错误
  • 用户错误尽量给清楚
  • 系统错误别把内部细节直接吐给页面

项目里最怕的不是有错误,而是所有错误都长一张脸。

四、日志别再靠 println! 找魂,Dioxus 这套更适合直接上 tracing

这块我前面也走过弯路。

最开始调 Dioxus 页面和 server function 的时候,确实很容易顺手:

rust 复制代码
println!("save note start");
println!("note id = {}", id);
println!("db done");

但它只适合非常短暂的"我先看看代码走没走到这里"。

一旦项目开始跨 Web、Desktop、Server 三头,这套东西就不够用了。

因为你很快会遇到这些问题:

  • 这条日志到底来自浏览器、桌面端还是服务端
  • 哪些日志开发时看,哪些日志上线后还要看
  • 页面出问题时,能不能把一次操作的上下文串起来

官方 Logging 文档里给的方向很明确:Dioxus 这套日志能力本身就是围着 tracing 来的。

客户端和服务端尽量统一到 tracing 这套宏上,别一边 println! 一边再补别的。

这个思路我很认同。

4.1 客户端:让 UI 事件先留下痕迹

先说客户端。

Web 端我现在基本就两类日志:

  • 用户操作日志
  • 页面异常日志

比如:

rust 复制代码
use dioxus::prelude::*;

#[component]
pub fn NoteListItem(id: i64, title: String) -> Element {
    rsx! {
        button {
            onclick: move |_| {
                tracing::info!(note_id = id, "open note from list");
            },
            "{title}"
        }
    }
}

这类日志不花,但很有用。

因为后面你真开始查"为什么某个跳转没发生""为什么某个按钮点了没反应",这些 UI 事件痕迹会比一堆裸 println! 顺太多。

按官方文档,Dioxus 在 Web 端会接到自己的 logging 方案上;实操里排查前端日志,基本就是看浏览器开发者工具里的 console 输出。这个习惯越早养越省事。

4.2 服务端:关键链路统一打结构化日志

服务端这边,直接把 tracing 当正经工具用会顺很多。

一个最小可用的初始化写法可以是:

rust 复制代码
use tracing::Level;

fn main() {
    tracing_subscriber::fmt()
        .with_max_level(Level::INFO)
        .init();

    dioxus::launch(app::App);
}

然后在 server 侧关键链路上留结构化字段:

rust 复制代码
use anyhow::Result;

use crate::models::note::NoteFormData;

pub async fn save_note(input: NoteFormData) -> Result<()> {
    tracing::info!(title = %input.title, "save note request");

    if input.title.trim().is_empty() {
        tracing::warn!("reject empty title");
        anyhow::bail!("标题不能为空");
    }

    tracing::info!("note saved");
    Ok(())
}

为什么我强调"结构化字段"?

因为你后面查问题的时候,最想看到的不是:

保存笔记了

而是:

  • 保存的是哪条
  • 哪一步失败
  • 是用户输入问题还是服务端异常

日志一旦开始带字段,项目排查体验会完全不一样。

4.3 别把日志和错误提示混成一件事

这是另一个很常见的坑。

很多人会把面向用户的提示文案,直接也当成日志内容。

比如用户看到:

保存失败,请稍后重试

日志里也只有:

保存失败,请稍后重试

这就没意义了。

更稳的做法是分开:

  • 用户提示负责"说人话"
  • 日志负责"留线索"

这两件事不该互相替代。

五、测试别一上来追求大而全,先把最值钱的两层补上

聊工程化,很多人一说到测试就很容易直接泄气。

因为脑子里立刻会出现这些画面:

  • E2E 跑起来很麻烦
  • Web + Desktop 双端一起测更麻烦
  • Fullstack 一套下来一看就不像今天能补完的样子

这判断也没错。

所以我现在更愿意把 Dioxus 项目的测试优先级压到两层:

  • 组件测试
  • Server Function 下面那层服务逻辑测试

先把这两层补上,性价比已经很高了。

5.1 组件测试:别先测浏览器,先测 rsx! 输出

官方 Testing 文档给了一个很实在的方向:可以用 dioxus-ssr + pretty_assertions 去比对两个 rsx! 片段渲染出来的结果。

这个方法我挺喜欢,因为它特别适合测"纯展示组件"。

举个例子:

rust 复制代码
use dioxus::prelude::*;

fn assert_rsx_eq(first: Element, second: Element) {
    let first = dioxus_ssr::render_element(first);
    let second = dioxus_ssr::render_element(second);
    pretty_assertions::assert_str_eq!(first, second);
}

#[test]
fn note_list_empty_state_should_render_hint() {
    assert_rsx_eq(
        rsx! {
            section { class: "empty-state",
                p { "还没有笔记,先写第一条吧" }
            }
        },
        rsx! {
            section { class: "empty-state",
                p { "还没有笔记,先写第一条吧" }
            }
        },
    );
}

这个测试不酷,但很实用。

尤其你后面把组件拆多了之后,很多 UI 回归其实根本不需要先拉起浏览器,先把静态渲染结果守住已经能挡掉一批低级改坏。

5.2 Server Function 单测:真正该测的是下面那层规则

这一块我想说得直接一点:

Dioxus 项目里,"Server Function 单元测试"最稳的落点,通常不是硬测 #[server] 宏那层,而是测它下面的 service。

这不是文档里的原句,而是我根据官方把 Server Function 定义成 Axum-compatible endpoint 这件事,往工程实践上推出来的判断。但它在实战里很有用。

因为 #[server] 最好的状态,本来就应该很薄。

真正有业务价值、最容易回归的,是:

  • 空标题要拦
  • 不存在的笔记不能更新
  • 删除后计数要不要变
  • 搜索结果的排序是不是还对

这些都应该落在服务逻辑层。

比如:

rust 复制代码
#[cfg(all(test, feature = "server"))]
mod tests {
    use crate::models::note::NoteFormData;
    use crate::server::note_service;

    #[tokio::test]
    async fn save_note_rejects_empty_title() {
        let err = note_service::save_note(NoteFormData {
            title: "   ".into(),
            content: "body".into(),
        })
        .await
        .unwrap_err();

        assert!(err.to_string().contains("标题不能为空"));
    }
}

这类测试有一个很现实的好处:

它不依赖浏览器,不依赖桌面壳,也不依赖 UI 生命周期。

你测的就是"规则到底对不对"。

工程上,这往往比"我能不能模拟一次整链路点击"更值钱。

六、别把工程化理解成"上来就摆大架子"

写到这里,我反而想替"工程化"这三个字降降温。

因为它特别容易把人吓跑。

很多人一看到工程化,就会自动脑补成:

  • 目录必须特别深
  • 类型必须特别多
  • 每层都要抽接口
  • 不上 DI 不配叫项目

真没必要。

尤其 Dioxus 这种还在快速演进、而且很多项目本来就是中小型跨平台工具的场景,最有价值的工程化,不是把架子摆得多满,而是先把几个会长期折腾你的问题收住:

  • 页面和组件别乱串
  • server-only 代码别泄到客户端
  • 错误别只剩白屏
  • 日志别只靠 println!
  • 关键规则至少有几条单测守住

如果这几件事你已经做到,那这个项目哪怕目录没多高级,也比一堆"看着分层很完整,实际上没人敢改"的工程强得多。

我自己现在越来越在意的,也不是"这项目像不像大厂模板",而是:

三周后我回来看,还敢不敢继续往里加需求。

这才是项目和 demo 的真正分界线。

这期解决了什么

这期我主要想把一个问题说透:

Dioxus 的难点,很多时候不是把页面写出来,而是把同一套 Web + Desktop + Server 的代码边界收住。

具体落到工程里,就是这几件事:

  • 先按 components / pages / server / models 立住职责
  • ErrorBoundary 和更清楚的 server error 把"白屏式报错"收掉
  • tracing 替换掉随手乱飞的 println!
  • 让组件测试和服务逻辑测试先把最值钱的回归点守住

如果把这些补上,Dioxus 项目就会从"功能堆起来了"往"还能继续维护"跨一步。

当前方案还有什么问题

这套方案不是终点,它只是我觉得现在最划算的起点。

它还有几个很现实的问题:

  • 组件测试现在更适合测静态 rsx! 输出,交互层测试还不算特别顺手
  • Server Function 虽然能和 Axum 生态很好地接起来,但一旦项目继续长大,service / repo / auth / middleware 这些层还是得继续补
  • 错误类型如果后面越来越多,只靠字符串映射成 ServerFnError 会慢慢变粗糙
  • Web 和 Desktop 虽然能共用一套代码,但平台差异一多,日志字段、错误展示、能力降级策略也得继续细化

说白了,这一版工程化解决的是:

先别让项目继续写着写着散掉。

它还没完全解决的是:

当项目继续变大时,怎么把 fullstack 和跨平台两条线一起撑住。

但我觉得先把第一步走稳,比一上来追求"终极架构"重要得多。

相关推荐
labixiong1 小时前
还原一个完整符合规范的 Promise(二)
前端·javascript
时光足迹1 小时前
腾讯云 TRTC UniApp SDK 从入门到上线
前端·vue.js·uni-app
时光足迹2 小时前
uni-app 里把加密视频嵌入页面播放?我分析了 4 种方案,只有 1 种接近完美
前端·vue.js·uni-app
To_OC2 小时前
万字解析《JS 语言精粹》之第五章:继承 5 大核心精髓(JS 原型核心)
前端·javascript·代码规范
时光足迹2 小时前
极光推送全攻略(上):被iOS证书折磨了三天,我写了一份前端也能看懂的避坑指南
前端·ios·uni-app
DyLatte2 小时前
AI 时代,最危险的不是被替代,而是努力不沉淀
前端·后端·程序员
mCell3 小时前
【锐评】桌面端技术营销:别拿跑分当工程判断
前端·rust·electron
柒和远方3 小时前
从一次工程审查看 AI 学习产品的边界兜底:RAG 资料链路一致性实战
前端·后端·架构
疯狂的魔鬼3 小时前
一个"懂分寸"的文本省略组件是怎样炼成的
前端·vue.js·设计