我用 Rust + Dioxus 做了个全栈跨平台笔记应用:第一版先把列表和详情跑通

前言

前面一路把 Dioxus 的核心零件拆下来之后,我很快撞上一个很具体的问题:

  • rsx! 会了
  • Signals 也懂了
  • 路由能配
  • #[server] 也能跑
  • SQLite 也确实连上了

但这些零件一旦往一个真实题目里拼,味道马上就变了。

最先踩到的坑,基本都是这一类:

  • 页面里一边写 UI,一边偷偷查数据库
  • 列表和详情共用一坨状态,改哪都牵一发而动全身
  • 本来只是想做个笔记应用,写着写着又想加标签、归档、同步、富文本、导出 PDF
  • 最后功能没做完,工程结构先烂了

后来回头看,问题不在代码写得少,而在第一版根本没收住。

所以这一篇不想写成"Dioxus 能做什么"的展示文,而是按这个笔记应用的真实落地过程,复盘第一版是怎么收敛下来的。

我最后选了 Markdown 笔记应用,原因很直接:它刚好能把 Dioxus 最值得讲的几条线一起串起来:

  • Web 页面可以直接跑
  • Desktop 版天然成立
  • Server Functions 有明确用武之地
  • SQLite 足够支撑第一版
  • 列表、详情、搜索这些交互都不假

它比 Todo 更像一个真的会继续往下做的跨平台工具。

这篇也不是"从零搭出完整版笔记系统"的保姆文。那个方向太大,写着写着一定会散。

这篇只复盘一件事:

这个笔记应用的第一版,为什么只先做读链路,以及这条链路最后是怎么定下来的。

这里的"读链路"指的是:

  1. 左侧看到笔记列表
  2. 点一条,右侧出现详情
  3. 搜索词能影响列表结果
  4. URL 能表达当前选中的笔记
  5. 数据从 SQLite 来,不是假数据

这条线跑顺以后,后面的新建、编辑、删除、表单校验、桌面打包,基本都是沿着现有骨架继续往下补。

一、问题不在不会写,而在第一版想塞的东西太多

这个项目刚起步的时候,最大的问题不是不会写列表,也不是不会连 SQLite,而是第一版想做的东西太多。

笔记应用这个题目有个很麻烦的地方:它太容易越做越大了。只要往前多想一步,需求立刻就会长出一串分支:

  • 要不要多端同步?
  • 要不要 Markdown 实时预览?
  • 要不要标签系统?
  • 要不要文件夹树?
  • 要不要本地优先再异步上传?

这些方向都成立,也都不是伪需求。问题在于,第一版如果把这些一起带上,项目复杂度会立刻从"做一个最小可用原型"跳到"做一个产品雏形"。

我后来是强行把问题收回到下面这几件事上:

  • 数据怎么存
  • 列表和详情怎么串起来
  • 搜索应该落在哪一层
  • 路由要不要表达当前选中状态
  • 页面、服务端、数据库的边界怎么分

因为这些问题不先卡住,后面写得越多,返工成本越高。

我一开始也差不多是这个路数:

既然 Dioxus 都能跑全栈了,那我就先把所有东西堆进去再说。

听起来很有冲劲,实际特别容易翻车。

这个项目真正开始顺起来,是在我把第一版边界收成一句很笨、但很有用的话之后:

第一版只做"读",先把"读"做顺。

因为"列表 -> 选中 -> 详情 -> 搜索 -> URL 同步 -> 数据落库"这条链路一旦顺了,后面很多东西自然就能往上接。

二、最后定成双栏,不是为了好看

这个项目最后没有做成手机记事本那种"一个页面一个页面点进去"的交互,而是收成了更接近 Obsidian、Notion 左栏列表那种工作区形态:

  • 左边一栏放笔记列表
  • 上面一个搜索框
  • 右边一栏放当前选中的笔记详情

最后会定成这个结构,不是因为它看起来更像成熟产品,而是因为它同时满足了几件很实际的事:

  • Web 上成立
  • Desktop 上也成立
  • 路由很好设计
  • 页面状态不复杂

更重要的是,这种布局跟当时的目标是对得上的:同一套 Rust 代码,在浏览器和桌面端都得像个真的工具,而不是"只是能跑"。

把第一版的用户路径列出来,为什么会选这个布局就很清楚了:

  1. 用户打开应用,左侧看到最近更新的笔记列表
  2. 点击某一项,右侧展示完整内容
  3. 在搜索框输入关键字,左侧列表实时变化
  4. 刷新页面后,如果 URL 是 /notes/12,仍然能恢复第 12 条笔记详情

这条路径一旦成立,项目马上就不再是"按钮点了会变色"的玩具了。路由、查询、状态和数据结构都会一起进场:

  • 页面布局
  • 路由状态
  • 服务端查询
  • 数据库结构

所以我后来越来越觉得,这个题目很适合拿来检验 Dioxus fullstack 到底有没有接成一条线。

三、我没继续写 Todo,因为它藏不住这次真正要处理的问题

Todo 当然不是不能写。但这次项目往前走的时候,我很快就发现,它会把不少真问题直接糊过去。

Todo 最擅长展示的是这些:

  • 基础状态管理
  • 列表渲染
  • 增删改查的最小闭环

但它不太容易自然带出下面这些更像项目的问题:

  • 列表页和详情页是不是要分开
  • 搜索应该在哪一层做
  • 返回模型要不要分摘要和详情
  • URL 应不应该表达选中状态
  • Desktop 版是否还合理

笔记应用就不一样。它虽然也是 CRUD,但很快就会逼出两个分离:

  1. 列表数据和详情数据要不要分离
  2. 页面状态和服务端查询要不要分层

这两个问题,恰恰就是很多 Dioxus 实战一开始最容易写歪的地方。

所以没继续写 Todo,不是为了避俗,而是因为笔记应用这个题目会逼着我正面处理 Dioxus 的几个核心问题。

四、第一版到底砍掉了什么

这个项目后来能往前走,一个很现实的原因就是第一版砍得够狠。

我当时给自己划了一条很硬的线,这一版先不碰下面这些东西:

4.1 第一版一定要做的

  • 笔记列表查询
  • 关键字搜索
  • id 查看笔记详情
  • 路由和当前选中项同步
  • SQLite 持久化

4.2 第一版刻意不做的

  • 新建和编辑
  • Markdown 预览
  • 标签和文件夹
  • 多端同步
  • 用户系统

砍需求不是为了省事,而是为了先把骨架立住。

那时候真正需要先确认的,不是标签怎么设计、预览怎么接、同步怎么做,而是:

  • 承重墙在哪
  • 水电怎么走
  • 门洞是不是打对了

落到这个项目里,对应的就是:

  • 页面层负责什么
  • Server Function 负责什么
  • 仓储层负责什么
  • 数据库到底该暴露多少给前端

这些东西不立住,后面功能越多,返工越疼。

五、数据库别一上来就设计得太满

只要一说到笔记系统,脑子里很容易马上蹦出一套"完整版"表设计:

  • notes
  • tags
  • note_tags
  • folders
  • attachments
  • users
  • sync_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 个字符?要不要去掉换行?要不要把空白内容显示成"无内容"?这些都更像服务端返回时的整形逻辑,而不是数据库该承担的结构责任。

所以我最后把摘要放在服务端现算。

这样做有两个很直接的好处:

  1. 不需要维护额外冗余字段
  2. 后面如果摘要规则变了,不用回头迁移旧数据

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 层消费"能力",而不是消费"实现细节"。

七、列表和详情为什么最后一定拆成了两条资源

如果按最省事的思路写,这里特别容易变成下面这样:

  1. 页面加载时一次性把所有笔记都查出来
  2. 点哪一条,就在前端从数组里拿出那条完整数据展示

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 件事:

  1. 读取当前路由,推导出 selected_id
  2. 管理搜索框输入
  3. 分别触发列表资源和详情资源
  4. 把结果传给左右两个展示组件

也就是说,页面层是用来协调的,不是用来承包一切的。

页面结构最后大概长这样:

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(),
        }
    }
}

这样拆完之后,NoteListNoteDetailPanel 都会变得很干净。

它们不关心:

  • 查询是从哪来的
  • SQL 写了什么
  • 数据库是不是 SQLite

它们只关心:

  • 我拿到了什么数据
  • 我要怎么展示
  • 用户交互发生后往外抛什么事件

这就是"组件层只消费数据,不生产 SQL"这句话真正落到代码里的样子。

十、回头看,第一版真正要做对的就是主链路

回头看,这一篇虽然一直在说列表和详情,但本质上并不是在教"怎么做一个双栏页面"。

它真正想解决的是:这个 Dioxus fullstack 项目的第一条主链路应该怎么搭,后面才不容易返工。

这条主链路可以再压缩成一句话:

用户在页面上触发搜索或选择,页面层只做编排,Server Function 只做桥接,仓储层负责查询,数据库负责存储,最后结果再返回到展示组件。

这句话拆开以后,对应的其实都是很具体的工程决策:

  • 为什么不用一个大状态把所有数据都兜住
  • 为什么不在组件里连数据库
  • 为什么列表和详情要拆
  • 为什么返回模型不能和表结构强绑定
  • 为什么 URL 应该表达当前选中项

只要这几个问题想清楚,后面的扩展基本都能顺着这套结构继续长。

后面如果要加"新建笔记"和"编辑笔记",新增的无非是:

  • create_note / update_note 这类 Server Function
  • 表单状态和提交状态
  • 更新成功后刷新列表和详情

但骨架本身不用推翻。

到这一步再回头看,第一版才算真的写对了。

总结

这一篇没有急着把笔记应用一次做完,而是先把第一版里最影响后续质量的那部分站稳了:

  • 为什么这个题目比 Todo 更适合 Dioxus fullstack
  • 为什么第一版要先收窄到"读链路"
  • 为什么数据库先用一张 notes 表就够
  • 为什么列表、详情、搜索、路由同步其实就是应用骨架
  • 为什么页面、Server Functions、仓储层必须分开

Dioxus 实战最怕的不是功能少,而是边界乱。

如果也在做一个 Rust + Dioxus 的工具型应用,我还是建议先把"页面编排、服务端桥接、数据库查询"这三层分干净,再去堆更重的能力。

相关推荐
用户1733598075371 小时前
Vue 3 SPA 首屏优化:从 3s 到 1.2s 的 5 个实践
前端·vue.js
咖啡无伴侣1 小时前
基础骨架:30 分钟搭好 pnpm workspace,完成双项目 Monorepo 迁入
前端
谷无姜2 小时前
Webpack5 进阶思考:那些官方文档没讲清楚的事
前端·webpack
weedsfly2 小时前
还在用 Axios?你可能需要重新理解 XHR 与 Fetch
前端·javascript·面试
CoderWeen2 小时前
从零实现一个 Vue3 流程图编辑器:节点拖拽、贝塞尔连线与框选
前端·javascript
森鹿2 小时前
express中间件原理以及大致实现
前端·express
光影少年2 小时前
HashRouter 和 BrowserRouter 区别、底层原理、部署差异
前端·react.js·nestjs
柯克七七2 小时前
我把祖传项目的构建时间砍了90%,领导以为我只是在"优化了一下",结果隔壁组的CI都崩了来问我配置
前端·webpack
风骏时光牛马2 小时前
JSP页面直接输出实体对象空属性引发页面500报错实战案例
前端