站内跳转整页刷新,状态丢了
主线位置:更新主线的入口。一次点击进了根上的委托监听器,
Link的onclick同步prevent_default再push路由,后面就是普通的标脏、重渲染;没被拦下的点击则离开更新主线,变成一次新的页面请求,从首屏主线重新走一遍。Link什么时候拦、什么时候放,见Router 运行时的「Link:什么时候拦,什么时候放」。
flowchart TB
clickev["点击站内链接"] --> kind{"怎么写的"}
kind -->|"a 加 href"| full["浏览器默认行为"]
kind -->|"Link,to 是枚举"| spa["prevent_default 后 push"]
kind -->|"Link,to 是字符串"| parse{"路由能解析吗"}
parse -->|能| spa
parse -->|不能| ext["当外链,不拦"]
ext --> full
full --> reload["整页请求,wasm 重载"]
reload --> lost["signal 全部归零"]
spa --> keep["状态保留"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建的 fullstack 应用。headless Chrome 经 DevTools Protocol 打开页面,记录类型为 Document 的网络请求。
现象
/links 下两个路由共用一个布局,布局里有一个计数器和几种链接:
#[derive(Routable, Clone, Debug, PartialEq)]
enum Route {
// ...
#[nest("/links")]
#[layout(LinkShell)]
#[route("/")]
LinksA {},
#[route("/b")]
LinksB {},
#[end_layout]
#[end_nest]
#[route("/")]
Home {},
#[route("/user/:id")]
User { id: u32 },
}
#[component]
fn LinkShell() -> Element {
let mut count = use_signal(|| 0);
// fix for the highlight: compare parsed routes, not strings
let route = use_route::<Route>();
let on_a = route == Route::LinksA {};
let on_b = route == Route::LinksB {};
rsx! {
div { id: "counter", "count = {count}" }
div { id: "route", "use_route = {route:?}, on_a = {on_a}, on_b = {on_b}" }
button { id: "inc", onclick: move |_| count += 1, "+" }
nav {
a { id: "raw", href: "/links/b", "a href" }
Link { id: "enum", to: Route::LinksB {}, "Link enum" }
Link { id: "str", to: "/links/b", "Link str" }
Link { id: "badstr", to: "/user/x", "Link str, no route" }
Link { id: "home", to: Route::LinksA {}, "Link A" }
}
Outlet::<Route> {}
}
}
脚本每次重新打开 /links,在 window 上放一个标记,点一次 #inc 让计数变成 1,再点一个链接,然后打印:地址、这期间的文档请求、标记还在不在、计数、页面、带 aria-current 的链接(route 一栏下面「修法」再看)。release 构建:
hrefs: raw href=/links/b | enum href=/links/b | str href=/links/b | badstr href=/user/x rel=noopener noreferrer | home href=/links/
open /links, click #inc: {"url":"/links","docRequests":["/links"],"marker":"same document","counter":"count = 1","page":"page A","current":"","route":"use_route = LinksA, on_a = true, on_b = false"}
click #raw: {"url":"/links/b","docRequests":["/links/b"],"marker":null,"counter":"count = 0","page":"page B","current":"enum,str","route":"use_route = LinksB, on_a = false, on_b = true"}
open /links, click #inc: {"url":"/links","docRequests":["/links"],"marker":"same document","counter":"count = 1","page":"page A","current":"","route":"use_route = LinksA, on_a = true, on_b = false"}
click #enum: {"url":"/links/b","docRequests":[],"marker":"same document","counter":"count = 1","page":"page B","current":"enum,str","route":"use_route = LinksB, on_a = false, on_b = true"}
open /links, click #inc: {"url":"/links","docRequests":["/links"],"marker":"same document","counter":"count = 1","page":"page A","current":"","route":"use_route = LinksA, on_a = true, on_b = false"}
click #str: {"url":"/links/b","docRequests":[],"marker":"same document","counter":"count = 1","page":"page B","current":"enum,str","route":"use_route = LinksB, on_a = false, on_b = true"}
open /links, click #inc: {"url":"/links","docRequests":["/links"],"marker":"same document","counter":"count = 1","page":"page A","current":"","route":"use_route = LinksA, on_a = true, on_b = false"}
click #badstr: {"url":"/user/x","docRequests":["/user/x"],"marker":null,"current":""}
open /links/, click #inc: {"url":"/links/","docRequests":["/links/"],"marker":"same document","counter":"count = 1","page":"page A","current":"home","route":"use_route = LinksA, on_a = true, on_b = false"}
open /links/b/, click #inc: {"url":"/links/b/","docRequests":["/links/b/"],"marker":"same document","counter":"count = 1","page":"page B","current":"","route":"use_route = LinksB, on_a = false, on_b = true"}
a { href }:浏览器发了一次/links/b的文档请求,window上的标记没了,计数回到 0。整页重新加载,wasm 重新下载、初始化,所有 signal 从初值开始。Link配枚举、Link配能解析的字符串:没有文档请求,标记和计数都在。Link配解析不了的字符串(/user/x,id要求u32):渲染出来带rel="noopener noreferrer",点击是整页请求,服务端回的是 404 纯文本。- 高亮:从
/links打开时,指向Route::LinksA {}的#home没有aria-current,因为它的href是/links/;从/links/b/打开时,#enum、#str都没有高亮。
同样四个地址用 curl 看服务端渲染的 HTML,高亮全都是对的:
== curl /links
<div id="route" data-node-hydration="2"><!--node-id3-->use_route = LinksA, on_a = true, on_b = false<!--#--></div>
<a href="/links/" aria-current="page" id="home" data-node-hydration="12,click:1">
== curl /links/
<div id="route" data-node-hydration="2"><!--node-id3-->use_route = LinksA, on_a = true, on_b = false<!--#--></div>
<a href="/links/" aria-current="page" id="home" data-node-hydration="12,click:1">
== curl /links/b
<div id="route" data-node-hydration="2"><!--node-id3-->use_route = LinksB, on_a = false, on_b = true<!--#--></div>
<a href="/links/b" aria-current="page" id="enum" data-node-hydration="6,click:1">
<a href="/links/b" aria-current="page" id="str" data-node-hydration="8,click:1">
== curl /links/b/
<div id="route" data-node-hydration="2"><!--node-id3-->use_route = LinksB, on_a = false, on_b = true<!--#--></div>
<a href="/links/b" aria-current="page" id="enum" data-node-hydration="6,click:1">
<a href="/links/b" aria-current="page" id="str" data-node-hydration="8,click:1">
也就是说高亮在水合之后才消失。
出处
整页刷新。 a { href } 只是一个普通元素,dioxus 不拦它的点击,浏览器执行默认行为。Link 在 onclick 里同步调 prevent_default 再 push_any,但外链、带修饰键的点击、new_tab 会先 return,交给浏览器。字符串的 to 走 From<&str> for NavigationTarget:当前路由枚举能解析就是 Internal,解析不了就是 External,哪怕它是一个站内路径。/user/x 就是这样被当成了外链。
高亮。 Link 判断「当前」是字符串比较:href == current_url,current_url 来自 RouterContext::full_route_string,也就是 history 里的当前地址;href 是目标路由 Display 出来的字符串。
Route::LinksA {}是#[nest("/links")]下的#[route("/")],Display出来是/links/;地址栏是/links时两个字符串不等。- 地址栏是
/links/b/时,路由能解析成LinksB,Display却是/links/b,同样不等。
服务端渲染和水合那一次用的是水合数据里带过来的、规范化之后的路由,所以 curl 看到的都高亮;水合之后排好的那次重渲染改用浏览器的真实地址去比,高亮就掉了(机制见Router 运行时的「水合:初始路由从服务端带过来」)。
修法
站内跳转一律用 Link,to 写路由枚举。 写枚举有两个好处:路径和参数类型由编译器检查,拼错了编译不过;to 一定是 Internal,一定被拦下。字符串的 to 留给真正的外链。要用程序跳转时,用 navigator().push(Route::LinksB {}),同样传枚举。
确实要整页加载的站内地址(服务端的下载接口、axum 自己的路由、不在路由枚举里的静态页面),继续用 a { href } 或字符串 to,那是对的;只是要知道它们会让 wasm 重新加载。
导航高亮不要依赖 Link 的字符串比较,用 use_route 拿到解析后的路由,按值比较。上面的 on_a / on_b 在每一行里都和页面一致,包括 /links 和 /links/b/ 两种地址:
let route = use_route::<Route>();
let on_a = route == Route::LinksA {};
let on_b = route == Route::LinksB {};
拿到布尔值以后自己设 class 和 aria-current。带参数的路由可以用 matches!(route, Route::User { .. }) 判断「在这一组页面里」。
怎么确认没中招
- 搜
a {里href: "/开头的站内地址,和Link里to: "/开头的字符串,逐个确认是不是有意整页加载。 - 浏览器里看渲染出来的
Link:站内链接带着rel="noopener noreferrer",说明to没解析成路由,被当成了外链。 - 开发者工具的网络面板按
Doc过滤,在站内点一圈:除了第一次加载,不应该再出现文档请求。也可以像实验那样在window上放一个标记,点完看它还在不在。 - 分别带尾斜杠、不带尾斜杠打开导航里的每个页面,看高亮在水合后是不是还在。
小结
a { href }不被 dioxus 拦截,站内跳转也是整页请求,wasm 重载,所有 signal 归零。Link的字符串to解析不了路由时当外链处理,同样整页跳转,渲染时还会带上rel="noopener noreferrer"。Link的高亮是href和当前地址的字符串比较:嵌套路由的根Display带尾斜杠,带尾斜杠打开的地址和Display不等,高亮在水合后消失。- 修法:
Link配路由枚举;高亮用use_route按值比较。