前言
上篇写完之后,我自己其实挺清楚,那个项目还不能叫"能用"。
它已经有了这些东西:
- 左侧笔记列表
- 右侧详情区
- 搜索
- 路由同步
- SQLite 持久化
但它还缺一个特别现实的能力:
你总不能只看笔记,不写笔记。
问题也正出在这里。
"写"这件事一进场,项目的气质会立刻变。前一秒你还在悠闲地查数据、渲染页面,下一秒就要同时面对这些东西:
- 表单状态
- 新建和编辑的共用逻辑
- 前端校验和服务端校验
- 提交后的刷新和跳转
- Desktop 版和 Web 版怎么一起交付
这里最容易翻车的点,不是 Dioxus 的 API 不够,而是人很容易在"就再多加一点"里失去边界感。
一上来就想:
- 既然都写 Markdown 了,要不要实时预览?
- 既然都能写了,要不要自动保存?
- 既然都跨平台了,要不要桌面端顺手支持本地导出?
这些方向都对,但第一版如果一起上,项目会很快从"继续能写下去"变成"作者和读者都开始心累"。
我给自己的要求其实就一句话:
不把功能越写越多,只把"能写"和"能交付"这两件事写结实。
一、从"只读"进入"可写",项目会立刻换一个味道
上篇那种列表 + 详情的结构,很多事都还比较单纯。
你查数据,显示出来,就完了。
但只要加上"新建"和"编辑",页面立刻不再只是结果展示层,而是开始真的承接用户输入。这里面的变化,不是多一个按钮这么简单,而是整个项目多了三条新边界:
- 输入状态和持久化状态分开
- 页面草稿和数据库记录分开
- 表单校验和落库逻辑分开
这三条边界要是没分清,代码很容易写成一种很熟悉的味道:
- 输入框一改,直接把数据库模型也跟着改了
- 页面一提交,服务端顺手又做了摘要生成、搜索重建、跳转判断
- 新建和编辑看着只差一个
id,最后却复制出两套页面
这种写法不是不能跑,问题是它后面会越来越黏。黏到什么程度?黏到你加一个"取消编辑",都得顺手摸四五个文件。
项目往后能继续写,很大程度上就靠先认下一个事实:
新建页和编辑页虽然长得像,但它们真正共用的不是页面,而是表单模型。
二、先把表单数据结构收住,再谈页面
这个项目进入"写链路"之后,我第一件事不是去画新页面,而是先把表单模型独立出来。
因为新建和编辑的页面差异,远没有它们的数据相似度高。
举个例子,这种结构就很适合作为表单层的输入模型:
rust
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct NoteFormData {
pub title: String,
pub content: String,
}
这个模型故意很薄,只放当前表单真正会提交的字段。
它不是数据库里的 NoteRow,也不是列表页里的 NoteSummary。它只是:
"这一刻用户准备提交什么。"
这个区分最实际的地方在于,它把"页面长什么样"和"这次提交的是什么"拆开了。后面不管是新建还是编辑,都不会一上来就把数据库模型拖进输入框里陪跑。
这事听着抽象,真写起来特别具体。你一旦偷懒直接拿数据库那层的结构去喂表单,后面很容易出现这种场面:
- 列表页要的只是标题和摘要,结果表单顺手背上了
created_at - 明明只是改个标题,页面却开始关心
updated_at怎么刷新 - 你本来只想做输入回填,最后把存储结构也绑进组件生命周期里
这种耦合刚写出来的时候不疼,过两天最疼。
2.1 新建
新建就是给它一份空默认值:
rust
impl Default for NoteFormData {
fn default() -> Self {
Self {
title: String::new(),
content: String::new(),
}
}
}
2.2 编辑
编辑则是先把详情数据映射成表单数据:
rust
impl From<NoteDetail> for NoteFormData {
fn from(note: NoteDetail) -> Self {
Self {
title: note.title,
content: note.content,
}
}
}
这样一来,"新建页"和"编辑页"的差异就被压回了比较诚实的两件事:
- 初始值从哪来
- 提交时调哪个 server function
而不是复制两套几乎一样的表单 JSX... 不对,这里是 rsx!。Rust 人不能把 JSX 这口锅背得太自然,不然总感觉下一秒就要顺手写个 useEffect 出来。
三、表单不是状态越多越高级
Dioxus 写表单时,一个很容易让人绕进去的问题是:
每个输入框,到底要不要都塞进 signal?
这个问题如果硬聊流派,很容易聊成前端圣战。可真落到笔记应用里,答案其实挺世俗:看字段规模,看交互密度,看你是不是准备为了两个输入框引入一整套情绪稳定但篇幅失控的状态组织。
3.1 这一版字段很少,受控写法没有原罪
笔记应用的第一版表单,其实字段很少:
- 标题
- 正文
这种情况下,直接用 signal 控住是完全可以接受的。
rust
let mut title = use_signal(String::new);
let mut content = use_signal(String::new);
rsx! {
input {
value: "{title}",
oninput: move |evt| title.set(evt.value()),
placeholder: "标题"
}
textarea {
value: "{content}",
oninput: move |evt| content.set(evt.value()),
placeholder: "开始写点什么"
}
}
这种写法在这里没什么心理负担:
- 预填回显很顺
- 本地校验方便
- 按钮禁用态很好做
3.2 真正烦人的,是状态和数据结构开始互相串门
很多项目表单后面会越来越大。等字段长到十几个,你再把每个输入都塞进信号,页面就会出现一种很微妙的观感:用户只是想记一条笔记,作者却像在管理一个小型空管系统。
所以在这个笔记应用里,我只把真正需要实时反馈的东西放在本地状态里:
- 标题
- 正文
- 是否提交中
- 当前错误信息
而不是把"数据库最终长什么样"也拖进来。
说白了,表单状态只该回答"眼下这个人正在怎么输入",不该顺手冒充"整个应用的数据真相"。
四、校验要分两层,不然迟早会互相甩锅
到这一步,另一个绕不过去的问题就来了:校验放哪?
如果只放前端,用户体验是快了,但服务端完全可以被绕过。
如果全放服务端,规则是稳了,但用户每次点提交都像在请求后台批复,体验会很拖。
所以比较顺的做法还是老老实实分两层。
4.1 前端先拦住那些肉眼可见的问题
比如这几个规则,就很适合先在客户端挡掉:
- 标题不能为空
- 标题不能全是空格
- 正文太长时先提示
举个例子:
rust
fn validate_note_form(data: &NoteFormData) -> Result<(), String> {
if data.title.trim().is_empty() {
return Err("标题不能为空".into());
}
if data.title.chars().count() > 80 {
return Err("标题别写成小作文".into());
}
Ok(())
}
这个阶段的目标不是安全,而是别让用户按下提交之后,才被远端服务器郑重通知"标题是空的"。这种反馈方式非常正式,也非常烦人。
4.2 服务端再做最终裁判
真正落库前,服务端还是得再验一次。
rust
#[server]
pub async fn create_note(data: NoteFormData) -> Result<i64, ServerFnError> {
crate::server::note_service::validate(&data)
.map_err(ServerFnError::new)?;
let pool = crate::server::db::pool().await?;
crate::server::note_repo::create_note(pool, data).await
}
这里我故意没把 SQL 写进 #[server] 里。
因为一旦你在 server function 里顺手又做校验、又写 SQL、又拼跳转返回值,后面这个函数一定会越长越像一个"什么都行一点,但谁都替代不了"的大杂烩。
真到了这里,分层不是为了优雅,是为了不让责任互相甩锅:
- 表单组件:收输入、展示错误、触发提交
#[server]:收参数、调服务、回结果repo/service:校验和落库
不然过几轮迭代之后,你会得到一个又管参数、又管 SQL、又管错误文案、还顺手决定跳去哪一页的超级函数。它不一定长得丑,但脾气一定很大。
这类函数还有个共同特点:一旦它出问题,改的人会产生一种错觉,觉得自己不是在修 bug,而是在拆定时炸弹。
五、新建和编辑不要各写一套提交链路
这个项目真正开始顺起来,是在我不再把"新建"和"编辑"当成两种世界观之后。
它们对页面来说当然不同:
- 一个是空白进入
- 一个是带数据回填
但对表单本身来说,差异其实很有限。它们共享的是:
- 同一套输入字段
- 同一套前端校验
- 同一套错误展示
- 同一套提交中状态
所以最后更像样的收法,是把它们压进同一个 NoteEditor 组件:
rust
#[component]
fn NoteEditor(
initial: NoteFormData,
mode: EditorMode,
) -> Element {
// 省略状态和提交逻辑
rsx! { div { "editor" } }
}
mode 只负责告诉它当前是:
CreateEdit { id: i64 }
提交时再分流:
rust
match mode {
EditorMode::Create => create_note(form_data).await,
EditorMode::Edit { id } => update_note(id, form_data).await,
}
这看着像个小优化,实际是在替后面的自己省命。
因为你以后想补的很多东西:
- Markdown 预览
- 自动保存提示
- "离开页面前有未保存内容"
其实都属于编辑器层,不属于"新建页"和"编辑页"各自单独的一份逻辑。
六、提交成功后,真正麻烦的不是落库,是页面怎么回到一致状态
很多文章写到"提交成功,插入数据库"就收工了。
但真正让人皱眉的,往往是提交之后那几秒钟的世界是不是一致。
比如新建成功后,至少有几个动作要考虑:
- 左侧列表要刷新
- 右侧详情要切到新笔记
- URL 最好也同步到
/notes/{id}
编辑成功后则通常是:
- 详情页刷新
- 列表里的标题和摘要刷新
- 当前选中状态别丢
这一步最容易出现的现场,一般长这样:
- 左侧列表已经有了新标题
- 右侧详情还是旧内容
- 浏览器地址栏停在老 URL
- 你盯着页面三秒,开始怀疑是不是自己刚才没点到保存
- 再点一次之后,数据库里喜提两条内容差不多的新笔记
所以这里最重要的不是"状态更新得多快",而是谁来声明现在页面到底落在哪条笔记上。
举个例子,新建成功后:
rust
let navigator = use_navigator();
match create_note(data).await {
Ok(id) => {
navigator.push(Route::NoteDetail { id });
}
Err(err) => {
error.set(Some(err.to_string()));
}
}
为什么这里非得把路由重新请回来?
因为上篇其实已经把这个边界立住了:
当前选中项,不只是本地 UI 状态,它也是页面状态。
既然如此,新建成功之后跳到新详情页,本来就该通过路由来表达,而不是偷偷改一个局部 signal 装作世界已经更新完毕。
因为一旦新建成功之后 URL 还停在原地,整个页面就会进入一种很别扭的状态:数据像是新的,位置却像是旧的。用户未必能说出哪里不对,但会本能地觉得这玩意不太牢靠。
笔记应用特别怕这种"不牢靠感"。它不是付款页,不会立刻把用户吓跑;但它会慢慢让人不想把重要内容放进去。对这类工具来说,这几乎等于慢性死亡。
七、#[server] 在这个题里真正解决的,是边界感
写到这里,我对 Dioxus Fullstack 的感受其实挺具体。
它最有存在感的地方,不是"少写一个前端 fetch",而是这种笔记应用里那条原本很容易拧巴的线,终于没那么拧巴了:
- 页面组件
- 表单模型
- server function
- repo
- SQLite
这套东西当然还是有前后端边界,但那个边界不会再把人推到一种很熟悉的处境里:前端一份类型,后端一份类型,中间再加一份"理论上应该一致"的契约文件,三方彼此礼貌,彼此不完全相信。
举个例子,一个最薄的更新函数完全可以长这样:
rust
#[server]
pub async fn update_note(id: i64, data: NoteFormData) -> Result<(), ServerFnError> {
crate::server::note_service::validate(&data)
.map_err(ServerFnError::new)?;
let pool = crate::server::db::pool().await?;
crate::server::note_repo::update_note(pool, id, data).await?;
Ok(())
}
你要说它有没有代价?当然有。
- WebView 路线的桌面端不是原生渲染
- Fullstack 生态不算 Leptos 那么老练
- 打包、部署、feature 管理还是得自己收拾
至少在这个项目里,它让"同一套 Rust 工程跑 Web + Desktop,同时还带一点服务端能力"这件事,不再像演示稿,而开始像产品雏形。
这也是我后来越来越在意 #[server] 边界感的原因。不是因为我突然迷上分层,而是因为这条线一旦写松,最先坏掉的不是代码美感,而是整个项目的可预测性。
八、桌面打包真正开始折腾你的,通常不是命令
只要项目开始像样,下一步就一定会问:
这东西除了浏览器,桌面端到底能不能给别人用?
这时候 Dioxus 的一个现实优势会很明显:前面页面和 fullstack 这套结构如果没写歪,桌面版通常不需要你再重画一遍 UI。
开发阶段直接跑:
bash
dx serve --platform desktop
但窗口弹出来,只能证明它活着,不能证明它好用。真正会露馅的是这些事:
- 路由在 Desktop 下是不是照样正常
- 表单提交是不是依旧通
- 列表和详情布局在桌面宽屏下是不是更顺手
- 打开第二次时,数据文件是不是还在原来的地方
如果这些都没出戏,再谈打包才有意义。
bash
dx bundle --platform desktop
命令本身不复杂,复杂的是那些平时在开发目录里被你默认忽略的现实细节:
- 应用名、图标、窗口尺寸有没有配
- SQLite 数据文件要放哪
- 桌面版是不是需要一个更像应用的默认存储目录
尤其是 SQLite 路径这件事,开发时放当前目录里很省心,打包后经常立刻变成"这数据库到底写到哪去了"。
在开发目录里,sqlite://notes.db 这种写法看着岁月静好;一旦进了桌面包,它可能突然指向一个你以为自己知道、实际上根本不想让用户去碰的位置。更麻烦的是,这类 bug 往往不炸,它只是安静地让数据出现在奇怪的地方,或者根本没写进去。你以为应用挺稳定,用户以为自己记忆出了问题。
这类问题没有技术浪漫,只有用户双击应用后到底能不能把昨天的笔记找回来。
九、到了 Web 这头,它又会从"跨平台故事"变回"正常服务端应用"
很多人一听"Dioxus Fullstack",会下意识觉得 Web 部署应该很轻盈,像把前端静态资源一扔就完事。
真到这一步,气氛会立刻务实起来。因为它终归还是一个带服务端能力的 Rust 应用。
因为它不是单纯吐静态文件,而是带服务端能力的:
- Server Functions 要跑
- SQLite 要访问
- 可能还会有 SSR
所以 Web 这头最容易让人清醒的一点就是:它不是"顺手导出一个前端包",而是"把一套 fullstack 应用老老实实放到服务器上跑起来"。
开发阶段你可以先本地跑:
bash
dx serve
真准备上线时,再走 release 构建和部署。
如果只是一个小型个人项目,最务实的路线通常就是:
- 一台普通 Linux 服务器
- 一个反向代理
- 应用自己跑 server
- SQLite 先跟着项目一起上
这条路线听起来不性感,但它很像第一版产品真正需要的样子:先跑稳,再谈漂亮。
而且 Web 这头还有个特别现实的问题,桌面端不一定会把它暴露得这么明显:一旦服务端进程重启、工作目录变化、SQLite 文件权限不对,前端页面照样能打开,甚至布局还挺体面,但你的"保存"可能已经悄悄变成一种礼貌动作。按钮点下去有反馈,内容却没有真的落下去。这种 bug 的杀伤力,通常比直接报错还大。
毕竟笔记应用最怕的不是架构不够新,最怕的是你写了一周,最后连"刷新后还能看到刚才那条内容"都要靠祈祷。
十、这道题最能看出 Dioxus 适不适合你的地方,不是 UI,而是交付姿势
写到最后,我反而越来越觉得,这个题目最有意思的部分,不是做了一个笔记应用,而是它把 Dioxus 的定位照得很清楚。
它适合的人,大概率是这种需求:
- 想用 Rust 把页面、服务端和桌面版尽量收在一起
- 接受桌面端本质还是 WebView 路线
- 更看重一套代码多端复用,而不是追求最原生的 UI 控制力
它不那么适合的,则通常是另一类需求:
- 对桌面原生体验要求特别高
- 想把 UI 渲染完全掌控在自己手里
- 不接受 WebView 带来的平台约束
所以这篇做到最后,我反而更不想说"Dioxus 真香"这种轻飘飘的话了。
更准确一点的说法是:
它适合这种题:同一套 Rust 工程,既要上 Web,又要落 Desktop,还得带一点不那么重但又确实存在的服务端能力。
笔记应用刚好就是这种题。
总结
上篇我只把"读链路"跑通,这一篇补的则是更像产品后半程的部分:
- 新建和编辑怎么共用一套表单模型
#[server]怎么写得够薄- 提交成功后怎么让列表、详情、路由回到一致状态
- Desktop 和 Web 版到底怎么交付
如果把这两篇合起来看,真正留下来的不是某个 API,而是几条很朴素、但很难绕开的判断:
- 第一版范围必须收,不然笔记应用很快就会长成需求黑洞
- 列表模型、详情模型、表单模型不要混成一团
#[server]负责桥接,不负责统治一切- 路由是页面状态的一部分,不只是跳转工具
这些东西立住了,后面再加 Markdown 预览、标签、自动保存,都是继续搭。
这些东西没立住,你后面每加一个功能,都像在给潮湿墙面刷新漆。刚刷完看着挺像样,过几天还是会鼓包。