写 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只关心复用 UIpages只关心页面编排和页面级状态server只关心服务端逻辑和外部资源models只关心输入输出的数据形状这 4 条边界一旦糊掉项目就会开始出现很熟悉的味道组件里顺手 import 了数据库模型页面里直接知道 SQL 该怎么查#[server]里面一边校验、一边落库、一边拼展示文案同一个Note结构同时拿来当表单、数据库行、接口返回、列表项这些写法不是当天就炸。它们最烦的地方在于第一版通常都能跑而且跑得还挺像那么回事。可一旦需求开始叠你就会很快发现Dioxus 这种“同一套 Rust 代码覆盖客户端和服务端”的项目最怕的不是代码少而是角色不清。尤其你前面如果是从 React、Vue 或 Tauri 过来很容易下意识把所有东西都往“前端目录”里塞。但 Dioxus fullstack 不是这个脑回路。按 Dioxus 官方Project Setup和Server 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.rsfnmain(){dioxus::launch(app::App);}src/lib.rspubmodapp;pubmodcomponents;pubmodpages;pubmodmodels;pubmodserver;src/app.rsusedioxus::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:VecString},}上面这段故意有两个点先立住应用入口里就把ErrorBoundary放好路由是路由页面是页面别把一堆页面逻辑塞回main.rs2.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.3src/main.rsusedioxus::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{letmuttitleuse_signal(String::new);letmutcontentuse_signal(String::new);letmutmessageuse_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|_|{letformNoteFormData{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(){letresultvalidate_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本质上就是ResultVNode, RenderError组件里碰到错误是可以直接往错误边界冒的。这件事最大的价值不是“语法很酷”而是你终于不用在页面树里到处写“如果炸了就显示这一段兜底文案”。举个例子下面这种写法就很适合放在页面边界useanyhow::{Context,Result};usedioxus::prelude::*;#[component]fnNoteContent(raw_markdown:String)-Element{lethtmlmarkdown::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.rsuseanyhow::{bail,Result};usecrate::models::note::{NoteDetail,NoteFormData};pubasyncfnsave_note(input:NoteFormData)-ResultNoteDetail{ifinput.title.trim().is_empty(){bail!(标题不能为空);}Ok(NoteDetail{id:1,title:input.title,content:input.content,})}src/server/mod.rsusedioxus::prelude::*;usecrate::models::note::{NoteDetail,NoteFormData};pubmodnote_service;#[server]pubasyncfnsave_note(input:NoteFormData)-ResultNoteDetail,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 可以返回ServerFnError、StatusCode、HttpError也可以返回自定义错误。更稳一点的做法是先区分用户错误和系统错误用户错误尽量给清楚系统错误别把内部细节直接吐给页面项目里最怕的不是有错误而是所有错误都长一张脸。四、日志别再靠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_idid,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-ssrpretty_assertions去比对两个rsx!片段渲染出来的结果。这个方法我挺喜欢因为它特别适合测“纯展示组件”。举个例子usedioxus::prelude::*;fnassert_rsx_eq(first:Element,second:Element){letfirstdioxus_ssr::render_element(first);letseconddioxus_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(){leterrnote_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 和跨平台两条线一起撑住。但我觉得先把第一步走稳比一上来追求“终极架构”重要得多。