首屏数据用了 use_resource
主线位置:首屏主线的「等 suspense」和「水合数据」两步。服务端只等挂起的任务,只把
use_server_future的结果写进水合数据;use_resource两样都不沾。两个 hook 的实现见异步:resource、future 与 spawn和水合,本页只讲首屏上选错 hook 的三种后果。
flowchart TB
hook{"首屏数据用哪个 hook"} -->|use_resource| r1["服务端不 poll"]
r1 --> r2["HTML 里是 None"]
r2 --> r3["浏览器再请求一次"]
hook -->|"use_resource + suspend"| s1["服务端等到数据"]
s1 --> s2["数据不进水合数据"]
s2 --> s3["客户端挂起,水合崩溃"]
hook -->|use_server_future| f1["服务端等到数据"]
f1 --> f2["数据进水合数据"]
f2 --> f3["客户端直接用"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),用 dx 0.7.9 dx build --platform web --fullstack --release 构建,服务端在 127.0.0.1:18830。浏览器一侧用 headless Chrome,经 DevTools Protocol 记录网络和控制台,等页面上 #inc 按钮拿到 data-dioxus-id(水合完成的标志)后读 #out,再点一次 #inc。全部是 release 构建。
现象
一个 server function,在服务端打一行日志、睡 200 毫秒、返回参数的两倍:
#[post("/api/slow")]
async fn slow(n: i32) -> Result<i32, ServerFnError> {
#[cfg(feature = "server")]
{
info!("slow({n}) on the server");
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
Ok(n * 2)
}
三个页面,只差取数据的那一行:
#[component]
fn Res() -> Element {
let r = use_resource(|| slow(1));
rsx! {
p { id: "out", "r = {r():?}" }
button { id: "inc", onclick: move |_| {}, "+" }
}
}
#[component]
fn ResSuspend() -> Element {
let r = use_resource(|| slow(1)).suspend()?;
rsx! {
p { id: "out", "r = {r:?}" }
button { id: "inc", onclick: move |_| {}, "+" }
}
}
#[component]
fn Sf() -> Element {
let r = use_server_future(|| slow(1))?;
rsx! {
p { id: "out", "r = {r():?}" }
button { id: "inc", onclick: move |_| {}, "+" }
}
}
先用 curl 看服务端发了什么,同时数服务端日志里 slow( 多了几行:
curl /res status=200 time_total=0.003227s slow() calls on the server: 0
curl /res-suspend status=200 time_total=0.204116s slow() calls on the server: 1
curl /sf status=200 time_total=0.204462s slow() calls on the server: 1
== curl /res
<p id="out" data-node-hydration="0"><!--node-id1-->r = None<!--#--></p>
== curl /res-suspend
<p id="out" data-node-hydration="0"><!--node-id1-->r = Ok(2)<!--#--></p>
== curl /sf
<p id="out" data-node-hydration="0"><!--node-id1-->r = Some(Ok(2))<!--#--></p>
再在浏览器里打开(listeners without data-dioxus-id 是水合后仍没认领到 id 的带监听器元素个数,下面「怎么确认没中招」会用到;wasm-bindgen 的堆栈删掉了):
=== /res (release)
[net] GET /res
[navigated] /res
[net] GET /assets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js
[net] GET /assets/exp-dioxus-pit3-fs_bg-dxh92effe0b0b26b1b.wasm
[navigatedWithinDocument] /res
[net] POST /api/slow
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">r = Some(Ok(2))</p>
...
=== /res-suspend (release)
[net] GET /res-suspend
[navigated] /res-suspend
[net] GET /assets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js
[net] GET /assets/exp-dioxus-pit3-fs_bg-dxh92effe0b0b26b1b.wasm
[navigatedWithinDocument] /res-suspend
[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-->r = Ok(2)<!--#--></p>
...
=== /sf (release)
[net] GET /sf
[navigated] /sf
[net] GET /assets/exp-dioxus-pit3-fs-dxh4c78877536e987ee.js
[net] GET /assets/exp-dioxus-pit3-fs_bg-dxh92effe0b0b26b1b.wasm
[navigatedWithinDocument] /sf
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">r = Some(Ok(2))</p>
...
use_resource:服务端 3 毫秒就回了,slow一次也没跑,HTML 里是r = None。没有 JS 的客户端(爬虫、链接预览)看到的就是这一版。浏览器水合后自己POST /api/slow,200 毫秒后才有数据。首屏多了一次往返,内容闪一下。use_resource(..).suspend()?:服务端等了 200 毫秒,HTML 里有数据,看起来修好了。但浏览器里水合抛出TypeError,#inc一直没有data-dioxus-id:页面停在服务端发来的样子,按钮点了没反应,后面也不会再渲染。这一种比第一种更糟,curl 看不出来。use_server_future:服务端等了,HTML 里有数据,浏览器不发请求,水合正常。
use_server_future 还有一个变体也会出错:依赖的 signal 在 async 块里读。
#[component]
fn SfAsync() -> Element {
let mut count = use_signal(|| 1);
// the signal is read inside the async block
let r = use_server_future(move || async move { slow(count()).await })?;
rsx! {
p { id: "out", "count = {count}, r = {r():?}" }
button { id: "inc", onclick: move |_| count += 1, "+" }
}
}
=== /sf-async (release)
...
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">count = 1, r = Some(Ok(2))</p>
#out after click: <p id="out" data-node-hydration="0">count = 2, r = Some(Ok(2))</p>
点击后 count 是 2,r 还是 2,网络日志里没有请求:资源没有重跑。
出处
服务端为什么不等 use_resource。 use_resource 用普通的 spawn 起任务,Runtime::spawn 给的类型是 TaskType::ClientOnly。只有组件把任务的 SuspendedFuture 返回出去(?),handle_element_return 才把它改成 Suspended;服务端的 wait_for_suspense 只 poll 能在 suspense 期间运行的任务(task_runs_during_suspense)。所以不 suspend 的 use_resource 在服务端一次都没被 poll,slow 的日志是 0 行。这一段在 Suspense 与错误边界的「没有蠢问题」里有原生实验。
加了 .suspend()? 为什么水合会崩。 Resource::suspend 让服务端等到了数据,但结果只在服务端那个 VirtualDom 的 signal 里,没有写进水合数据。浏览器里组件第一次运行,use_resource 刚 spawn 出任务,状态是 Pending,suspend() 返回挂起,组件在客户端的树里没有 <p> 和按钮。服务端的 HTML 里却有,按钮带着 data-node-hydration="2,click:1"。rehydrate 照着客户端的树收集 id,少了这几个,hydrate_node 里 ids[2] 是 undefined,id.toString() 抛出 TypeError。这和 hook 顺序不一致时的崩溃是同一条路,见水合的「两边顺序不一致会怎样」。
use_server_future 多做了两件事:服务端在 future 结束后 storage_entry.insert(&out, caller),把结果写进为它预留的槽位;客户端第一次运行先 initial_web_result.take(),槽位里有值就直接返回,再 poll_now() 一次,于是第一次渲染就是 Ready,不挂起,树和服务端一致。
async 块里读 signal 为什么不重跑。 客户端第一次运行走的是「槽位里有值,直接返回」那条路,用户的 async 块一次也没被 poll,count() 没被读,use_resource 的 ReactiveContext 就没有订阅 count。完整的推演和 debug 构建的输出见一次页面请求的「动脑筋答案」。
问: 服务端已经把 Ok(2) 渲染进 HTML 了,客户端为什么不能直接拿来用?
答: 客户端拿不到。HTML 里的 r = Ok(2) 是一段文本,水合只认领节点、挂监听器,不解析文本,也不比较内容。客户端组件要的是一个 Result<i32, _> 值,它只能从水合数据里反序列化出来,而往水合数据里写值的只有 use_server_future(以及 use_server_cached 这类基于同一套槽位的 hook)。
修法
首屏要的数据用 use_server_future;依赖的 signal 在闭包里、async 块外面读:
#[component]
fn SfFix() -> Element {
let mut count = use_signal(|| 1);
// the signal is read in the closure, outside the async block
let r = use_server_future(move || {
let n = count();
async move { slow(n).await }
})?;
rsx! {
p { id: "out", "count = {count}, r = {r():?}" }
button { id: "inc", onclick: move |_| count += 1, "+" }
}
}
=== /sf-fix (release)
...
listeners without data-dioxus-id: 0
#out after hydration: <p id="out" data-node-hydration="0">count = 1, r = Some(Ok(2))</p>
[net] POST /api/slow
#out after click: <p id="out">count = 2, r = Some(Ok(4))</p>
闭包每次被调用都会读 count,第一次运行(客户端走槽位那条路时)也不例外,于是订阅建立了;点击后资源重跑,发出 POST /api/slow,拿到 4。点击后 <p> 没有了 data-node-hydration:重跑期间组件挂起,最近的边界把内容换成占位、数据回来后重建,这一段的取舍见一次页面请求。
use_resource 并没有错,它适合首屏之后才需要、或者只在浏览器里有意义的数据(用户点了才加载的列表、依赖 window 的值)。分界线是「这份数据要不要出现在服务端发出的 HTML 里」:要,就用 use_server_future;不要,用 use_resource,并且不要对它 .suspend()?。
怎么确认没中招
- curl 页面,看首屏数据在不在 HTML 里:
curl --noproxy '*' -s http://127.0.0.1:18830/res | grep 'id="out"'得到r = None就是没等。 - 浏览器开发者工具的网络面板:页面加载完、还没有任何操作时,不应该出现首屏数据对应的 server function 请求。
- 水合是否完整:在控制台里执行下面这句,带监听器、却没认领到
data-dioxus-id的元素个数应该是 0。上面/res-suspend是 1,正常页面都是 0。
document.querySelectorAll('[data-node-hydration*=":"]:not([data-dioxus-id])').length
- 代码里搜
use_resource(后面紧跟.suspend():在 fullstack 应用里,这个组合只要出现在首屏渲染的组件里,水合就会崩。
小结
use_resource的任务在服务端是ClientOnly,不 suspend 就不被 poll:首屏 HTML 里是None,浏览器再请求一次。use_resource(..).suspend()?让服务端等到了数据,但数据不进水合数据;客户端第一次渲染挂起,树比服务端少,hydrate_node抛TypeError,页面不再响应。- 首屏数据用
use_server_future:服务端把结果写进槽位,客户端第一次渲染直接取,不挂起、不请求。 - 依赖的 signal 在
use_server_future的闭包里、async块外面读,否则水合后它变了也不会重新请求。