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

写 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 起步结构,可以先长这样:

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

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

src/lib.rs

pubmodapp;pubmodcomponents;pubmodpages;pubmodmodels;pubmodserver;

src/app.rs

usedioxus::prelude::*;usecrate::pages::{edit_note::EditNotePage,home::HomePage,new_note::NewNotePage,not_found::NotFoundPage};#[component]pubfnApp()->Element{rsx!{ErrorBoundary{handle_error:|error|{rsx!{div{class:"app-error",h1{"页面出错了"}p{"{error}"}}}},Router::<Route>{}}}}#[derive(Routable, Clone, PartialEq)]pubenumRoute{#[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

[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

usedioxus::prelude::*;#[derive(Clone, Debug, PartialEq)]structNoteFormData{title:String,content:String,}fnvalidate_note_form(input:&NoteFormData)->Result<(),&'staticstr>{ifinput.title.trim().is_empty(){returnErr("标题不能为空");}ifinput.content.trim().is_empty(){returnErr("正文不能为空");}Ok(())}#[component]fnApp()->Element{letmuttitle=use_signal(String::new);letmutcontent=use_signal(String::new);letmutmessage=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|_|{letform=NoteFormData{title:title(),content:content(),};matchvalidate_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}"}}}}fnmain(){tracing_subscriber::fmt::init();dioxus::launch(App);}#[cfg(test)]modtests{usesuper::*;#[test]fnreject_empty_title(){letresult=validate_note_form(&NoteFormData{title:" ".into(),content:"hello".into(),});assert_eq!(result,Err("标题不能为空"));}}

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

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

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

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

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

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

pubstructNote{pubid:i64,pubtitle:String,pubcontent:String,pubcreated_at:String,pubupdated_at:String,}

然后这个结构被拿去:

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

短期很爽,后面很疼。

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

比如:

useserde::{Deserialize,Serialize};#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]pubstructNoteSummary{pubid:i64,pubtitle:String,pubexcerpt:String,}#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]pubstructNoteDetail{pubid:i64,pubtitle:String,pubcontent:String,}#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]pubstructNoteFormData{pubtitle:String,pubcontent: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>,组件里碰到错误是可以直接往错误边界冒的。

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

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

useanyhow::{Context,Result};usedioxus::prelude::*;#[component]fnNoteContent(raw_markdown:String)->Element{lethtml=markdown::to_html(&raw_markdown).parse::<String>().context("Markdown 渲染失败")?;rsx!{article{dangerous_inner_html:"{html}"}}}

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

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

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

然后在更上层统一兜底:

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

useanyhow::{bail,Result};usecrate::models::note::{NoteDetail,NoteFormData};pubasyncfnsave_note(input:NoteFormData)->Result<NoteDetail>{ifinput.title.trim().is_empty(){bail!("标题不能为空");}Ok(NoteDetail{id:1,title:input.title,content:input.content,})}

src/server/mod.rs

usedioxus::prelude::*;usecrate::models::note::{NoteDetail,NoteFormData};pubmodnote_service;#[server]pubasyncfnsave_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 都喜欢这么写:

#[server]asyncfndelete_note(id:i64)->Result<(),ServerFnError>{// ...Err(ServerFnError::new("删除失败"))}

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

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

删除失败

删为什么失败?

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

全糊在一起。

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

更稳一点的做法是:

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

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

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

这块我前面也走过弯路。

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

println!("save note start");println!("note id = {}",id);println!("db done");

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

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

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

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

官方Logging文档里给的方向很明确:Dioxus 这套日志能力本身就是围着tracing来的。
客户端和服务端尽量统一到tracing这套宏上,别一边println!一边再补别的。

这个思路我很认同。

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

先说客户端。

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

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

比如:

usedioxus::prelude::*;#[component]pubfnNoteListItem(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当正经工具用会顺很多。

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

usetracing::Level;fnmain(){tracing_subscriber::fmt().with_max_level(Level::INFO).init();dioxus::launch(app::App);}

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

useanyhow::Result;usecrate::models::note::NoteFormData;pubasyncfnsave_note(input:NoteFormData)->Result<()>{tracing::info!(title=%input.title,"save note request");ifinput.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!片段渲染出来的结果。

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

举个例子:

usedioxus::prelude::*;fnassert_rsx_eq(first:Element,second:Element){letfirst=dioxus_ssr::render_element(first);letsecond=dioxus_ssr::render_element(second);pretty_assertions::assert_str_eq!(first,second);}#[test]fnnote_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]最好的状态,本来就应该很薄。

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

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

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

比如:

#[cfg(all(test, feature ="server"))]modtests{usecrate::models::note::NoteFormData;usecrate::server::note_service;#[tokio::test]asyncfnsave_note_rejects_empty_title(){leterr=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 和跨平台两条线一起撑住。

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