Rust in Everything
基于 dioxus 0.7.10

站内跳转整页刷新,状态丢了

主线位置:更新主线的入口。一次点击进了根上的委托监听器,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 按值比较。

💬 关联讨论

发起讨论
加载中...