前言
我前面做那个全栈跨平台笔记应用的时候,有一个很明显的分界点。
前面几期,更多是在解决"怎么把功能做出来":
- 页面怎么拆
- 路由怎么跳
- 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 起步结构,可以先长这样:
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 可以返回 ServerFnError、StatusCode、HttpError,也可以返回自定义错误。
更稳一点的做法是:
- 先区分用户错误和系统错误
- 用户错误尽量给清楚
- 系统错误别把内部细节直接吐给页面
项目里最怕的不是有错误,而是所有错误都长一张脸。
四、日志别再靠 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 和跨平台两条线一起撑住。
但我觉得先把第一步走稳,比一上来追求"终极架构"重要得多。