水合
主线位置:首屏主线的最后一段,「水合数据 → 客户端
rebuild+rehydrate」。服务端已经写出带标记的 HTML(dioxus-ssr),这一页讲两样东西怎么从服务端带到浏览器:一是use_server_future等 hook 在服务端算好的值,二是 DOM 节点和客户端ElementId的对应关系。一次页面请求已经解开过一份 16 字节的水合数据、走过run里的水合分支,这一页讲数据按什么顺序排、两边对不上时会怎样。
sequenceDiagram participant S as 服务端 scope participant P as SsrRendererPool participant H as HTML participant C as 客户端 rebuild participant J as hydrate.ts S->>S: create_entry 预留槽位 S->>S: future 完成后 insert P->>S: 按客户端建树顺序收集 P->>H: CBOR 再 base64 H->>C: from_serialized C->>C: 同一顺序 create_entry 取值 C->>J: rehydrate 收集 ids J->>H: 按编号认领节点 挂监听器
HydrationContext:一串按位置编号的槽位
HydrationContext 是一个 Rc<RefCell<HTMLData>>(web 端多一个 suspense_finished 标志)。HTMLData 才是数据本身:
pub(crate) struct HTMLData {
/// The position of the cursor in the data. This is only used on the client
pub(crate) cursor: usize,
/// The data required for hydration
pub data: Vec<Option<Vec<u8>>>,
/// The types of each serialized data
///
/// NOTE: we don't store this in the main data vec because we don't want to include it in
/// release mode and we can't assume both the client and server are built with debug assertions
/// matching
#[cfg(debug_assertions)]
pub debug_types: Vec<Option<String>>,
/// The locations of each serialized data
#[cfg(debug_assertions)]
pub debug_locations: Vec<Option<String>>,
}
data的每一项是一个槽位:None是预留了还没写,Some(bytes)是某个值的 CBOR 编码。- 下标 0 固定留给错误(
error_entry用create_entry_with_id(0)),所以cursor从 1 开始。 create_entry取当前cursor作下标、cursor += 1,把data补长到这个下标,返回一个只记着下标的SerializeContextEntry<T>。之后insert(&value, location)写进去,get()读出来。- 槽位没有名字,只有下标。两边能取到同一个值,靠的是两边按同样的顺序调用
create_entry。
值怎么变成字节由 Transportable 决定。所有 Serialize + DeserializeOwned 的类型都有一个默认实现,用 ciborium 编成 CBOR;Result<T, anyhow::Error> 和 CapturedError 有专门的实现,错误只保留 to_string() 的文字。
谁在创建槽位
| 调用者 | 存的是什么 | 在哪个 scope |
|---|---|---|
use_server_future、use_loader | future 的结果 | 调用它的组件 |
use_server_cached | 闭包的返回值 | 调用它的组件 |
provide_fullstack_history_context | 服务端解析出的初始路由字符串(有 router 时) | 根 scope |
init_error_boundary | 这个错误边界里的错误(Option<CapturedError>) | 每个错误边界,包括 VirtualDom 自带的根错误边界 |
ServerDocument::create_head_component | 一个 bool:这个 head 元素服务端写过没有 | 用 document::Title、Meta、Link 等 head 组件的地方 |
没写进值的槽位也要预留。一次页面请求里那份数据中间的两个 f6,就是没有 router 时的路由槽和没有错误的错误边界槽。客户端要是少建了它们,后面所有的下标都会错一位。
服务端:每个 scope 一份,最后按顺序合并
serialize_context 在两边的实现完全不同:
pub fn serialize_context() -> HydrationContext {
#[cfg(feature = "web")]
// On the client, the hydration logic provides the context in a global
if let Some(current_context) = CONTEXT.with(|context| context.borrow().clone()) {
current_context
} else {
// If the context is not set, then suspense is not active
HydrationContext {
suspense_finished: true,
..Default::default()
}
}
#[cfg(not(feature = "web"))]
{
// On the server each scope creates the context lazily
dioxus_core::has_context()
.unwrap_or_else(|| dioxus_core::provide_context(HydrationContext::default()))
}
}
服务端用的是 has_context,它只查当前 scope,不往父 scope 找(has_context)。所以每个创建槽位的 scope 都有一份自己的 HydrationContext,下标各自从 1 数起。服务端渲染时 future 完成的先后不确定,每个组件只往自己那份里写,就不会互相干扰。
渲染完成后,serialize_server_data 调 extract_from_suspense_boundary,把这些分散的上下文按一个固定顺序拼成一份:
/// Walks through the suspense boundary in a depth first order and extracts the data from the context API.
/// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic.
pub(crate) fn extract_from_suspense_boundary(
vdom: &VirtualDom,
scope: ScopeId,
) -> HydrationContext {
let data = HydrationContext::default();
Self::serialize_errors(&data, vdom, scope);
Self::take_from_scope(&data, vdom, scope);
data
}
serialize_errors把这个边界的ErrorContext里的错误写进下标 0。take_from_scope先把当前 scope 的那份extend进来,再沿VNode往下走:suspense 边界先走它挂起的那棵子树,然后是root_node();遇到组件进它的 scope,遇到 Fragment 逐个进。
「深度优先」的顺序必须和客户端建树时组件第一次运行的顺序一样,因为客户端是在建树的过程中按运行顺序 create_entry 的。这里有一个容易忽略的地方:core 建树时,同一个根元素里的动态节点是倒着建的(原因见 load_placeholders 的注释)。用原生 VirtualDom 看组件的运行顺序:
#[component]
fn Named(name: String) -> Element {
println!("run {name}");
rsx! { "{name}" }
}
fn app() -> Element {
rsx! {
// two components inside one root element
div {
Named { name: "A" }
Named { name: "B" }
}
// two components as roots of the same template
Named { name: "C" }
Named { name: "D" }
}
}
fn main() {
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
println!("{}", dioxus_ssr::render(&dom));
}
run B
run A
run C
run D
<div>AB</div>CD
div 里的 B 先于 A 运行,模板根上的 C、D 则按顺序。take_from_vnode 照抄了这个规则:根元素下的动态节点用一份和 core 相同的 collect_dyn_node_range 求出范围,然后 (start..=end).rev() 倒着取,注释写着「dioxus core runs nodes in an odd order to not mess up template order. We need to match that order here」。如果 A、B 各有一个 use_server_future,数据里 B 的值排在 A 前面。
最后 serialized() 把整个 data 再编一次 CBOR(外层是 Vec<Option<Vec<u8>>>,Vec<u8> 被 serde 当成整数序列),然后标准 base64,写进 window.initial_dioxus_hydration_data。流式渲染时每个后到的 suspense 边界单独做一次 extract_from_suspense_boundary,随替换块一起送下去(见流式渲染)。
客户端:同一个顺序,一个全局游标
web 端的 run 读出数据,HydrationContext::from_serialized 解回 Vec<Option<Vec<u8>>>,游标设为 1;下标 0 有错误就先 throw_error 到 ScopeId::APP。然后在 in_context 里 rebuild:
server_data.in_context(|| {
virtual_dom.in_scope(ScopeId::ROOT, || {
// Provide a hydration compatible create error boundary method
dioxus_core::provide_create_error_boundary(
dioxus_fullstack_core::init_error_boundary,
);
#[cfg(feature = "document")]
document::init_fullstack_document();
});
virtual_dom.rebuild(&mut websys_dom);
});
in_context 把这份上下文放进一个线程局部变量 CONTEXT,rebuild 期间所有组件的 serialize_context() 拿到的都是同一份,共用一个游标。服务端是「每个 scope 一份,最后按建树顺序拼起来」,客户端是「一份,按建树顺序依次领」,两者在顺序一致时产出相同的下标。rebuild 结束,in_context 把 CONTEXT 恢复成 None;之后再新建的组件拿到的是 suspense_finished: true 的空上下文,get() 一律返回 DataNotAvailable。
get 的三种失败(TakeDataError)和 use_server_future 对每种的处理:
#[cfg(feature = "web")]
match initial_web_result.take() {
// The data was deserialized successfully from the server
Some(Ok(o)) => return o,
// The data is still pending from the server. Don't try to resolve it on the client
Some(Err(crate::transport::TakeDataError::DataPending)) => {
std::future::pending::<()>().await
}
// The data was not available on the server, rerun the future
Some(Err(_)) => {}
// This isn't the first run, so we don't need do anything
None => {}
}
get() 的结果 | 意思 | use_server_future 怎么做 |
|---|---|---|
Ok(value) | 服务端写了,反序列化成功 | 直接返回,不 .await 用户的 future,不发请求 |
DataPending | 槽位在,但是 None | 永远挂起,等流式块送来数据后重跑这个边界 |
DataNotAvailable | 下标超出数据长度,或者水合已经结束 | 照常跑用户的 future(客户端发请求) |
DeserializationError | 有字节,但解不成客户端要的类型 | 打一条 ERROR 日志,然后照常跑用户的 future |
最后一行是下一节实验的关键:反序列化失败不会 panic,而是退回到客户端自己请求。
认领节点:rehydrate 和 hydrate.ts
rebuild 在 skip_mutations = true 下跑完,VirtualDom 里每个节点都有了客户端的 ElementId,但 JS 侧的 nodes 数组还是空的。WebsysDom::rehydrate 先把流式块的回调注册给 JS(register_rehydrate_chunk_for_streaming_debug,顺带处理 hydrate_queue 里已经排队的块),再从根 scope 开始 start_hydration_at_scope:Rust 侧收集 ids,交给 JS 侧的 hydrate(ids, under)。
rehydrate_template_node 按文档顺序走模板,和服务端 Renderer 写标记的顺序一一对应:
| 模板节点 | 推进 ids 的是 | 对应服务端的标记 |
|---|---|---|
| 根元素,或带动态属性的元素 | 根的 mounted_root,或动态属性的 mounted_dynamic_attribute | data-node-hydration="N..." |
| 模板根上的静态文本 | mounted_root | <!--node-idN--> |
| 动态文本、占位 | mounted_dynamic_node | <!--node-idN-->、<!--placeholderN--> |
| 组件、Fragment | 递归进去 | 同左 |
经过的 suspense 边界如果还有挂起的任务,记进 suspense_hydration_ids,流式块到达时靠它把块里的路径换回 ScopeId。
JS 侧的 hydrate 分两步。先 querySelectorAll("[data-node-hydration]"),对每个元素调 hydrate_node:
hydrate_node(hydrateNode: HTMLElement, ids: { [key: number]: number }) {
const hydration = hydrateNode.getAttribute("data-node-hydration");
const split = hydration!.split(",");
const id = ids[parseInt(split[0])];
this.nodes[id] = hydrateNode;
if (split.length > 1) {
// @ts-ignore
hydrateNode.listening = split.length - 1;
hydrateNode.setAttribute("data-dioxus-id", id.toString());
编号后面还有监听器的,写上 data-dioxus-id 并逐个 createListener。然后用一个只看注释的 TreeWalker 扫一遍:
placeholderN:nodes[ids[N]] = 注释节点本身。占位在客户端也是一个注释节点,直接认领,留在 DOM 里。node-idN:删掉这个注释,下一个节点就是文本,nodes[ids[N]] = 文本节点,再删掉文本后面的<!--#-->。如果下一个节点直接就是注释(服务端的文本是空串,浏览器没有为它建节点),就在那里插一个空文本节点再认领。
到这里 nodes[ElementId] 指向了服务端发来的真实节点,之后的指令(SetText、ReplaceWith…)按 ElementId 找节点,和纯客户端渲染没有区别。
<pre>这个 PR 之前,dioxus-ssr 把占位写成 <pre data-node-hydration={} hidden></pre>。PR #2579(2024-07-02 合并,首次随 v0.6.0-alpha.0 发布)改成注释节点,说明是「Instead of using <pre hidden></pre> as placeholder nodes, this PR uses comment nodes. This fixes css that relies on the order of children, hydration of placeholders in forms and placeholders in svgs」。它修的 issue #2274 标题是「Placeholders in SVGs break rendering」:同样的组件只开 web 时正常,开了 fullstack 之后 <path> 被渲染到了 <svg> 外面(HTML 解析器在 SVG 里遇到 <pre> 会结束 SVG)。现在服务端写 <!--placeholderN-->,hydrate.ts 里那个 split("placeholder") 分支直接拿注释当节点。
两边顺序不一致会怎样
槽位只认下标,所以服务端和客户端只要有一处 hook 顺序不同,从那里往后的下标就全错了。用两个页面复现,服务端和客户端在 cfg 下故意以相反的顺序调用两个 use_server_future。num、word 是两个 #[get] server function,分别返回 Ok(1) 和 Ok("two".to_string()):
#[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, "+" }
}
}
(sleep_ms(ms) 也是一个 #[get] server function,睡 ms 毫秒后返回 Ok(ms)。)实际项目里不会有人这样写 cfg,但效果和「use_server_future 放在 if 里、条件在两边不同」「客户端多调了一个创建槽位的 hook」是一样的。headless Chrome 156 打开页面,用一段 Node 22 脚本通过 DevTools Protocol 记录网络和控制台,水合后读 #out,点一次 #inc 再读一次。release 构建:
=== swap (release)
[net] GET /swap
[net] GET /assets/exp-dioxus-fs1-fs-dxhb955d2e33fde5fe5.js
[net] GET /assets/exp-dioxus-fs1-fs_bg-dxhd9dbd27f8dd2b5f8.wasm
[net] GET /favicon.ico
[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')
Stack:
TypeError: Cannot read properties of undefined (reading 'toString')
at U.hydrate_node (http://127.0.0.1:18790/assets/exp-dioxus-fs1-fs-dxhb955d2e33fde5fe5.js:1:4061)
at U.hydrate (http://127.0.0.1:18790/assets/exp-dioxus-fs1-fs-dxhb955d2e33fde5fe5.js:1:4397)
...
[exception] TypeError: Cannot read properties of undefined (reading 'toString')
at U.hydrate_node (http://127.0.0.1:18790/assets/exp-dioxus-fs1-fs-dxhb955d2e33fde5fe5.js:1:4061)
...
#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)
[net] GET /swap2
[net] GET /assets/exp-dioxus-fs1-fs-dxhb955d2e33fde5fe5.js
[net] GET /assets/exp-dioxus-fs1-fs_bg-dxhd9dbd27f8dd2b5f8.wasm
#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>
(堆栈只留了前两帧。)
类型不同(/swap):服务端的数据里,槽位 3 是 {"Ok": 1},槽位 4 是 {"Ok": "two"}(槽位 1 是路由 "/swap",2 是根错误边界)。客户端第一个 use_server_future 是 word,领到槽位 3,按 Result<String, ServerFnError> 解,失败,打出那条 Error deserializing data;按上一节的表,它退回去自己请求,于是有了 GET /api/word。请求要走网络,这个 future 第一次 poll 是 Pending,组件在第一个 ? 处挂起,第二个 use_server_future 根本没执行。挂起的组件在客户端的树里没有 <p> 和按钮,服务端的 HTML 里却有。rehydrate 照着客户端的树收集 ids,比服务端的编号少:按钮是 data-node-hydration="2,click:1",ids[2] 是 undefined,hydrate_node 要给带监听器的元素写 data-dioxus-id,id.toString() 抛出 TypeError。这个异常从 wasm-bindgen 的导入函数里抛出,没被捕获,打断了 run:页面停在服务端发来的样子,<!--node-id1--> 注释还在,按钮点了没有反应。
类型相同(/swap2):两个槽位都是 Result<u64, ServerFnError>,反序列化都成功,没有任何日志,也没有请求。客户端的 b 拿到了服务端 a 的 10,a 拿到了 20。水合不比较内容,页面上仍是服务端写的「a = 10, b = 20」;第一次点击让组件重跑,文本按客户端的值重写成「a = 20, b = 10」。用户看到的是「点了一下,两个毫不相干的数换了位置」。
这两种结果都没有一条报错直接指出「hook 顺序不一致」。会让两边顺序不同的常见写法:use_server_future 放在依赖 cfg!(feature = "server")、window、时间、随机数的条件里;只在一边存在的组件里调 use_server_future;以及服务端和客户端渲染出不同结构(不同的组件树意味着不同的建树顺序)。
debug 构建:类型名和位置,以及它们为什么对不上
debug 构建多送两个数组,window.initial_dioxus_hydration_debug_types 和 ..._debug_locations,HTMLData::get 反序列化失败时用它们报「服务端在哪一行、以什么类型写的」。同一个 /swap 页面的 debug 构建,服务端写出的脚本:
<script>window.initial_dioxus_hydration_data="hYEY9oYYZRgvGHMYdxhhGHD2hRihGGIYTxhrAYgYoRhiGE8YaxhjGHQYdxhv";window.initial_dioxus_hydration_debug_types=["core::option::Option<dioxus_core::render_error::CapturedError>","unknown","alloc::string::String","unknown","unknown","unknown","core::result::Result<i32, dioxus_fullstack_core::error::ServerFnError>","core::result::Result<alloc::string::String, dioxus_fullstack_core::error::ServerFnError>"];window.initial_dioxus_hydration_debug_locations=["~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-server-0.7.10/src/ssr.rs:556:29","unknown","~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-fullstack-core-0.7.10/src/history.rs:44:30","unknown","unknown","unknown","src/main.rs:122:13","src/main.rs:123:13"];</script>
数据解开是 5 项(错误、路由 "/swap"、错误边界的 None、{"Ok":1}、{"Ok":"two"}),调试数组却有 8 项。客户端控制台里那条错误:
[console.log] ERROR ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-fullstack-core-0.7.10/src/transport.rs:299 Error deserializing data: Semantic(None, "invalid type: integer `1`, expected string")
This type was serialized on the server at unknown with the type name unknown. The client failed to deserialize the type core::result::Result<alloc::string::String, dioxus_fullstack_core::error::ServerFnError> at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-fullstack-core-0.7.10/src/transport.rs:297:51.
失败的是数据的下标 3,调试数组的下标 3 是 "unknown"。真正的类型 Result<i32, ...> 和位置 src/main.rs:122:13 在下标 6。原因在 HTMLData::extend:
let mut other_data_iter = other.data.iter().cloned();
#[cfg(debug_assertions)]
let mut other_debug_types_iter = other.debug_types.iter().cloned();
#[cfg(debug_assertions)]
let mut other_debug_locations_iter = other.debug_locations.iter().cloned();
// Merge the error entry from the other context
if let Some(Some(other_error)) = other_data_iter.next() {
self.data[0] = Some(other_error.clone());
#[cfg(debug_assertions)]
{
self.debug_types[0] = other_debug_types_iter.next().unwrap_or(None);
self.debug_locations[0] = other_debug_locations_iter.next().unwrap_or(None);
}
}
// Don't copy the error from the other context
self.data.extend(other_data_iter);
other_data_iter.next() 无论有没有错误都消耗了对方的下标 0,两个调试迭代器却只在「有错误」时才前进。每个 scope 的上下文下标 0 通常是 None,于是每合并一个 scope,调试数组就比数据多一项。/swap 合并了三个 scope(根 scope 的路由、根错误边界、Swap 本身),多出 3 项,5 对 8。
脱离浏览器也能复现,直接用 dioxus-fullstack-core 的公开 API 模拟 extract_from_suspense_boundary(原生,debug 构建):
use base64::Engine;
use dioxus_fullstack_core::HydrationContext;
use std::panic::Location;
fn scope_ctx(value: i32) -> HydrationContext {
// a scope with one use_server_future: its own context, entries start at index 1
let ctx = HydrationContext::default();
ctx.create_entry::<i32>().insert(&value, Location::caller());
ctx
}
fn main() {
let merged = HydrationContext::default();
merged.error_entry().insert(&None, Location::caller());
merged.extend(&scope_ctx(10));
merged.extend(&scope_ctx(20));
let out = merged.serialized();
let bytes = base64::engine::general_purpose::STANDARD.decode(&out.data).unwrap();
let slots: Vec<Option<Vec<u8>>> = ciborium::from_reader(&bytes[..]).unwrap();
for (i, slot) in slots.iter().enumerate() {
let shown = match slot {
Some(b) => {
let v: ciborium::Value = ciborium::from_reader(&b[..]).unwrap();
format!("{v:?}")
}
None => "None".to_string(),
};
println!("data[{i}] = {shown}");
}
println!("debug_types = {}", out.debug_types);
println!("debug_locations = {}", out.debug_locations);
}
data[0] = Null
data[1] = Integer(Integer(10))
data[2] = Integer(Integer(20))
debug_types = ["core::option::Option<dioxus_core::render_error::CapturedError>","unknown","i32","unknown","i32"]
debug_locations = ["src/bin/extend_drift.rs:17:38","unknown","src/bin/extend_drift.rs:11:44","unknown","src/bin/extend_drift.rs:11:44"]
数据 3 项,调试数组 5 项,data[1] 对上的是 "unknown"。0.6.3 的同名方法是三个 extend_from_slice,两边长度始终一致;错位是 PR #3891(提交 84221f55a,首次随 v0.7.0-alpha.0 发布)把水合格式抽成单独的 crate(当时叫 fullstack-protocol)、加上「合并错误槽」逻辑时带进来的。数据本身不受影响,受影响的只是 debug 下这条报错:它恰好在最需要的时候指错地方。报错里客户端那一侧的位置也没用,transport.rs:297:51 是 HTMLData::get 里那句 Location::caller(),而 get 没有 #[track_caller],指向的是 dioxus 自己。
/swap2 点击之后显示什么?上面的 Swap2:服务端按 a、b 的顺序调用两个 use_server_future,分别得到 Ok(10)、Ok(20);客户端按 b、a 的顺序调用。headless Chrome 打开页面、水合完成后,点一次按钮(clicks += 1 让组件重跑)。#out 的文本会是什么?(M2 Max、macOS 27、Chrome 156,release 构建。)
动脑筋答案
那次运行的最后两行(原样):
#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>
- 水合之后是服务端的「a = 10, b = 20」:
rebuild在skip_mutations下不写 DOM,rehydrate只认领节点,不比较文本。注意<!--node-id1-->这次没有残留:水合正常完成了。 - 点击之后变成「a = 20, b = 10」:客户端第一个
create_entry的是 b,领到服务端写 a 的槽位 3(10);a 领到槽位 4(20)。类型都是Result<u64, ServerFnError>,反序列化成功,没有日志、没有请求。组件重跑时SetText写进的是客户端一直持有的这两个值。
小结
HydrationContext是一串只有下标的槽位:下标 0 留给错误,create_entry按调用顺序领下标,值用 CBOR 编码,整串再 CBOR 加 base64 写进页面。- 服务端每个 scope 一份上下文,
extract_from_suspense_boundary按客户端建树的顺序合并(同一根元素下的动态节点倒序,和 core 一致);客户端在rebuild期间只有一份全局上下文,按运行顺序依次领下标。 use_server_future在客户端第一次运行时:取到值就直接返回;槽位是空的就一直挂起等流式块;取不到或解不开就自己请求。rehydrate按文档顺序收集ids,hydrate.ts用ids[编号]认领带data-node-hydration的元素、文本注释之间的文本和占位注释本身,并挂上监听器。- hook 顺序两边不一致时,类型不同会触发反序列化错误、组件在客户端挂起、
hydrate_node抛TypeError,水合中断;类型相同则静默串值,下一次重渲染才显现。 - debug 的类型和位置数组在
HTMLData::extend里每合并一个 scope 就多一项,报错里的服务端类型和位置因此对不上。