前言
上一篇我们把组件、Props 和组件通信拆完了。
接下来很自然就会碰到另一个问题:组件会写了,那多页面应用到底怎么搭?
如果你是从 React 过来的,脑子里大概已经有一套熟悉的映射:
Route对应页面Link对应导航- Layout 包公共壳子
- 嵌套路由负责把列表页、详情页、设置页串起来
这个理解在 Dioxus 里基本成立。
但 Dioxus Router 和很多前端路由库有个很不一样的地方:它不太鼓励你到处拼路径字符串,而是把路由收进一个有类型的 Rust 枚举里。
因为一旦路由是类型,很多平时在前端项目里拖到运行时才暴露的问题,会更早地在编译阶段就冒出来。比如:
- 动态参数是不是漏传了
- 参数类型是不是写错了
- 某个页面到底归哪个 Layout 管
- 这个跳转是不是应该
push,还是应该replace
所以这篇不打算只讲"怎么写几个 #[route] 宏"。我更关心的是把主线捋顺:
- 路由枚举到底在项目里扮演什么角色
Outlet为什么是多页面结构的关键- 页面目录该怎么拆,才不会后面越写越乱
1. 先把心智摆正:Dioxus 路由不是字符串表,而是应用的页面契约
先看一个最小例子:
rust
use dioxus::prelude::*;
#[derive(Clone, Debug, PartialEq, Routable)]
enum Route {
#[route("/")]
Home {},
#[route("/notes")]
NotesList {},
#[route("/settings")]
Settings {},
}
fn App() -> Element {
rsx! {
Router::<Route> {}
}
}
#[component]
fn Home() -> Element {
rsx! {
div {
h1 { "首页" }
nav {
Link { to: Route::NotesList {}, "去笔记列表" }
" | "
Link { to: Route::Settings {}, "去设置页" }
}
}
}
}
这一段的重点其实不是 Router::<Route> {},而是 Route 这个枚举。
你可以把它理解成整个应用的页面地图。
以前很多前端项目的路由表,常常是:
- 一个字符串路径
- 一个组件引用
- 外加一堆元信息
写起来当然也没问题,但时间一长就容易出现"路径改了,别的地方没跟着改""动态参数少传一个也没人提醒"的场面。
而在 Dioxus 里,官方文档对 Router 的定位很直接:它是 Dioxus 自带的路由方案,接口风格接近 React Router,但会借 Rust 的类型系统把路由定义收得更紧。
落到项目里,大概有两点最值钱:
1.1 页面入口统一收口
所有能访问到的页面,都应该在 Route 里有名字。
这个好处非常直接:
- 项目一大,页面边界还是清楚的
- 新同学上手时,先看
Route就知道应用大概长什么样 - 跳转目标不是乱写路径字符串,而是明确的枚举变体
1.2 动态参数会跟着路由类型走
比如详情页必须带 note_id,那你就别想着"先跳过去再说",编译器根本不会放过你。
这比在运行时等 404、等空页面、等参数解析失败,靠谱得多。
所以如果让我先给一个总原则,那就是:
Dioxus 的路由系统,先当成"页面契约"来设计,再把它当成"导航工具"来使用。
我会建议先按这个顺序来想。
2. 动态路由别怕,它其实就是"路径参数进入类型系统"
多页面应用里最常见的,不是首页和设置页,而是:
- 列表页
- 详情页
- 编辑页
- 某个资源下的子页面
这类页面只靠静态路径肯定不够,所以动态路由基本绕不过去。
举个最常见的例子,笔记详情页:
rust
use dioxus::prelude::*;
#[derive(Clone, Debug, PartialEq, Routable)]
enum Route {
#[route("/notes")]
NotesList {},
#[route("/notes/:note_id")]
NoteDetail { note_id: usize },
}
#[component]
fn NotesList() -> Element {
rsx! {
ul {
li {
Link {
to: Route::NoteDetail { note_id: 1 },
"打开 1 号笔记"
}
}
li {
Link {
to: Route::NoteDetail { note_id: 42 },
"打开 42 号笔记"
}
}
}
}
}
#[component]
fn NoteDetail(note_id: usize) -> Element {
rsx! {
section {
h1 { "笔记详情" }
p { "当前笔记 ID:{note_id}" }
}
}
}
这个写法非常像"把 URL 参数解构成函数参数"。
Dioxus Router 在 Routable 的派生文档里写得很清楚:动态段用 /:dynamic 表示,参数类型需要能从字符串解析出来。像 usize、String 这类常见类型,用起来都比较顺手。
这套写法的好处很实际:
- 路径和参数是一套东西,不是分开的两套约定
- 详情页的参数需求,直接体现在组件签名里
- 你一旦少传参数、传错类型,问题会尽早暴露
如果你从 React Router 过来,可以把它理解成:
- 以前是先匹配路径,再手动从 params 里拿值
- 现在是路由匹配完成后,参数直接进组件
写业务时,后者会省心不少。
尤其是资源型页面很多的时候,比如:
/posts/:id/users/:user_id/orders/:order_id/projects/:project_id/settings
页面一多,你会很快感受到这层约束的好处。
3. 多页面项目后面顺不顺,往往取决于你会不会用嵌套和 Outlet
很多人第一次写多页面应用,会先把目标定成"能从 A 页跳到 B 页"。
这当然没错,但这只是最低目标。
真实项目里更关键的问题通常是:
- 哪些页面共用同一套侧边栏
- 哪些页面要挂在同一个业务分区下面
- 哪些页面只是列表和详情的切换,不该整页重建公共外壳
到了这一步,嵌套路由和 Layout 基本就躲不开了。
先看一个稍微像样点的结构:
rust
use dioxus::prelude::*;
#[rustfmt::skip]
#[derive(Clone, Debug, PartialEq, Routable)]
enum Route {
#[route("/")]
Home {},
#[nest("/notes")]
#[layout(NotesLayout)]
#[route("/")]
NotesList {},
#[route("/:note_id")]
NoteDetail { note_id: usize },
#[route("/:note_id/edit")]
NoteEdit { note_id: usize },
#[end_layout]
#[end_nest]
#[route("/settings")]
Settings {},
}
fn App() -> Element {
rsx! {
Router::<Route> {}
}
}
#[component]
fn NotesLayout() -> Element {
rsx! {
div { class: "notes-shell",
aside {
h2 { "笔记系统" }
ul {
li { Link { to: Route::NotesList {}, "全部笔记" } }
li { Link { to: Route::Settings {}, "应用设置" } }
}
}
main {
Outlet::<Route> {}
}
}
}
}
这里面有 3 个角色,最好一次记住:
3.1 #[nest("/notes")] 负责公共路径前缀
说白了,这一组子路由都挂在 /notes 下面。
于是:
NotesList {}对应/notesNoteDetail { note_id: 1 }对应/notes/1NoteEdit { note_id: 1 }对应/notes/1/edit
这样写的好处是,你不会把一组强相关页面散落在各处。
3.2 #[layout(NotesLayout)] 负责公共页面壳子
它不只是普通的组件复用。
它表达的是:这一组页面在视觉结构和交互结构上,属于同一个大区块。
比如统一的:
- 侧边栏
- 顶部导航
- 面包屑
- 工具栏
- 内容区容器
这些都该进 Layout,而不是每个页面都手抄一遍。
3.3 Outlet::<Route> {} 负责把子页面塞进当前布局
这就是嵌套路由里最容易卡住、但也最关键的一块。
官方文档在 Routable 的 layout 说明里写得很直白:Layout 的子路由,会渲染到这个 Layout 组件里的 Outlet 位置。
说成人话就是:
- Layout 先把外壳搭好
- 当前命中的子页面,再填进
Outlet
你完全可以把 Outlet 理解成:
"子页面插槽"
这个点一旦没想明白,后面就很容易跑偏。要么把 Layout 当普通组件乱包,要么把同一套导航复制到每个页面里。
4. 编程式导航别神化,本质就是拿到 Navigator 然后决定 push 还是 replace
平时大部分跳转都可以用 Link,这没问题。
但业务稍微一复杂,你迟早会碰到这些场景:
- 新建成功后,跳去详情页
- 登录成功后,替换到工作台首页
- 点击"返回"时,回上一页
- 保存完成后,不想让用户再回到旧表单
这时候就该上编程式导航了。
在 Dioxus Router 0.7.9 的 API 文档里,Navigator 提供了这些常用方法:
pushreplacego_backgo_forwardcan_go_backcan_go_forward
看一个很典型的例子:
rust
use dioxus::prelude::*;
#[component]
fn CreateNoteButton() -> Element {
let nav = use_navigator();
rsx! {
button {
onclick: move |_| {
let created_id = 101usize;
nav.push(Route::NoteDetail { note_id: created_id });
},
"创建后跳到详情页"
}
}
}
这里最需要分清的是 push 和 replace。
4.1 push:适合"新增一条历史记录"
比如:
- 从列表点进详情
- 创建完成后进入详情
- 从首页进入某个功能页
这类跳转,用户通常希望还能点返回键回去。
4.2 replace:适合"当前页没有保留价值"
比如:
- 登录页跳工作台
- 引导页跳首页
- 提交成功后替换掉临时中转页
这种场景如果还用 push,用户点返回就可能回到一个本来不该回去的页面,体验会很怪。
举个例子:
rust
use dioxus::prelude::*;
#[component]
fn LoginSuccess() -> Element {
let nav = use_navigator();
rsx! {
button {
onclick: move |_| {
nav.replace(Route::Home {});
},
"进入首页"
}
}
}
所以编程式导航难点不在 API,而在于你得先想清楚:
这次跳转,是不是应该留下历史记录。
这个判断一旦养成习惯,路由体验会稳定很多。
5. 查询参数适合表达页面状态,但别把所有状态都塞进 URL
路由讲到这里,很多人下一步就会问:
"那筛选条件、tab、搜索词怎么办?"
这就轮到查询参数了。
在 Routable 派生文档里,查询段本身也是路由语法的一部分。文档里给出的形式大致有两种:
?:query?:tab&:keyword
这说明查询参数不是额外拼接的小技巧,它本来就是路由定义的一部分。
举个示意。这里我只演示"怎么把查询段表达进路由定义",具体字段组织你还是要以当前版本文档为准:
rust
use dioxus::prelude::*;
#[derive(Clone, Debug, PartialEq, Routable)]
enum Route {
#[route("/notes?:tab&:keyword")]
NotesList {
tab: String,
keyword: String,
},
}
那什么状态适合放进查询参数?
- 列表筛选条件
- 搜索关键词
- 当前选中的 tab
- 排序方式
- 分页信息
因为这些状态有一个共同点:它们天然适合被分享、刷新和回放。
比如用户把:
/notes?tab=favorite&keyword=rust
这个地址发给别人,对方打开后就该看到同样的页面筛选结果。
这就是查询参数的价值。
不过这东西也别上头。
5.1 适合进 URL 的状态
- 会影响页面结果集
- 需要刷新后仍然保留
- 需要分享给别人
- 需要让浏览器前进后退感知到
5.2 不适合进 URL 的状态
- 弹窗开没开
- 某个按钮 hover 没 hover
- 临时输入到一半的表单内容
- 只在当前组件里有意义的交互细节
很多项目后面变乱,不是因为没用查询参数,而是因为把本地 UI 状态和页面路由状态搅在一起了。
这个边界最好早点想清楚。
6. 页面组织别等项目乱了再补救,建议从一开始就按"路由、布局、页面、组件"拆开
前面这些 API 学会以后,后面差距往往就出在项目结构上。
Dioxus 很容易让人写出一种"先都塞进 main.rs,反正能跑"的结构。
能跑当然能跑。
但只要页面一多,你很快就会碰到这些问题:
Route枚举越来越长- 页面组件和通用组件混在一起
- Layout 和业务页面互相穿插
- 列表页、详情页、编辑页都堆在同一个文件
如果你这篇是准备往"真项目"方向走,我比较推荐下面这种拆法:
text
src/
├── main.rs
├── app.rs
├── router.rs
├── layouts/
│ ├── mod.rs
│ └── notes_layout.rs
├── pages/
│ ├── mod.rs
│ ├── home.rs
│ ├── settings.rs
│ └── notes/
│ ├── mod.rs
│ ├── list.rs
│ ├── detail.rs
│ └── edit.rs
└── components/
├── mod.rs
├── nav_bar.rs
├── empty_state.rs
└── note_card.rs
然后职责尽量定死一点:
6.1 router.rs
只管这几件事:
Route枚举- 路由层级
nest/layout关系- 页面入口之间的结构关系
不要把大段业务逻辑塞进这里。
6.2 layouts/
放公共页面壳子。
比如:
- 后台壳子
- 笔记模块壳子
- 登录后区域的统一框架
Layout 该关心的是整体结构,不该顺手承担某个列表页的细节业务。
6.3 pages/
放页面级组件。
判断标准很简单:这个组件是不是能被某个路由直接命中。
如果答案是能,那它就更像 page,而不是普通 component。
6.4 components/
放可复用、可被多个页面共享的小块 UI。
比如:
- 卡片
- 表格工具栏
- 空状态
- 按钮组
- 导航条
不要把"整个设置页"这种东西也放进 components/,不然后面目录名就失去意义了。
7. 从 Demo 走到项目,路由层我最建议先守住这 4 条线
最后把最容易踩坑的地方收一下。
7.1 Route 枚举是页面地图,不要一会儿字符串跳转、一会儿类型跳转
既然已经用了 Routable,就尽量统一心智。
项目里跳转目标最好都围绕 Route::Xxx {} 来写,这样你后面改路径时,成本会低很多。
7.2 Layout 是页面结构复用,不是业务逻辑垃圾桶
很多人写着写着,会把:
- 页面请求
- 列表筛选
- 弹窗状态
- 详情逻辑
全都往 Layout 里塞。
这就错位了。
Layout 适合承载公共骨架和共享导航,不适合把某个页面的细节业务也一起吞进去。
7.3 查询参数适合页面状态,不适合组件私有状态
这个前面说过,但值得再强调一次。
如果某个状态刷新后应该保留、复制地址后应该复现,那它更像路由状态;否则大概率只是本地 UI 状态。
7.4 别一上来就沉迷高级玩法
dioxus-router 还支持按路由变体做 bundle splitting。这个能力当然有用,但官方文档也写得很明白:要额外打开 wasm-split 相关 feature,还要配合 dx serve --experimental-wasm-split 或打包参数。
如果你现在还在写系列前几篇里的那种中小型应用,我建议先把:
- 路由层次
- Layout 结构
- 页面目录
- 跳转语义
这几个基础点站稳,再去碰更进阶的优化。
总结
Dioxus 的多页面开发,表面上是在学 Router,实际更像是在补两门基本功:
- 怎么把页面结构表达成类型
- 怎么把公共壳子和具体页面拆干净
这两件事理顺以后,路由这块其实就没那么绕:
Route枚举负责定义页面契约- 动态路由把路径参数直接带进类型系统
nest + layout + Outlet负责把页面层级组织起来Navigator负责编程式跳转- 查询参数负责表达可分享、可回放的页面状态
Dioxus 路由真正让我觉得舒服的地方,不是"它也能做多页面",而是页面结构会更早收敛下来。哪些页面归一组,哪些参数必须带,哪些地方该共用 Layout,都会提前写进代码里。