前言
前面一路把 Dioxus 的核心零件拆下来之后,我很快撞上一个很具体的问题:
rsx!会了- Signals 也懂了
- 路由能配
#[server]也能跑- SQLite 也确实连上了
但这些零件一旦往一个真实题目里拼,味道马上就变了。
最先踩到的坑,基本都是这一类:
- 页面里一边写 UI,一边偷偷查数据库
- 列表和详情共用一坨状态,改哪都牵一发而动全身
- 本来只是想做个笔记应用,写着写着又想加标签、归档、同步、富文本、导出 PDF
- 最后功能没做完,工程结构先烂了
后来回头看,问题不在代码写得少,而在第一版根本没收住。
所以这一篇不想写成"Dioxus 能做什么"的展示文,而是按这个笔记应用的真实落地过程,复盘第一版是怎么收敛下来的。
我最后选了 Markdown 笔记应用,原因很直接:它刚好能把 Dioxus 最值得讲的几条线一起串起来:
- Web 页面可以直接跑
- Desktop 版天然成立
- Server Functions 有明确用武之地
- SQLite 足够支撑第一版
- 列表、详情、搜索这些交互都不假
它比 Todo 更像一个真的会继续往下做的跨平台工具。
这篇也不是"从零搭出完整版笔记系统"的保姆文。那个方向太大,写着写着一定会散。
这篇只复盘一件事:
这个笔记应用的第一版,为什么只先做读链路,以及这条链路最后是怎么定下来的。
这里的"读链路"指的是:
- 左侧看到笔记列表
- 点一条,右侧出现详情
- 搜索词能影响列表结果
- URL 能表达当前选中的笔记
- 数据从 SQLite 来,不是假数据
这条线跑顺以后,后面的新建、编辑、删除、表单校验、桌面打包,基本都是沿着现有骨架继续往下补。
一、问题不在不会写,而在第一版想塞的东西太多
这个项目刚起步的时候,最大的问题不是不会写列表,也不是不会连 SQLite,而是第一版想做的东西太多。
笔记应用这个题目有个很麻烦的地方:它太容易越做越大了。只要往前多想一步,需求立刻就会长出一串分支:
- 要不要多端同步?
- 要不要 Markdown 实时预览?
- 要不要标签系统?
- 要不要文件夹树?
- 要不要本地优先再异步上传?
这些方向都成立,也都不是伪需求。问题在于,第一版如果把这些一起带上,项目复杂度会立刻从"做一个最小可用原型"跳到"做一个产品雏形"。
我后来是强行把问题收回到下面这几件事上:
- 数据怎么存
- 列表和详情怎么串起来
- 搜索应该落在哪一层
- 路由要不要表达当前选中状态
- 页面、服务端、数据库的边界怎么分
因为这些问题不先卡住,后面写得越多,返工成本越高。
我一开始也差不多是这个路数:
既然 Dioxus 都能跑全栈了,那我就先把所有东西堆进去再说。
听起来很有冲劲,实际特别容易翻车。
这个项目真正开始顺起来,是在我把第一版边界收成一句很笨、但很有用的话之后:
第一版只做"读",先把"读"做顺。
因为"列表 -> 选中 -> 详情 -> 搜索 -> URL 同步 -> 数据落库"这条链路一旦顺了,后面很多东西自然就能往上接。
二、最后定成双栏,不是为了好看
这个项目最后没有做成手机记事本那种"一个页面一个页面点进去"的交互,而是收成了更接近 Obsidian、Notion 左栏列表那种工作区形态:
- 左边一栏放笔记列表
- 上面一个搜索框
- 右边一栏放当前选中的笔记详情
最后会定成这个结构,不是因为它看起来更像成熟产品,而是因为它同时满足了几件很实际的事:
- Web 上成立
- Desktop 上也成立
- 路由很好设计
- 页面状态不复杂
更重要的是,这种布局跟当时的目标是对得上的:同一套 Rust 代码,在浏览器和桌面端都得像个真的工具,而不是"只是能跑"。
把第一版的用户路径列出来,为什么会选这个布局就很清楚了:
- 用户打开应用,左侧看到最近更新的笔记列表
- 点击某一项,右侧展示完整内容
- 在搜索框输入关键字,左侧列表实时变化
- 刷新页面后,如果 URL 是
/notes/12,仍然能恢复第 12 条笔记详情
这条路径一旦成立,项目马上就不再是"按钮点了会变色"的玩具了。路由、查询、状态和数据结构都会一起进场:
- 页面布局
- 路由状态
- 服务端查询
- 数据库结构
所以我后来越来越觉得,这个题目很适合拿来检验 Dioxus fullstack 到底有没有接成一条线。
三、我没继续写 Todo,因为它藏不住这次真正要处理的问题
Todo 当然不是不能写。但这次项目往前走的时候,我很快就发现,它会把不少真问题直接糊过去。
Todo 最擅长展示的是这些:
- 基础状态管理
- 列表渲染
- 增删改查的最小闭环
但它不太容易自然带出下面这些更像项目的问题:
- 列表页和详情页是不是要分开
- 搜索应该在哪一层做
- 返回模型要不要分摘要和详情
- URL 应不应该表达选中状态
- Desktop 版是否还合理
笔记应用就不一样。它虽然也是 CRUD,但很快就会逼出两个分离:
- 列表数据和详情数据要不要分离
- 页面状态和服务端查询要不要分层
这两个问题,恰恰就是很多 Dioxus 实战一开始最容易写歪的地方。
所以没继续写 Todo,不是为了避俗,而是因为笔记应用这个题目会逼着我正面处理 Dioxus 的几个核心问题。
四、第一版到底砍掉了什么
这个项目后来能往前走,一个很现实的原因就是第一版砍得够狠。
我当时给自己划了一条很硬的线,这一版先不碰下面这些东西:
4.1 第一版一定要做的
- 笔记列表查询
- 关键字搜索
- 按
id查看笔记详情 - 路由和当前选中项同步
- SQLite 持久化
4.2 第一版刻意不做的
- 新建和编辑
- Markdown 预览
- 标签和文件夹
- 多端同步
- 用户系统
砍需求不是为了省事,而是为了先把骨架立住。
那时候真正需要先确认的,不是标签怎么设计、预览怎么接、同步怎么做,而是:
- 承重墙在哪
- 水电怎么走
- 门洞是不是打对了
落到这个项目里,对应的就是:
- 页面层负责什么
- Server Function 负责什么
- 仓储层负责什么
- 数据库到底该暴露多少给前端
这些东西不立住,后面功能越多,返工越疼。
五、数据库别一上来就设计得太满
只要一说到笔记系统,脑子里很容易马上蹦出一套"完整版"表设计:
notestagsnote_tagsfoldersattachmentsuserssync_jobs
然后刚建完表,就已经有点不想继续了。
这就是典型的"产品还没跑,架构先起飞"。
但这个项目的第一版,真不需要这么重。
5.1 一张 notes 表就够把主链路跑通
我最后就是从这一张表起步的:
sql
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notes_updated_at
ON notes(updated_at DESC);
这张表看着很朴素,但已经能支撑这个项目第一版要做的所有事:
title给列表标题和搜索content给详情正文,也能顺带支持全文模糊匹配updated_at给列表排序
它不豪华,但够用。对第一版来说,这比一步到位更实际。
5.2 为什么第一版先不加 summary
这个问题我当时也想过:
列表不是要显示摘要吗?那是不是应该建个
summary字段?
可以,但第一版我最后没这么做。
原因很简单,摘要在这个阶段并不是"独立数据",而更像"展示策略"。
举个例子,列表里要不要截 80 个字符?要不要去掉换行?要不要把空白内容显示成"无内容"?这些都更像服务端返回时的整形逻辑,而不是数据库该承担的结构责任。
所以我最后把摘要放在服务端现算。
这样做有两个很直接的好处:
- 不需要维护额外冗余字段
- 后面如果摘要规则变了,不用回头迁移旧数据
5.3 为什么第一版也不急着上 FTS
SQLite 明明支持全文检索,那为什么先不用?
因为当时的目标不是做"搜索系统",而是先把"一个可继续扩展的笔记应用第一版"立住。
第一版先用 LIKE 做模糊匹配,足够说明:
- 搜索应该在服务端做
- 数据不是前端硬过滤假列表
- 查询能力已经进入真实项目范畴
等后面数据量上来,再把 LIKE 替换成 FTS,这才是正常节奏。
六、这个项目最容易写歪的地方,不是 SQL,而是边界
我一开始脑子里最顺手的写法,其实就是下面这样:
rust
#[component]
fn NotesPage() -> Element {
let notes = use_resource(|| async move {
let pool = SqlitePool::connect("sqlite://notes.db").await.unwrap();
sqlx::query_as::<_, Note>("SELECT * FROM notes")
.fetch_all(&pool)
.await
.unwrap()
});
rsx! { ... }
}
这段代码不是不能跑,问题是它把三件本来应该分开的事情揉在一起了:
- 页面组件
- 数据库连接
- 查询逻辑
Demo 里这么写很常见,因为快。但项目只要再往前走一步,问题马上就会出来:
- Web 构建和 server build 边界开始模糊
- 数据库连接生命周期失控
- 组件开始知道太多底层细节
- 后面想做测试、缓存、日志都难接
这个项目后来能继续往下写,靠的就是一开始先把职责拆开:
text
src/
├── main.rs
├── routes/
│ └── mod.rs
├── pages/
│ └── notes.rs
├── components/
│ ├── note_list.rs
│ └── note_detail.rs
├── models/
│ └── note.rs
└── server/
├── db.rs
├── note_repo.rs
└── note_api.rs
这不是为了追求"目录工整",而是因为后面一旦要加编辑、刷新、校验、打包,这几个边界不先分开,改起来会很痛苦。
6.1 models 层:先按页面需要返回什么来设计
写到这里我才真正意识到,数据库里存什么,和页面需要什么,并不总是一回事。
举个例子,列表页和详情页就不该吃同一种数据。
rust
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NoteSummary {
pub id: i64,
pub title: String,
pub preview: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NoteDetail {
pub id: i64,
pub title: String,
pub content: String,
pub updated_at: String,
}
为什么最后要拆成这两个模型?因为列表页和详情页的诉求根本不一样:
- 列表页要轻,要快,要适合搜索结果预览
- 详情页才需要完整正文
如果一开始就把完整 content 全量拉到列表页,再靠前端自己挑哪条显示详情,短期省事,长期会越来越笨。
6.2 db 层:数据库连接是应用级资源,不是页面级临时变量
后来有一件事我基本不再犹豫:数据库连接绝对不能继续留在页面组件内部。
所以最后的做法,是在服务端做一个共享连接池初始化:
rust
#[cfg(feature = "server")]
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
#[cfg(feature = "server")]
use tokio::sync::OnceCell;
#[cfg(feature = "server")]
static DB: OnceCell<SqlitePool> = OnceCell::const_new();
#[cfg(feature = "server")]
pub async fn pool() -> Result<&'static SqlitePool, sqlx::Error> {
DB.get_or_try_init(|| async {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite://data/notes.db")
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&pool)
.await?;
Ok(pool)
})
.await
}
这段代码解决的是数据库资源归位的问题:
- 连接池只初始化一次
- 建表逻辑只放一处
- 页面完全不需要知道数据库怎么来的
连接池是应用资源,不是某个页面的局部变量。
6.3 repo 层:这里才是真正应该写 SQL 的地方
很多文章会把 #[server] 函数直接写成"查库函数"。Demo 没问题,但这个项目里我最后没这么堆。
我最后收成的是下面这层关系:
#[server]只负责暴露接口repo才真正负责查库和整形
比如列表查询可以写成这样:
rust
pub async fn list_notes(
pool: &SqlitePool,
keyword: Option<String>,
) -> Result<Vec<NoteSummary>, ServerFnError> {
let keyword = keyword.unwrap_or_default().trim().to_string();
let pattern = format!("%{keyword}%");
let rows = sqlx::query_as::<_, NoteRow>(
r#"
SELECT id, title, content, updated_at
FROM notes
WHERE (? = '' OR title LIKE ? OR content LIKE ?)
ORDER BY updated_at DESC
"#,
)
.bind(&keyword)
.bind(&pattern)
.bind(&pattern)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|row| NoteSummary {
id: row.id,
title: row.title,
preview: row.content.replace('\n', " ").chars().take(80).collect(),
updated_at: row.updated_at,
})
.collect())
}
第一,搜索放在服务端做。
这不是因为前端不能过滤,而是只要数据最终来自数据库,搜索逻辑理应跟着查询走,而不是先把所有内容拉回来,再在组件里 filter 一遍。
第二,摘要在服务端裁。
因为"摘要怎么生成"属于展示策略,跟查询结果一起返回,页面会轻很多。
第三,列表只查列表真正需要的字段。
哪怕现在这条 SQL 里还把 content 带出来做摘要,后面也可以继续优化成更明确的投影或单独摘要字段。关键是思路要对。
6.4 note_api 层:Server Functions 做薄,不做重
在这个项目里,#[server] 最后只保留成页面和仓储之间那层薄桥接:
rust
#[server]
pub async fn list_notes(keyword: Option<String>) -> Result<Vec<NoteSummary>, ServerFnError> {
let pool = crate::server::db::pool().await?;
crate::server::note_repo::list_notes(pool, keyword).await
}
#[server]
pub async fn get_note(id: i64) -> Result<Option<NoteDetail>, ServerFnError> {
let pool = crate::server::db::pool().await?;
crate::server::note_repo::get_note(pool, id).await
}
这样收完以后,页面只需要知道:
- 我有个
list_notes - 我还有个
get_note
至于连接池怎么初始化、SQL 怎么写、摘要怎么截断,页面一概不用管。
这里的边界很明确:
UI 层消费"能力",而不是消费"实现细节"。
七、列表和详情为什么最后一定拆成了两条资源
如果按最省事的思路写,这里特别容易变成下面这样:
- 页面加载时一次性把所有笔记都查出来
- 点哪一条,就在前端从数组里拿出那条完整数据展示
10 条测试数据时这么写当然没问题,但项目再往前走一步,坑就出来了:
- 列表会越来越重
- 详情数据不可控
- 后面难做分页
- 搜索和选中态耦合在一起
后来我基本是被迫接受了一个事实:
列表和详情虽然都属于"读",但它们是两条不同的数据通路。
所以页面层最后收成了下面这样:
rust
let mut keyword = use_signal(String::new);
let notes = use_resource(move || async move {
let kw = keyword();
list_notes((!kw.trim().is_empty()).then_some(kw))
.await
.unwrap_or_default()
});
let detail = use_resource(move || async move {
match selected_id {
Some(id) => get_note(id).await.unwrap_or(None),
None => None,
}
});
这样拆完之后,依赖关系就很清楚了:
- 搜索词变了,只会影响列表资源
- 选中的
id变了,只会影响详情资源 - 两边的生命周期互不拖累
写到这里,问题已经不是"有没有状态管理"了,而是状态边界划得好不好。
八、最后我还是让 URL 表达了"当前选中了哪条笔记"
一开始也不是没想过把选中状态只留在内存里:
反正是个双栏页面,选中状态存在内存里不就行了?
短期当然可以。但这个项目既然要同时跑 Web 和 Desktop,这个想法就不太够。
所以最后还是让路由直接表达当前上下文:
rust
#[derive(Routable, Clone, PartialEq)]
pub enum Route {
#[route("/notes")]
NotesIndex {},
#[route("/notes/:id")]
NoteDetail { id: i64 },
}
这里真正值钱的不是"路由写法对不对",而是这个设计会直接带来几个结果:
- 刷新之后还能恢复当前笔记
- 详情页可以直接分享 URL
- 页面和状态关系更透明
- Desktop 和 Web 的导航模型保持一致
这时候就能看到,路由已经不只是"切页面"的工具了,它还承担了状态持久化表达的职责。
这个点一开始很容易被低估。
九、页面组件到底该管到哪一步
写到这里,页面组件更像"编排层",而不是"大总管"。
它主要负责 4 件事:
- 读取当前路由,推导出
selected_id - 管理搜索框输入
- 分别触发列表资源和详情资源
- 把结果传给左右两个展示组件
也就是说,页面层是用来协调的,不是用来承包一切的。
页面结构最后大概长这样:
rust
rsx! {
div { class: "notes-layout",
NoteList {
items: notes.cloned().unwrap_or_default(),
keyword: keyword(),
selected_id,
on_search: move |value| keyword.set(value),
on_select: move |id| navigator.push(Route::NoteDetail { id }),
}
NoteDetailPanel {
note: detail.cloned().flatten(),
}
}
}
这样拆完之后,NoteList 和 NoteDetailPanel 都会变得很干净。
它们不关心:
- 查询是从哪来的
- SQL 写了什么
- 数据库是不是 SQLite
它们只关心:
- 我拿到了什么数据
- 我要怎么展示
- 用户交互发生后往外抛什么事件
这就是"组件层只消费数据,不生产 SQL"这句话真正落到代码里的样子。
十、回头看,第一版真正要做对的就是主链路
回头看,这一篇虽然一直在说列表和详情,但本质上并不是在教"怎么做一个双栏页面"。
它真正想解决的是:这个 Dioxus fullstack 项目的第一条主链路应该怎么搭,后面才不容易返工。
这条主链路可以再压缩成一句话:
用户在页面上触发搜索或选择,页面层只做编排,Server Function 只做桥接,仓储层负责查询,数据库负责存储,最后结果再返回到展示组件。
这句话拆开以后,对应的其实都是很具体的工程决策:
- 为什么不用一个大状态把所有数据都兜住
- 为什么不在组件里连数据库
- 为什么列表和详情要拆
- 为什么返回模型不能和表结构强绑定
- 为什么 URL 应该表达当前选中项
只要这几个问题想清楚,后面的扩展基本都能顺着这套结构继续长。
后面如果要加"新建笔记"和"编辑笔记",新增的无非是:
create_note/update_note这类 Server Function- 表单状态和提交状态
- 更新成功后刷新列表和详情
但骨架本身不用推翻。
到这一步再回头看,第一版才算真的写对了。
总结
这一篇没有急着把笔记应用一次做完,而是先把第一版里最影响后续质量的那部分站稳了:
- 为什么这个题目比 Todo 更适合 Dioxus fullstack
- 为什么第一版要先收窄到"读链路"
- 为什么数据库先用一张
notes表就够 - 为什么列表、详情、搜索、路由同步其实就是应用骨架
- 为什么页面、Server Functions、仓储层必须分开
Dioxus 实战最怕的不是功能少,而是边界乱。
如果也在做一个 Rust + Dioxus 的工具型应用,我还是建议先把"页面编排、服务端桥接、数据库查询"这三层分干净,再去堆更重的能力。