Dioxus 多页面怎么做:`dioxus-router`、嵌套路由、`Outlet` 和页面组织,一篇给你讲顺

前言

上一篇我们把组件、Props 和组件通信拆完了。

接下来很自然就会碰到另一个问题:组件会写了,那多页面应用到底怎么搭?

如果你是从 React 过来的,脑子里大概已经有一套熟悉的映射:

  • Route 对应页面
  • Link 对应导航
  • Layout 包公共壳子
  • 嵌套路由负责把列表页、详情页、设置页串起来

这个理解在 Dioxus 里基本成立。

但 Dioxus Router 和很多前端路由库有个很不一样的地方:它不太鼓励你到处拼路径字符串,而是把路由收进一个有类型的 Rust 枚举里。

因为一旦路由是类型,很多平时在前端项目里拖到运行时才暴露的问题,会更早地在编译阶段就冒出来。比如:

  • 动态参数是不是漏传了
  • 参数类型是不是写错了
  • 某个页面到底归哪个 Layout 管
  • 这个跳转是不是应该 push,还是应该 replace

所以这篇不打算只讲"怎么写几个 #[route] 宏"。我更关心的是把主线捋顺:

  1. 路由枚举到底在项目里扮演什么角色
  2. Outlet 为什么是多页面结构的关键
  3. 页面目录该怎么拆,才不会后面越写越乱

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 表示,参数类型需要能从字符串解析出来。像 usizeString 这类常见类型,用起来都比较顺手。

这套写法的好处很实际:

  • 路径和参数是一套东西,不是分开的两套约定
  • 详情页的参数需求,直接体现在组件签名里
  • 你一旦少传参数、传错类型,问题会尽早暴露

如果你从 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 {} 对应 /notes
  • NoteDetail { note_id: 1 } 对应 /notes/1
  • NoteEdit { note_id: 1 } 对应 /notes/1/edit

这样写的好处是,你不会把一组强相关页面散落在各处。

3.2 #[layout(NotesLayout)] 负责公共页面壳子

它不只是普通的组件复用。

它表达的是:这一组页面在视觉结构和交互结构上,属于同一个大区块。

比如统一的:

  • 侧边栏
  • 顶部导航
  • 面包屑
  • 工具栏
  • 内容区容器

这些都该进 Layout,而不是每个页面都手抄一遍。

3.3 Outlet::<Route> {} 负责把子页面塞进当前布局

这就是嵌套路由里最容易卡住、但也最关键的一块。

官方文档在 Routablelayout 说明里写得很直白:Layout 的子路由,会渲染到这个 Layout 组件里的 Outlet 位置。

说成人话就是:

  • Layout 先把外壳搭好
  • 当前命中的子页面,再填进 Outlet

你完全可以把 Outlet 理解成:

"子页面插槽"

这个点一旦没想明白,后面就很容易跑偏。要么把 Layout 当普通组件乱包,要么把同一套导航复制到每个页面里。

平时大部分跳转都可以用 Link,这没问题。

但业务稍微一复杂,你迟早会碰到这些场景:

  • 新建成功后,跳去详情页
  • 登录成功后,替换到工作台首页
  • 点击"返回"时,回上一页
  • 保存完成后,不想让用户再回到旧表单

这时候就该上编程式导航了。

在 Dioxus Router 0.7.9 的 API 文档里,Navigator 提供了这些常用方法:

  • push
  • replace
  • go_back
  • go_forward
  • can_go_back
  • can_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 });
            },
            "创建后跳到详情页"
        }
    }
}

这里最需要分清的是 pushreplace

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,实际更像是在补两门基本功:

  1. 怎么把页面结构表达成类型
  2. 怎么把公共壳子和具体页面拆干净

这两件事理顺以后,路由这块其实就没那么绕:

  1. Route 枚举负责定义页面契约
  2. 动态路由把路径参数直接带进类型系统
  3. nest + layout + Outlet 负责把页面层级组织起来
  4. Navigator 负责编程式跳转
  5. 查询参数负责表达可分享、可回放的页面状态

Dioxus 路由真正让我觉得舒服的地方,不是"它也能做多页面",而是页面结构会更早收敛下来。哪些页面归一组,哪些参数必须带,哪些地方该共用 Layout,都会提前写进代码里。

相关推荐
用户987409238871 小时前
用 Remotion + edge-tts 打造中文教学视频全自动流水线
前端
风骏时光牛马1 小时前
Less前端工程化实战:变量混合器与项目样式分层落地
前端
假如让我当三天老蒯1 小时前
Options API(选项式 API) 和 Composition API(组合式 API)
前端·vue.js·面试
SameX1 小时前
iOS 独立开发实践:用 MapKit + 像素渲染实现 Citywalk 轨迹地图 App「雁过留痕」
前端
skyey2 小时前
页面加载时,深色模式闪白的问题解决
前端
IT_陈寒2 小时前
Java 并行流把我坑惨了,这6小时加班值了
前端·人工智能·后端
anOnion11 小时前
构建无障碍组件之Menu Button pattern
前端·html·交互设计
用户479492835691512 小时前
claude Fable用不了?把Gpt 5.5pro接到你的claude code里
前端·后端
zhangxingchao14 小时前
Kotlin常用的Flow 操作符整理
前端