Router 运行时
主线位置:更新主线的一个分支:点一个
Link,委托监听器把点击交给Runtime::handle_event,Link的闭包prevent_default后调history.pushState,再对订阅了路由的组件ReactiveContext::mark_dirty,之后和写 signal 一样进render_immediate。首屏主线上,SSR 为每个请求建一个以请求 path 为初值的MemoryHistory,Router解析失败时把ParseRouteError抛给根错误边界,render_to据此返回 404;解析成功则把最终的路由字符串写进水合数据,客户端水合时先用它而不是浏览器地址。
sequenceDiagram participant B as 浏览器 participant L as Link 闭包 participant RC as RouterContext participant H as WebHistory participant O as Outlet 和 Link B->>L: click 左键 无修饰键 L->>L: prevent_default L->>RC: push_any 目标 RC->>H: push 字符串 H->>B: pushState RC->>O: update_subscribers 标脏 O->>RC: 重跑时 current 解析 B->>H: popstate 后退 H->>O: updater 回调 标脏
路由枚举怎么解析、怎么写回字符串、render(level) 长什么样,见 Routable。本页讲运行时的几块:Router 组件和它放进 context 的东西,History 这个抽象和它的三个实现,Outlet 怎么知道自己在第几层,Link 和 navigator(),路由变了谁重跑,以及服务端的 404。
实验有两套。原生的那套依赖 dioxus =0.7.10(router、fullstack)和同版本的 dioxus-router、dioxus-history、dioxus-html(serialize),在 VirtualDom 上用 HistoryProvider 塞一个 MemoryHistory,点击用 dioxus_html::SerializedMouseData 构造后交给 Runtime::handle_event,HTML 用 dioxus_ssr::render 打印。fullstack 那套用 dx build --platform web --fullstack(dx 0.7.9,debug)编出服务端和 wasm,IP=127.0.0.1 PORT=18810 ./server 启动,用 curl --noproxy '*' 看状态码和 HTML,用无头 Chrome 加 DevTools 协议点链接。平台 M2 Max、macOS 27、rustc 1.99.0、Chrome 156。
Router:两个 context 和一个 Outlet
Router 本身只有几行:
pub fn Router<R: Routable + Clone>(props: RouterProps<R>) -> Element {
use crate::{outlet::OutletContext, RouterContext};
use_hook(|| {
provide_router_context(RouterContext::new(props.config.call(())));
});
#[cfg(feature = "streaming")]
dioxus_hooks::use_after_suspense_resolved(|| {
dioxus_fullstack_core::commit_initial_chunk();
});
use_hook(|| {
provide_context(OutletContext::<R>::new());
});
rsx! { Outlet::<R> {} }
}
它在自己的作用域里放两个 context:RouterContext(导航、当前路由、订阅者)和第 0 层的 OutletContext<R>,然后渲染一个 Outlet。它的 props 只有一个 config 回调,RouterProps 的 PartialEq 永远返回 true(注释写的是「prevent the router from re-rendering when the initial url or config changes」),父组件重跑也不会让 Router 重跑,配置只在第一次渲染时读一次。
streaming 那一段只在服务端编译:dioxus-server 依赖 dioxus-router 时打开了它的 streaming feature,客户端的 dioxus 不开。它在 Router 之下的 suspense 都完成后调 commit_initial_chunk,下面讲水合时会用到。
provide_router_context 除了在当前作用域 provide_context,还在 ScopeId::ROOT 上放一个 RootRouterContext,记下最先建出来的那个路由器。root_router() 读的就是它,所以在 Router 之外(比如它的父组件里)也能拿到路由器;router() / navigator() / use_route() 这些则是普通的 consume_context,只在 Router 的子孙里可用。
RouterContext:真值在 History 里
RouterContext 是 Copy 的句柄,里面是一个放在根作用域的 CopyValue<RouterContextInner>(generational-box)。Inner 里没有「当前路由」这个字段:
struct RouterContextInner {
unresolved_error: Option<ExternalNavigationFailure>,
subscribers: Arc<Mutex<HashSet<ReactiveContext>>>,
routing_callback: Option<AnyRoutingCallback>,
failure_external_navigation: fn() -> Element,
internal_route: fn(&str) -> bool,
site_map: &'static [SiteMapSegment],
}
当前路由每次都现读。current 先取 full_route_string,也就是 history().current_route(),再 R::from_str:
pub fn current<R: Routable>(&self) -> R {
let absolute_route = self.full_route_string();
// If this is a child route, map the absolute route to the child route before parsing
let mapping = consume_child_route_mapping::<R>();
let route = match mapping.as_ref() {
// ...
None => {
R::from_str(&absolute_route).map_err(|err| format!("Failed to parse route {err}"))
}
};
match route {
Ok(route) => route,
Err(err) => {
dioxus_core::throw_error(ParseRouteError { message: err });
"/".parse().unwrap_or_else(|err| panic!("{err}"))
}
}
}
路由器不缓存解析结果:同一次渲染里每个 Outlet、每个 use_route() 都各自解析一遍字符串。路由枚举是类型,字符串才是状态,状态放在 History 里,浏览器的地址栏、服务端的请求 path 都由它代表。
internal_route 是一个函数指针 |route| R::from_str(route).is_ok(),在 RouterContext::new 里用泛型 R 填进去:Inner 本身不带类型参数,RouterContext 才能作为一个不带泛型的 context 被任何地方取出来。GenericRouterContext<R> 是给 on_update 回调用的带类型的薄包装。
RouterContext::new 末尾还做了一件事:
// If the current route is different from the one in the browser, replace the current route
let current_route: R = myself.current();
if current_route.to_string() != history.current_route() {
myself.replace(current_route);
}
解析出来的枚举再 Display 一次,和历史记录里的字符串不一样就 replace。Routable 里看到,/user/7/ 解析后写回是 /user/7,/blog 写回是 /blog/,这里就是把地址改成「规范」写法的地方。解析失败时 current 返回 "/" 解析出的路由,于是地址也被改成 /。原生实验分别以四个 path 启动,打印 MemoryHistory::current_route() 和渲染结果(report 函数打印历史、各组件的渲染次数和 dioxus_ssr::render,下面只保留相关的行):
== rebuild at "/blog"
history.current_route() = Some("/blog/"), can_go_back = Some(false)
html: <nav>...</nav><p>blog</p>
== rebuild at "/user/7/"
history.current_route() = Some("/user/7"), can_go_back = Some(false)
html: <nav><a href="/user/7" aria-current="page">u7</a>...</nav><p>user 7</p>
== rebuild at "/user/x"
history.current_route() = Some("/"), can_go_back = Some(false)
html: <nav>...<a href="/" class="on" aria-current="page">home</a>...</nav><p>home</p>
root error boundary: Failed to parse route Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: user/x
2) Route 'User' ('/user/:id') did not match:
Dynamic segment '(id:u32)' did not match: invalid digit found in string
3) Nest 'Nest0ParseError' ('/blog') did not match:
Static segment 'blog' did not match instead found 'user'
/user/x 解析失败:错误被 throw_error 抛给最近的错误边界(这里没有,落到根上的 ROOT_ERROR_BOUNDARY),页面却照样渲染出首页,历史也被改成 /。错误边界里有没有东西,渲染器自己去看:SSR 看了,下面讲;纯客户端的 dioxus-web 主循环不看。
如果枚举里根本没有能解析 "/" 的路由,那句 unwrap_or_else(|err| panic!("{err}")) 就会 panic。一个只有 #[route("/a")] 的枚举,不给 HistoryProvider、直接在 VirtualDom 里渲染 Router:
ERROR Unable to find a history provider in the renderer. Make sure your renderer supports the Router. Falling back to the in-memory history provider.
thread 'main' (1207570) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-router-0.7.10/src/contexts/router.rs:278:50:
Route did not match:
Attempted Matches:
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
ERROR Panic while rendering component `dioxus_router::components::router::Router<nohist::R>`: Any { .. }
ERROR Error while rendering component `dioxus_router::components::router::Router<nohist::R>`: Encountered panic: Any { .. }
html:
第一行来自 history():没人提供 History 时打一条 ERROR,在根上放一个 MemoryHistory::default()(初始路由 /)。/ 解析不出来,attempted_routes 还是空的(a 这个静态节点碰到「没有下一段」时不推错误),然后 panic。core 把组件 panic 接住、记成错误,所以进程没退,页面是空的。
#3860「Return a 404 when paring the route fails」之前,current 解析失败就 panic。PR 的说明:「This PR changes the router to throw a url parsing error into the context instead of panicing when it fails to parse the current url. On the server, it changes fullstack to return a 404 status if the route is not found or a 500 status if there is another error in the root context」。它新增了 ParseRouteError 这个类型和 throw_error 那一句,并在 dioxus-server 的渲染里按它返回 404。首次进入的版本是 0.7.0(最早的 tag 是 v0.7.0-alpha.0)。
上面那条 panic 是改动留下的退路:抛完错误还得返回一个 R,它选了 "/",枚举里没有 "/" 时退路本身也失败。
History:一个 trait,三个实现
History 在单独的 dioxus-history crate 里,路由器和渲染器都依赖它、互不依赖。必须实现的只有五个方法:current_route、go_back、go_forward、push、replace;current_prefix、can_go_back / can_go_forward(默认 true)、external(默认 false,即「做不到」)、updater(默认什么都不做)、include_prevent_default(liveview 用)都有默认实现。它通过 context 传递(Rc<dyn History>),由渲染器在根上提供,HistoryProvider 组件可以在子树里换一个。
| 实现 | 在哪 | 谁提供 | current_route | push / replace | updater |
|---|---|---|---|---|---|
WebHistory | dioxus-web | 不水合时 init_document | location 的 path + search + hash,去掉前缀 | history.pushState / replaceState,state 存滚动位置 | 在 window 上挂 popstate 监听 |
HashHistory | dioxus-web | 只能经 Config::history 手动配置 | location.hash 去掉 # | 写成 pathname#路由 | 同上 |
MemoryHistory | dioxus-history | SSR 的 render_to;desktop;没人提供时的兜底 | RefCell 里的字符串 | 改字符串,维护前后两个栈 | 不实现 |
FullstackHistory<H> | dioxus-fullstack-core | provide_fullstack_history_context,服务端包 MemoryHistory,水合的客户端包 WebHistory | 水合期间读水合数据,之后转发 | 转发 | 转发 |
MemoryHistory 很直白:push 遇到和当前相同的路由直接返回,否则把旧值压进 history 栈、清空 future;replace 只改当前值。with_initial_path 里有一句 path.to_string().parse().unwrap_or_else(|err| panic!("index route does not exist: ...")),但目标类型是 String,parse 不会失败,这条 panic 文案到不了。
WebHistory 有三处值得看。new 把 history.scrollRestoration 设成 manual,再用当前地址 replaceState 一次,把滚动位置写进 state;之后每次 push 先把当前滚动位置补写进当前条目,再 pushState 新条目并滚到顶部,popstate 时从 state 里读回坐标,用 requestAnimationFrame 滚回去:浏览器原生的滚动恢复对 pushState 出来的条目不可靠,于是自己管。route_from_location 去前缀时写的是 path.strip_prefix(prefix).unwrap_or(prefix):地址不以前缀开头时,当前路由会变成前缀字符串本身。前缀来自 dioxus_cli_config::web_base_path(),也就是 dx 配置里的 base path。
updater 是 History 唯一能主动通知路由器的地方:路由器在 RouterContext::new 里把「标脏所有订阅者」的闭包交给它,WebHistory 把闭包挂到 popstate 上。pushState / replaceState 不会触发 popstate,所以程序发起的导航由路由器自己标脏(下一节),浏览器的前进后退靠 popstate。MemoryHistory 不实现 updater,它的所有变化都经过路由器,不需要。
Outlet:层号放在 context 里
Outlet 只是转发到 OutletContext::render:
pub(crate) fn render() -> Element
where
R: Routable + Clone,
{
let router = use_router_internal().expect("Outlet must be inside of a router");
let outlet: OutletContext<R> = use_outlet_context();
let current_level = outlet.level();
provide_context(outlet.next());
if let Some(error) = router.render_error() {
return if current_level == 0 {
error
} else {
VNode::empty()
};
}
router.current::<R>().render(current_level)
}
每个 Outlet 从祖先那里取 OutletContext,用它的层号去 render,再往自己的作用域里放一个层号加一的 OutletContext。Router 放的是第 0 层,所以 Router 里那个 Outlet 渲染第 0 层(最外面的 layout),layout 里的 Outlet 从 layout 的作用域往上找,找到的是上一个 Outlet 放的第 1 层,以此类推。层号和组件树的嵌套深度对齐,靠的就是 context 沿父链查找;layout 组件本身什么都不用知道。
render_error 是外部导航失败时的错误页(RouterConfig::failure_external_navigation,默认组件是 FailureExternalNavigation),只在第 0 层渲染。注意 provide_context 在每次渲染时都会调,不是放在 use_hook 里,同一个值重复放进同一个作用域。Outlet 的文档注释说它「Only works as descendant of a [Link] component」,应为 Router。
路由变了,谁重跑
full_route_string 里有一行 inner.subscribe_to_current_context():
fn subscribe_to_current_context(&self) {
if let Some(rc) = ReactiveContext::current() {
rc.subscribe(self.subscribers.clone());
}
}
谁在渲染时读了当前路由,谁的 ReactiveContext 就进了 subscribers 集合。读路由的地方有:每个 Outlet(经 current)、每个 Link(要比较 href 和当前地址)、调 use_route() / router().current() 的用户组件。路由变化时,change_route 先跑 on_update 回调(它可以返回一个新目标,用 replace 覆盖),然后:
self.inner.read().update_subscribers();
update_subscribers 对集合里每个 ReactiveContext 调 mark_dirty。这和 signal 写入之后的那一步完全一样(见一次点击),之后就是调度器按高度重跑这些作用域。路由器没有用 signal 存路由,是因为真值在 History 里,浏览器的后退不经过 Rust 的写入;订阅集合加上 updater 回调,两条来路(程序 push、浏览器 popstate)都能通知到同一批订阅者。
原生实验:Shell 是 layout,里面有四个 Link(u7 指向 User { id: 7 },home 带 active_class: "on",ext 是外链,str 是字符串 "/no/such/page")和两个按钮(navigator().push(Route::BlogIndex {})、navigator().go_back()),每个组件进来时给自己的计数器加一。从 / 开始,点 u7,等 wait_for_work 后 render_immediate,打印这一轮的指令:
click listeners: [2, 3, 4, 6, 8, 10]
== rebuild at "/"
history.current_route() = Some("/"), can_go_back = Some(false)
renders: Shell=1 Home=1 User=0 BlogIndex=0
html: <nav><a href="/user/7">u7</a><a href="/" class="on" aria-current="page">home</a><a href="https://example.com" rel="noopener noreferrer">ext</a><a href="/no/such/page" rel="noopener noreferrer">str</a><button>blog</button><button>back</button></nav><p>home</p>
prevent_default after Link click: default_action_enabled = false
--- edits after clicking Link to User 7: 7 edits
SetAttribute class=None id=8
SetAttribute aria-current=None id=8
SetAttribute aria-current=Text("page") id=10
LoadTemplate <p> index=0 id=13
CreateTextNode "user 7" id=14
ReplacePlaceholder path=[0] m=1
ReplaceWith id=12 m=1
== after Link click
history.current_route() = Some("/user/7"), can_go_back = Some(true)
renders: Shell=1 Home=1 User=1 BlogIndex=0
html: <nav><a href="/user/7" aria-current="page">u7</a><a href="/">home</a><a href="https://example.com" rel="noopener noreferrer">ext</a><a href="/no/such/page" rel="noopener noreferrer">str</a><button>blog</button><button>back</button></nav><p>user 7</p>
== after navigator().push(BlogIndex)
history.current_route() = Some("/blog/"), can_go_back = Some(true)
renders: Shell=1 Home=1 User=1 BlogIndex=1
html: <nav><a href="/user/7">u7</a><a href="/">home</a><a href="https://example.com" rel="noopener noreferrer">ext</a><a href="/no/such/page" rel="noopener noreferrer">str</a><button>blog</button><button>back</button></nav><p>blog</p>
== after navigator().go_back()
history.current_route() = Some("/user/7"), can_go_back = Some(true)
renders: Shell=1 Home=1 User=2 BlogIndex=1
html: <nav><a href="/user/7" aria-current="page">u7</a><a href="/">home</a><a href="https://example.com" rel="noopener noreferrer">ext</a><a href="/no/such/page" rel="noopener noreferrer">str</a><button>blog</button><button>back</button></nav><p>user 7</p>
Link 是组件,它的 a 在 Shell 的模板之后按动态节点倒序创建,所以 id 是 u7=10、home=8、ext=6、str=4,两个按钮是 2、3。
7 条指令里,前三条来自两个 Link:home 不再是当前页,去掉 class 和 aria-current;u7 加上 aria-current。后四条来自第二层的 Outlet:旧的 <p>home</p> 换成新建的 User 的 <p>user 7</p>。Shell 没有重跑:它没读路由,第 0 层 Outlet 虽然重跑了,render(0) 还是 Shell {},没有 props 的组件 memoize 命中(#[component] 与 Props)。go_back 之后 User 是第二次渲染:中间切到过 BlogIndex,User 已经卸载,回来是新建。
/ 依次点 u7、blog 按钮、back 按钮,Shell 一共渲染了几次?Link:什么时候拦,什么时候放
Link 渲染一个普通的 a,href 是目标的字符串(内部路由前面加 history.current_prefix()),所以不靠 wasm 也是一个能点的链接,SSR 出来的 HTML 也一样。拦截在 onclick 里:
let action = move |event: MouseEvent| {
// Only handle events without modifiers
if !event.modifiers().is_empty() {
return;
}
// Only handle left clicks
if event.trigger_button() != Some(dioxus_elements::input_data::MouseButton::Primary) {
return;
}
// If we need to open in a new tab, let the browser handle it
if new_tab {
return;
}
// todo(jon): this is extra hacky for no reason - we should fix prevent default on Links
if do_default && is_external {
return;
}
event.prevent_default();
if do_default && is_router_nav {
router.push_any(to.clone());
}
if let Some(handler) = onclick {
handler.call(event);
}
};
do_default 是 onclick.is_none() || !onclick_only。按顺序:带 Ctrl/Shift/Alt/Meta 的点击、非左键、new_tab(渲染成 target="_blank")都直接返回,不 prevent_default,交给浏览器开新标签或新窗口;外链也返回,交给浏览器整页跳转;剩下的先 prevent_default,内部路由就 push_any,最后调用户的 onclick。onclick_only: true 加上一个 onclick 时,只拦下默认跳转、调用户回调,不导航。prevent_default 是在同步回调里调的,在 web 上能生效(异步回调里不行,见事件)。外链默认加 rel="noopener noreferrer";aria-current="page" 和 active_class 都按 href == 当前地址 的字符串相等来判断。
外链还是内部路由,由 to 的类型决定。传枚举值是内部路由;传字符串时走 From<&str> for NavigationTarget:
impl From<&str> for NavigationTarget {
fn from(value: &str) -> Self {
match try_router() {
Some(router) => match router.internal_route(value) {
true => NavigationTarget::Internal(value.to_string()),
false => NavigationTarget::External(value.to_string()),
},
None => NavigationTarget::External(value.to_string()),
}
}
}
字符串能被当前路由枚举解析,就是内部路由;解析不了,哪怕是 /no/such/page 这样的站内路径,也当外链处理:渲染时带 rel="noopener noreferrer",点击不拦截,浏览器整页请求,由服务端回 404。上面原生实验里 str 那个 a 就是这样。
浏览器里的实测:fullstack 构建、页面从 /user/7/ 打开,等 #u7 带上 data-dioxus-id(水合完成),在 window 上挂一个冒泡阶段的 click 监听:它在 dioxus 挂在 #main 上的委托监听器之后运行,记下 defaultPrevented,然后自己 preventDefault,免得外链真的跳走。用 dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) 逐个点击,每步之后打印地址、history.length、#page 的文字、带 aria-current 的链接:
hydrated: {"url":"/user/7/","len":2,"page":"user 7","current":"","prevented":[]}
click #home: {"url":"/","len":3,"page":"home","current":"home","prevented":["home:true"]}
ctrl+click #u7: {"url":"/","len":3,"page":"home","current":"home","prevented":["u7:false"]}
click #u7: {"url":"/user/7","len":4,"page":"user 7","current":"u7","prevented":["u7:true"]}
history.back(): {"url":"/","len":4,"page":"home","current":"home","prevented":[]}
click #ext: {"url":"/","len":4,"page":"home","current":"home","prevented":["ext:false"]}
click #newtab: {"url":"/","len":4,"page":"home","current":"home","prevented":["newtab:false"]}
点内部链接:被拦下,history.length 加一,没有新的文档请求(从水合完成到实验结束,网络日志里只有一次 /favicon.ico)。Ctrl 点击、外链、new_tab 都没有被拦下。history.back() 走的是 popstate,页面回到 home。第一行 current 是空的,原因在下面「水合」一节。
navigator() 和 RouterContext 的导航方法
navigator() 返回 Navigator(RouterContext),方法全部转发给 RouterContext;use_navigator() 只是把它放进 use_hook。push / replace 的结构一样:
pub fn push(&self, target: impl Into<NavigationTarget>) -> Option<ExternalNavigationFailure> {
let target = target.into();
{
let mut write = self.inner.write_unchecked();
match target {
NavigationTarget::Internal(p) => {
let history = history();
history.push(p)
}
NavigationTarget::External(e) => return write.external(e),
}
}
self.change_route()
}
内部路由交给 History::push,再 change_route 标脏订阅者;外部地址交给 History::external:WebHistory 设 location.href 整页跳走,MemoryHistory 用默认实现返回 false,路由器把失败记进 unresolved_error,第 0 层 Outlet 改为渲染 failure_external_navigation 组件,返回值是 Some(ExternalNavigationFailure)。在 MemoryHistory 上用一个按钮调 navigator().push("https://example.com/"),点击前后的 SSR 输出:
before: <button>go</button>
push returned Some(ExternalNavigationFailure("https://example.com/"))
after: <h1>External Navigation Failure!</h1><p>The application tried to programmatically navigate to an external page. This operation has failed. Click the link below to complete the navigation manually.</p><a>Click here to go back</a>
整个页面被换成了默认的失败组件(它的「Click here to go back」只是调 clear_error)。
go_back / go_forward 调完 History 之后也立刻 change_route。在 web 上,history.back() 按 HTML 规范是异步的,change_route 标脏的那一刻地址可能还没变;真正读到新地址的那次重跑由随后的 popstate 触发。
服务端:MemoryHistory、404 和状态码
SSR 的 render_to 为每个请求建 VirtualDom 时(ssr.rs,整体流程见一次请求的渲染),先建历史:
let history = if let Some(base_path) = base_path() {
let base_path = base_path.trim_matches('/');
let base_path = format!("/{base_path}");
let route = route.strip_prefix(&base_path).unwrap_or(&route);
dioxus_history::MemoryHistory::with_initial_path(route).with_prefix(base_path)
} else {
dioxus_history::MemoryHistory::with_initial_path(&route)
};
route 是请求的 path 加 query,base path 被剥掉、记成前缀(Link 渲染 href 时再加回去)。然后在根作用域里 provide_fullstack_history_context(history)。渲染等完 suspense 后看根错误边界,按错误类型挑状态码,其中一条是:
// If there was an error while routing, return the error with a 404 status
// Return a routing error if any of the errors were a routing error
if let Some(routing_error) = error.downcast_ref::<ParseRouteError>().cloned() {
status_code = Some(StatusCode::NOT_FOUND);
out_message = Some(routing_error.to_string());
}
Router 抛出的 ParseRouteError 就落在这里。有状态码时整页不渲染,响应体是 out_message(server.rs 里 Response::builder().status(status).body(..))。对实验服务(路由只有 / 和 /user/:id,外面一层 layout)curl -i:
== /
HTTP/1.1 200 OK
== /user/7
HTTP/1.1 200 OK
== /user/7/
HTTP/1.1 200 OK
== /user/x
HTTP/1.1 404 Not Found
== /nope
HTTP/1.1 404 Not Found
/user/x 的完整响应:
HTTP/1.1 404 Not Found
content-length: 259
date: Wed, 28 Oct 2026 11:54:39 GMT
Failed to parse route Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: user/x
2) Route 'User' ('/user/:id') did not match:
Dynamic segment '(id:u32)' did not match: invalid digit found in string
响应体是纯文本的路由匹配过程,没有 content-type,也没有页面框架。原生实验里同样的失败渲染出了首页,这里那份 HTML 被丢弃了,因为错误边界里有错误。
想要一个自己的 404 页面,通常是在枚举末尾加一个 catch-all 路由。加上 #[route("/:..rest")] NotFound { rest: Vec<String> } 重新构建:
== /nope/x
HTTP/1.1 200 OK
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["nope", "x"]
== /user/x
HTTP/1.1 200 OK
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["user", "x"]
现在每个 URL 都能解析,没有 ParseRouteError,状态码回到 200:页面说「找不到」,HTTP 说「成功」,搜索引擎会把它当正常页面收录。状态码要在组件里自己设,FullstackContext::commit_http_status 写的是 render_to 在没有错误时读的那个状态:
#[component]
fn NotFound(rest: Vec<String>) -> Element {
dioxus::fullstack::FullstackContext::commit_http_status(dioxus::fullstack::StatusCode::NOT_FOUND, None);
rsx! { p { id: "page", "not found: {rest:?}" } }
}
HTTP/1.1 404 Not Found
content-type: text/html; charset=utf-8
age: 0
<p id="page" data-node-hydration="0"><!--node-id1-->not found: ["nope", "x"]
这次是带完整页面的 404。在客户端 FullstackContext::current() 返回 None,这一行什么都不做。
水合:初始路由从服务端带过来
服务端和客户端用同一个 FullstackHistory 包装(impl History for FullstackHistory),水合的客户端由 dioxus-web 的主循环在 server_data.in_context 里调 init_fullstack_document 装上,包的是 WebHistory。它和被包的历史只差两处:
fn match_hydration<O>(
during_hydration: impl FnOnce() -> O,
after_hydration: impl FnOnce() -> O,
) -> O {
if is_hydrating() {
let update = schedule_update();
queue_effect(move || update());
during_hydration()
} else {
after_hydration()
}
}
fn current_route(&self) -> String {
match_hydration(|| self.initial_route(), || self.history.current_route())
}
水合期间(客户端在 HydrationContext 里 rebuild 的那一段;服务端 is_hydrating() 恒为真),current_route 返回 initial_route():先读水合数据里的路由条目,读不到才问被包的历史;can_go_back / can_go_forward 返回 false。同时给当前作用域排一次「水合之后再渲染一遍」。这样客户端第一次渲染用的是服务端渲染时的路由,两边的树一致;水合结束后,那些读过路由的作用域按真实地址再跑一次。
路由条目是怎么写进水合数据的:provide_fullstack_history_context 在两边同一个时刻 create_entry() 占一个位置(所以必须在两边以相同顺序调用,注释也这么说);服务端 Router 的 suspense 都完成后,commit_initial_chunk 调 finalize_route,把当时的 history.current_route() 写进去。RouterContext::new 那次规范化的 replace 发生在这之前,所以写进去的是规范后的路由。对比两个页面的 initial_dioxus_hydration_data:
/ g4EY9oIYYRgv9g== 10 字节 83 81 18 f6 82 18 61 18 2f f6
/user/7 g4EY9ogYZxgvGHUYcxhlGHIYLxg39g== 22 字节 83 81 18 f6 88 18 67 18 2f 18 75 18 73 18 65 18 72 18 2f 18 37 f6
(第二列是页面里的原文,后面是 base64 解码后的字节。)61 2f 和 67 2f 75 73 65 72 2f 37 是 CBOR 文本 "/" 和 "/user/7",被按字节逐个编码成一个 u8 数组放进条目;debug 构建页面里的 initial_dioxus_hydration_debug_locations 对这一条记的位置是 dioxus-fullstack-core-0.7.10/src/history.rs:44:30,就是 finalize_route 里那句 entry.insert。请求 /user/7/ 得到的水合数据和 /user/7 逐字节相同。数据格式本身见水合。
这就解释了上面浏览器实验第一行的 "current":""。服务端把 /user/7/ 规范成 /user/7,渲染出的 u7 链接带 aria-current="page"(curl 看得到);客户端水合时用的也是 /user/7,和 Display 相同,RouterContext::new 不 replace,地址栏仍是 /user/7/。水合之后排好的那次重渲染里,Link 改用 WebHistory 的真实地址 /user/7/ 去比,和 href="/user/7" 不相等,aria-current 被去掉。同一个页面从 /user/7 打开,第一行是 {"url":"/user/7","len":5,"page":"user 7","current":"u7","prevented":[]}。带尾斜杠打开的页面,导航高亮在水合后消失,直到下一次站内跳转。
动脑筋答案
把 report 在三步之后打印的 renders 行摘出来:
== after Link click
renders: Shell=1 Home=1 User=1 BlogIndex=0
== after navigator().push(BlogIndex)
renders: Shell=1 Home=1 User=1 BlogIndex=1
== after navigator().go_back()
renders: Shell=1 Home=1 User=2 BlogIndex=1
一次,就是首次渲染那一次。三次导航都标脏了订阅者:两个 Outlet 和四个 Link。第 0 层 Outlet 每次重跑都返回 Shell {},Shell 没有 props,比较相等,不重跑;按钮和链接所在的 nav 是 Shell 模板的一部分,Link 作为子组件自己重跑。真正换掉的只有第 1 层 Outlet 下的页面组件。
小结
Router在自己的作用域放RouterContext和第 0 层OutletContext,props 永远相等,配置只读一次。- 当前路由不缓存:每次
current()都从History取字符串再解析;启动时解析结果写回的字符串和历史不同就replace,解析失败抛ParseRouteError并退回"/",枚举里没有"/"时 panic。 History是渲染器提供的 context:web 是pushState加popstate,SSR 是每请求一个MemoryHistory,fullstack 外面再包一层,水合期间读服务端写进水合数据的路由,水合后对读过路由的作用域补渲染一次。Outlet的层号放在 context 里,每层加一;读过路由的作用域进订阅集合,导航时逐个mark_dirty,没读路由的 layout 不重跑。Link只拦无修饰键的左键点击;外链、new_tab、解析不了的字符串目标都交给浏览器整页跳转。- 路由不匹配时 SSR 回 404,响应体是纯文本的匹配过程;用 catch-all 做 404 页面会变成 200,要自己
commit_http_status。