Rust in Everything
基于 dioxus 0.7.10

水合时两边渲染得不一样

主线位置:首屏主线的最后一步,客户端 rebuild + rehydrate。客户端在水合上下文里把同一棵树再建一遍,按位置从水合数据里取值、按编号认领服务端的节点。槽位和节点编号怎么对应,见水合;本页只讲两边第一次渲染不一致时,用户看到什么、怎么避免。

flowchart TB
  diff{"两边哪里不一样"} -->|hook 顺序,类型不同| t1["槽位反序列化失败"]
  t1 --> t2["组件挂起,树变小"]
  t2 --> t3["hydrate_node 抛 TypeError"]
  t3 --> dead["页面不再响应"]
  diff -->|hook 顺序,类型相同| s1["两个值互换"]
  s1 --> s2["第一次重渲染时才显出来"]
  diff -->|只有文本| x1["保留服务端的文本"]
  x1 --> x2["第一次重渲染时改掉"]

本页实验在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建。浏览器一侧用 headless Chrome,经 DevTools Protocol 记录网络和控制台,等 #inc 拿到 data-dioxus-id 后读 #out,点一次 #inc 再读。

现象

前两个页面在 cfg 下故意让服务端和客户端以相反的顺序调用两个 use_server_future。num、word 是两个 #[get] server function,返回 Ok(1) 和 Ok("two".to_string());sleep_ms(ms) 睡 ms 毫秒后返回 Ok(ms)。第三个页面渲染的文本在两边不同,相当于渲染时读了时间、随机数或 window:

#[component]
fn Swap() -> Element {
  // server: i32 then String; client: String then i32
  #[cfg(feature = "server")]
  let (n, w) = {
    let n = use_server_future(num)?;
    let w = use_server_future(word)?;
    (n, w)
  };
  #[cfg(not(feature = "server"))]
  let (n, w) = {
    let w = use_server_future(word)?;
    let n = use_server_future(num)?;
    (n, w)
  };
  rsx! {
    p { id: "out", "n = {n():?}, w = {w():?}" }
    button { id: "inc", onclick: move |_| {}, "+" }
  }
}

#[component]
fn Swap2() -> Element {
  // same type on both slots, opposite order on the client
  let mut clicks = use_signal(|| 0);
  #[cfg(feature = "server")]
  let (a, b) = {
    let a = use_server_future(|| sleep_ms(10))?;
    let b = use_server_future(|| sleep_ms(20))?;
    (a, b)
  };
  #[cfg(not(feature = "server"))]
  let (a, b) = {
    let b = use_server_future(|| sleep_ms(20))?;
    let a = use_server_future(|| sleep_ms(10))?;
    (a, b)
  };
  rsx! {
    p { id: "out", "a = {a():?}, b = {b():?}, clicks = {clicks}" }
    button { id: "inc", onclick: move |_| clicks += 1, "+" }
  }
}

#[component]
fn TextDiff() -> Element {
  let mut clicks = use_signal(|| 0);
  // differs between the two renders, like a clock or a random number
  let side = if cfg!(feature = "server") { "server" } else { "client" };
  rsx! {
    p { id: "out", "rendered on {side}, clicks = {clicks}" }
    button { id: "inc", onclick: move |_| clicks += 1, "+" }
  }
}

release 构建的输出(wasm-bindgen 的堆栈删掉了):

=== /swap (release)
[net] GET /swap
[navigated] /swap
[net] GET /assets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js
[net] GET /assets/exp-dioxus-pit3-fs_bg-dxh92effe0b0b26b1b.wasm
[navigatedWithinDocument] /swap
[console.log] ERROR ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-fullstack-core-0.7.10/src/transport.rs:306 Error deserializing data: Semantic(None, "invalid type: integer `1`, expected string")
[net] GET /api/word
[console.error] wasm-bindgen: imported JS function that was not marked as `catch` threw an error: Cannot read properties of undefined (reading 'toString')
...
[exception] TypeError: Cannot read properties of undefined (reading 'toString')
(#inc never got data-dioxus-id)
listeners without data-dioxus-id: 1
#out after hydration: <p id="out" data-node-hydration="0"><!--node-id1-->n = Some(Ok(1)), w = Some(Ok("two"))<!--#--></p>
#out after click: <p id="out" data-node-hydration="0"><!--node-id1-->n = Some(Ok(1)), w = Some(Ok("two"))<!--#--></p>
=== /swap2 (release)
...
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">a = Some(Ok(10)), b = Some(Ok(20)), clicks = 0</p>
#out after click: <p id="out" data-node-hydration="0">a = Some(Ok(20)), b = Some(Ok(10)), clicks = 1</p>
=== /textdiff (release)
...
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">rendered on server, clicks = 0</p>
#out after click: <p id="out" data-node-hydration="0">rendered on client, clicks = 1</p>

(listeners without data-dioxus-id 是水合后仍没认领到 id 的带监听器元素个数。)

  • 类型不同(/swap):控制台先报一条 Error deserializing data,接着发出 GET /api/word,然后是 TypeError。页面停在服务端发来的样子,<!--node-id1--> 注释都还在,按钮没有 data-dioxus-id,点了没反应。
  • 类型相同(/swap2):没有任何报错,也没有请求。水合后页面上还是服务端写的「a = 10, b = 20」;第一次点击让组件重跑,两个数换了位置。
  • 只有文本不同(/textdiff):水合后仍显示「rendered on server」,第一次重渲染才变成「rendered on client」。

三种都没有一条消息直接说「两边不一致」。

出处

水合数据是一串按位置编号的槽位,每个 use_server_future 在第一次运行时占下一个;客户端按自己的调用顺序依次取(水合的「HydrationContext:一串按位置编号的槽位」)。

  • /swap 的客户端第一个调用是 word,取到的是服务端 num 的槽位,按 Result<String, ServerFnError> 解失败,SerializeContextEntry::get 打出 transport.rs:306 那条日志(debug 构建拿得到类型名和写入位置时,打的是同一个函数里更长的那条,带服务端和客户端两边的类型与位置)。use_server_future 对「取不到」的处理是自己重新请求,所以有 GET /api/word;请求要走网络,future 第一次 poll 是 Pending,组件挂起,客户端的树里没有 <p> 和按钮。rehydrate 照客户端的树收集节点 id,比服务端的编号少,hydrate_node 给带监听器的按钮写 data-dioxus-id 时 id 是 undefined,id.toString() 抛出 TypeError。异常从 wasm-bindgen 的导入函数里抛出,没被捕获,打断了 web 主循环。
  • /swap2 两个槽位类型相同,反序列化都成功,值就互换了。水合不比较内容,所以要等组件下一次重跑,文本才按客户端的值重写。
  • /textdiff 的结构一样,节点都能认领;水合跳过所有 DOM 修改(WebsysDom.skip_mutations),服务端的文本留在页面上。客户端的 VNode 里存的是「rendered on client」,下一次 diff 时发现 clicks 变了,整段动态文本按客户端的值重写。

会让 hook 顺序不一致的常见写法:use_server_future 放在依赖 cfg!(feature = "server")、window、时间、随机数的 if 里;只在一边渲染的组件里调 use_server_future;两边渲染出不同的组件树(不同的树意味着不同的建树顺序)。首屏用 use_resource(..).suspend()? 也走到同一个 TypeError,见首屏数据用了 use_resource。

修法

hook 一律无条件调用,顺序两边相同。 服务端和客户端要做不同的事,把分支放进 future 里,而不是放在 hook 外面:

// both sides call the same hooks in the same order
let n = use_server_future(num)?;
let w = use_server_future(word)?;

num / word 本来就是 server function,客户端调用时自动变成请求,不需要 cfg。

第一次渲染只用两边都有的数据。 只有浏览器才有的值(时间、随机数、window 的尺寸、localStorage),第一次渲染时用一个两边相同的初值,水合之后在 use_effect 里换掉。effect 在水合完成后才跑(只在 wait_for_work 里执行),这时 DOM 已经被认领:

#[component]
fn TextFix() -> Element {
  let mut clicks = use_signal(|| 0);
  // same value on both sides during hydration, switch after it
  let mut side = use_signal(|| "server");
  use_effect(move || side.set("client"));
  rsx! {
    p { id: "out", "rendered on {side}, clicks = {clicks}" }
    button { id: "inc", onclick: move |_| clicks += 1, "+" }
  }
}
=== /textfix (release)
...
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">rendered on client, clicks = 0</p>
#out after click: <p id="out" data-node-hydration="0">rendered on client, clicks = 1</p>

水合后 #out 立刻是客户端的值,不用等用户点击。随机数、时间这类值如果要出现在首屏 HTML 里,就在服务端算一次,用 use_server_future 或 use_server_cached 把它带到客户端,客户端不再自己算。

怎么确认没中招

  • 在浏览器控制台执行下面这句,结果应该是 0。它数的是服务端标了监听器(data-node-hydration 里有 :)、水合后却没拿到 data-dioxus-id 的元素,/swap 上是 1,其余页面是 0:
document.querySelectorAll('[data-node-hydration*=":"]:not([data-dioxus-id])').length
  • 控制台里出现 Error deserializing data,或 TypeError 的堆栈里有 hydrate_node,就是 hook 顺序或类型对不上。debug 构建的消息里有服务端写入槽位的位置和类型名,见水合的「debug 构建」一节。
  • 类型相同的互换没有任何日志。端到端测试里,加载页面后不做任何操作先记下首屏数据,再触发一次无关的重渲染,前后应该一致。
  • 代码里搜 cfg!(feature = "server")、cfg!(target_arch = "wasm32")、web_sys::window()、Instant::now、rand:: 出现在组件函数体(而不是 effect 或事件回调)里的地方。

小结

  • 水合按位置取数据、按编号认领节点,不比较内容;两边第一次渲染必须调用相同顺序的 hook、渲染相同的结构。
  • hook 顺序不一致、类型不同:反序列化失败,组件挂起,hydrate_node 抛 TypeError,页面不再响应。
  • 类型相同:值静默互换,第一次重渲染时才显出来。只有文本不同:服务端的文本留到第一次重渲染。
  • 修法:hook 无条件调用,分支放进 future;只有浏览器才有的值放到 use_effect 里,水合之后再设。

💬 关联讨论

发起讨论
加载中...