跳转到内容

路由工作台

前两个教程都只有一个主要组件。这个例子开始进入应用结构:外层 shell 保持挂载,右侧内容由 Outlet 根据路径切换,页面之间通过 Navigate 写入本地 history。

router demo

这个录屏由 docs/tapes/router.tape 生成,运行的是 examples/routing/router.rs。录屏会进入项目列表,打开动态详情页,再用 history 返回和前进。

cargo run --example router

123 切换页面;在项目列表里按 j/k 移动,按 Enter 打开详情;按 b/f 后退和前进;按 r 把当前历史项替换成首页;按 q 退出。

RouterProvider 持有 history;Outlet 消费当前 path;Navigate 写入新的历史项。

routes! 使用和 element! 接近的声明式语法。父路由渲染 shell,子路由交给 shell 内部的 Outlet

let routes = routes! {
    "/" => AppShell {
        "/" => OverviewPage,
        "/projects/:slug" => ProjectDetailPage,
        "/projects" => ProjectsPage,
        "/activity" => ActivityPage,
    },
};

element!(RouterProvider(
    routes: routes,
    index_path: "/",
))
.fullscreen()
.await
.expect("Failed to run the application");

动态路由放在静态前缀路由之前。/projects/:slug 要先于 /projects,否则 /projects/boreal 会先命中 /projects 这个前缀。

AppShell 注册全局导航键,并在右侧放一个 Outlet。当路径变化时,shell 不需要卸载,只有 Outlet 的子页面变化。下面摘出关键结构,省略了样式和导航列表。

#[component]
fn AppShell(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let mut navigate = hooks.use_navigate();
    let mut exit = hooks.use_exit();

    hooks.use_event_handler(EventScope::Current, EventPriority::Normal, move |event| {
        let Event::Key(key) = event else {
            return EventResult::Ignored;
        };
        if key.kind != KeyEventKind::Press {
            return EventResult::Ignored;
        }

        match key.code {
            KeyCode::Char('1') => navigate.push("/"),
            KeyCode::Char('2') => navigate.push("/projects"),
            KeyCode::Char('3') => navigate.push("/activity"),
            KeyCode::Char('b') => navigate.back(),
            KeyCode::Char('f') => navigate.forward(),
            KeyCode::Char('r') => navigate.replace("/"),
            KeyCode::Char('q') | KeyCode::Char('Q') => exit(),
            _ => return EventResult::Ignored,
        }

        EventResult::Consumed
    });

    element!(
        View(flex_direction: Direction::Horizontal) {
            Border { /* navigation */ }
            View(width: Constraint::Fill(1)) {
                Outlet
            }
        }
    )
}

这里 push 会新增历史项,replace 会覆盖当前历史项,backforward 只移动当前指针。history 的生命周期跟随 RouterProvider,不是进程级全局状态。

项目列表页把选中的项目写进路径,并用 push_with_state 携带一次导航状态:

let project = PROJECTS[selected.get()];
navigate.push_with_state(
    &format!("/projects/{}", project.slug),
    RouteNotice {
        message: format!("selected: {}", project.name),
    },
);

详情页从 path 里读 slug,再尝试读取这次导航携带的 RouteState

let slug = {
    let params = hooks.use_params();
    params.get("slug").cloned().unwrap_or_default()
};

let notice = hooks
    .try_use_route_state::<RouteNotice>()
    .map(|state| state.message.clone())
    .unwrap_or_else(|| "opened without route state".to_string());

RouteState 是“这次导航附带的上下文”,适合列表进入详情时传递选择来源、临时提示或轻量 payload。它不是全局 store,也不应该替代 URL 参数。

navigate.push(path)navigate.replace(path) 会清空当前 route state。只有 push_with_statereplace_with_state 会显式携带 state。

这个语义很重要:从带 state 的详情页跳到普通页面时,不应该把旧 state 泄漏过去。路由状态应该像导航参数一样被主动携带,而不是从上一页隐式继承。

下一步可以看 路由核心模型,那里会拆开 RouterHistoryRouteContextRoute 的边界。