Rust in Everything
基于 dioxus 0.7.10

一次页面请求

主线位置:首屏主线从头到尾。本页不深入任何一层,只把一次 GET / 经过的函数串起来:用 curl 拿到服务端发出的 HTML,逐块标出每一段是谁写的、给谁用;再用 headless Chrome 看水合之后的 DOM,用服务端日志确认 server function 在渲染时被直接调用、在客户端重跑时才走 HTTP。每一段末尾链到详细讲它的那一章。

sequenceDiagram
  participant B as 浏览器
  participant AX as axum Router
  participant P as SsrRendererPool
  participant LT as 本地线程
  participant VD as 本次请求的 VirtualDom
  participant W as wasm 客户端
  B->>AX: GET /
  AX->>P: fallback render_handler
  P->>LT: spawn_pinned
  LT->>VD: 新建 加根 context
  LT->>VD: rebuild_in_place
  Note over VD: 组件挂起 double 在跑
  LT->>VD: wait_for_suspense
  Note over LT: 查根错误边界定状态码
  LT-->>AX: 状态码和响应头
  LT-->>B: head 和流式脚本
  LT-->>B: 带标记的 HTML 和水合数据
  LT-->>B: body 结尾
  B->>W: 加载 js 和 wasm
  Note over W: skip_mutations 下 rebuild
  W->>B: rehydrate 认领节点 挂监听器
步骤文件 · 函数关键的一两行详见
1. 页面请求落到 fallbackfullstack-server/src/server.rs · serve_dioxus_application、render_handler.fallback(get(FullstackState::render_handler))一次请求的渲染、axum 卷dioxus 生成的 Router
2. 查增量缓存,然后钉到一个本地线程上渲染fullstack-server/src/ssr.rs · SsrRendererPool::render_tolet join_handle = rt.spawn_pinned(create_render_future);一次请求的渲染
3. 每个请求新建 VirtualDom,根上放 ServerDocument、FullstackContext、初始路由同上let mut virtual_dom = virtual_dom_factory();一次请求的渲染
4. 建树,不产生 DOM 指令core/src/virtual_dom.rs · rebuild_in_placeself.rebuild(&mut NoOpMutations);VirtualDom 与 Runtime
5. 等 suspense(use_server_future 在服务端跑完)core/src/virtual_dom.rs · wait_for_suspense;fullstack-core/src/server_future.rs · use_server_futurestorage_entry.insert(&out, caller);Suspense 与错误边界、水合
6. 根错误边界里的错误变成状态码fullstack-server/src/ssr.rs · render_to 中段status_code = Some(StatusCode::NOT_FOUND);一次请求的渲染
7. 写 <head> 和流式脚本fullstack-server/src/ssr.rs · render_head、render_before_bodywrite!(to, "<script>{INITIALIZE_STREAMING_JS}</script>")?;document 与 eval、流式渲染
8. 带水合标记渲染组件树ssr/src/renderer.rs · Rendererwrite!(buf, "<!--node-id{}-->", self.dynamic_node_id)?;dioxus-ssr
9. 写水合数据和加载 wasm 的 <script>fullstack-server/src/ssr.rs · render_after_mainr#"<script>window.initial_dioxus_hydration_data="{raw_data}";"#水合
10. 浏览器里在水合数据之上 rebuild,不写 DOMweb/src/lib.rs · runwebsys_dom.skip_mutations = true;水合
11. 沿 DOM 认领节点、挂监听器web/src/hydration/hydrate.rs · WebsysDom::rehydrate;interpreter/src/ts/core.ts · hydratethis.interpreter.base().hydrate(ids, under);水合

实验:curl 一次,再让浏览器打开、点一次

应用只有一个 server function 和一个 use_server_future。server function 的函数体打两行日志:一行带上它「所在的请求」(从 FullstackContext 里取请求的方法和 URI),一行在 100 毫秒的 tokio::time::sleep 之后,让组件在服务端确实挂起一次:

use dioxus::logger::tracing::info;
use dioxus::prelude::*;

#[post("/api/double")]
async fn double(n: i32) -> Result<i32, ServerFnError> {
  // which request is this body running inside: the page request or a POST to /api/double?
  let req = dioxus::fullstack::FullstackContext::current()
    .map(|cx| {
      let parts = cx.parts_mut();
      format!("{} {}", parts.method, parts.uri)
    })
    .unwrap_or_else(|| "(没有 FullstackContext)".to_string());
  info!("double({n}) 开始,所在请求:{req}");
  tokio::time::sleep(std::time::Duration::from_millis(100)).await;
  info!("double({n}) 返回 {}", n * 2);
  Ok(n * 2)
}

fn app() -> Element {
  let mut count = use_signal(|| 1);
  info!("app 运行 count = {}", count.peek());
  let doubled = use_server_future(move || double(count()))?;
  rsx! {
    div { id: "out", "count = {count}, doubled = {doubled():?}" }
    button { id: "inc", onclick: move |_| count += 1, "+" }
  }
}

fn main() {
  dioxus::launch(app);
}

依赖是 dioxus = "=0.7.10"(router、fullstack),server feature 额外打开 tokio(time)。dx build --platform web --fullstack --release(dx 0.7.9)编出 server 和 public/,IP=127.0.0.1 PORT=18760 ./server 启动。M2 Max、macOS 27、rustc 1.99.0。

curl 拿到的 HTML

curl --noproxy '*' http://127.0.0.1:18760/,响应头是 200 OK、content-type: text/html; charset=utf-8、transfer-encoding: chunked,正文 1124 字节,原样:

<!DOCTYPE html>
<html>
    <head>
        <title>dioxus | ⛺</title>
        <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta charset="UTF-8">
    <link rel="preload" as="script" href="/./assets/exp-dioxus-mainline-fs-dxhd7f2ee82f6c56aa2.js" crossorigin></head>
    <body>
        <div id="main"><script>window.hydrate_queue=[];window.dx_hydrate=(id,data,debug_types,debug_locations)=>{let decoded=atob(data),bytes=Uint8Array.from(decoded,(c)=>c.charCodeAt(0));if(window.hydration_callback)window.hydration_callback(id,bytes,debug_types,debug_locations);else window.hydrate_queue.push([id,bytes,debug_types,debug_locations])};
</script><div id="out" data-node-hydration="0"><!--node-id1-->count = 1, doubled = Some(Ok(2))<!--#--></div><button id="inc" data-node-hydration="2,click:1">+</button><script>window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg==";</script></div>
    <script type="module" async src="/./assets/exp-dioxus-mainline-fs-dxhd7f2ee82f6c56aa2.js"></script>
            </body>
</html>

把它和 dx 生成的 public/index.html 逐行 diff,只有一处不同:模板里是空的 <div id="main"></div>,响应里这个 div 被填满了。其余每一个字节都来自模板。按出现顺序:

片段谁写的给谁用
<!DOCTYPE html> 到 <link rel="preload" ...>public/index.html 的 <head>,render_head 照抄;<title> 可以被组件里的 document::Title 换掉浏览器;preload 让 wasm 的 JS 胶水提前下载
</head>、<body>、<div id="main">模板,render_before_body 照抄
第一个 <script>:window.hydrate_queue、window.dx_hydraterender_before_body 写的 INITIALIZE_STREAMING_JS(interpreter/src/ts/initialize_streaming.ts 编译后的结果)流式渲染:后到的 suspense 块调 dx_hydrate 送数据,wasm 还没加载完就先排队
<div id="out" data-node-hydration="0">...<button ... data-node-hydration="2,click:1">+</button>dioxus_ssr::Renderer,pre_render = true用户看到的内容;data-node-hydration、<!--node-id1-->、<!--#--> 是给水合认领节点用的
第二个 <script>:window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg=="render_after_mainwasm 启动时读它,use_server_future 从里面取服务端算好的值
</div> 和 <script type="module" async src="...js">模板里 id="main" 之后的部分,render_after_main 照抄加载 wasm 的入口;async 模块脚本不阻塞解析
</body></html>模板

用 curl --raw 看分块,正好三块,对应源码里三次往通道里写:

746 '<!DOCTYPE html>\n<html>\n    <head>\n      ' ... ',debug_locations])};\n</script>'
362 '<div id="out" data-node-hydration="0"><!' ... 'aa2.js"></script>\n            '
16 '</body>\n</html>\n' ... '</body>\n</html>\n'
0 (结束)

(每行是块长度、开头 40 个字符和结尾 30 个字符,由一段 Python 解析 --raw 的输出打印。)

debug 构建(不加 --release)的同一个页面多出两样东西:模板里 dx 的热重载提示框(一段 <style>、一段 <script> 和一个 #__dx-toast 的 div,共 9 KB 左右),以及水合脚本里的两个调试数组:

<script>window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg==";window.initial_dioxus_hydration_debug_types=["core::option::Option<dioxus_core::render_error::CapturedError>","unknown","unknown","unknown","unknown","unknown","core::result::Result<i32, 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","unknown","unknown","unknown","unknown","src/main.rs:22:17"];</script>

它们是 #[cfg(debug_assertions)] 下才写的,水合数据反序列化失败时用来报「服务端在哪一行、以什么类型写的」。

服务端日志

这次 curl 期间,server 打出的日志(release 构建,时间戳是 UTC):

2026-10-27T15:34:06.162473Z  INFO dioxus_server::server: Registering: POST /api/double
2026-10-27T15:34:06.210304Z  INFO exp_dioxus_mainline_fs: app 运行 count = 1
2026-10-27T15:34:06.210359Z  INFO exp_dioxus_mainline_fs: double(1) 开始,所在请求:GET /
2026-10-27T15:34:06.313400Z  INFO exp_dioxus_mainline_fs: double(1) 返回 2
2026-10-27T15:34:06.313477Z  INFO exp_dioxus_mainline_fs: app 运行 count = 1

double(1) 的函数体跑在 GET / 这个请求里,日志里没有任何 POST /api/double:服务端渲染时,use_server_future 里的 double(..) 是直接调用函数体,不经过 HTTP。组件跑了两次:第一次 use_server_future 挂起(double 在 sleep),100 毫秒后 future 完成,组件重跑,这次拿到了值。

headless Chrome:水合与点击

Chrome 156 --headless=new --remote-debugging-port=18761,一段 Node 22 脚本用自带的 WebSocket 说 Chrome DevTools Protocol:打开 Network 记录每个请求,收 console,等按钮出现 data-dioxus-id(说明水合完成)后取 DOM、查监听器,再点一次按钮。点击前在 #main 上挂一个 MutationObserver,点击后 30 毫秒看一眼 #main 的子节点。输出([net] 和 [console] 行前面是相对脚本开始的毫秒数;脚本按批打印,所以点击后的 MutationObserver 那一行排在 [net] 前面,实际顺序看时间戳):

+ 311ms [net] GET /
+ 421ms [net] GET /assets/exp-dioxus-mainline-fs-dxhd7f2ee82f6c56aa2.js
+ 428ms [net] GET /assets/exp-dioxus-mainline-fs_bg-dxh82591c31b6e2f25.wasm
+ 442ms [console.log] INFO src/main.rs:21 app 运行 count = 1
--- 水合之后 ---
#main: <div id="main" data-dioxus-id="0"><script>window.hydrate_queue=[];window.dx_hydrate=(id,data,debug_types,debug_locations)=>{let decoded=atob(data),bytes=Uint8Array.from(decoded,(c)=>c.charCodeAt(0));if(window.hydration_callback)window.hydration_callback(id,bytes,debug_types,debug_locations);else window.hydrate_queue.push([id,bytes,debug_types,debug_locations])};
</script><div id="out" data-node-hydration="0">count = 1, doubled = Some(Ok(2))</div><button id="inc" data-node-hydration="2,click:1" data-dioxus-id="3">+</button><script>window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg==";</script></div>
监听器 #main: click
监听器 #inc: (none)
--- 点击 #inc ---
30ms 后 #main 的子节点: SCRIPT #comment SCRIPT
MutationObserver: ["remove <div#out>","remove <button#inc>","add <!--placeholder-->","remove <!--placeholder-->","add <div#out>","add <button#inc>","text \"count = 2, doubled = Some(Ok(4))\""]
+1044ms [net] POST /api/double
+1044ms [console.log] INFO src/main.rs:21 app 运行 count = 2
+1149ms [console.log] INFO src/main.rs:21 app 运行 count = 2
#main: <div id="main" data-dioxus-id="0"><script>window.hydrate_queue=[];window.dx_hydrate=(id,data,debug_types,debug_locations)=>{let decoded=atob(data),bytes=Uint8Array.from(decoded,(c)=>c.charCodeAt(0));if(window.hydration_callback)window.hydration_callback(id,bytes,debug_types,debug_locations);else window.hydrate_queue.push([id,bytes,debug_types,debug_locations])};
</script><div id="out">count = 2, doubled = Some(Ok(4))</div><button id="inc" data-dioxus-id="2">+</button><script>window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg==";</script></div>

同一时间段服务端多了这几行:

2026-10-27T15:34:12.691780Z  INFO exp_dioxus_mainline_fs: app 运行 count = 1
2026-10-27T15:34:12.691864Z  INFO exp_dioxus_mainline_fs: double(1) 开始,所在请求:GET /
2026-10-27T15:34:12.795136Z  INFO exp_dioxus_mainline_fs: double(1) 返回 2
2026-10-27T15:34:12.795181Z  INFO exp_dioxus_mainline_fs: app 运行 count = 1
2026-10-27T15:34:13.422916Z  INFO exp_dioxus_mainline_fs: double(2) 开始,所在请求:POST /api/double
2026-10-27T15:34:13.524820Z  INFO exp_dioxus_mainline_fs: double(2) 返回 4

从这些输出能读出:

  • 水合期间浏览器只请求了 HTML、JS 胶水和 wasm,没有 POST /api/double;客户端的组件只跑了一次,doubled 直接是 Some(Ok(2))。
  • 水合后 <!--node-id1--> 和 <!--#--> 两个注释不见了,按钮多了 data-dioxus-id="3",根 #main 是 data-dioxus-id="0",click 监听器挂在 #main 上。从这一刻起,就是一次点击那条更新主线。服务端发来的两个 <script> 留在 #main 里没人删,它们不在 VirtualDom 里,水合不碰它们。
  • 点击之后 count 变成 2,use_server_future 的依赖变了,在客户端重跑,这次 double(2) 走的是 POST /api/double,服务端日志里它「所在请求」就是这个 POST。
  • 重跑期间组件又挂起了:div 和按钮被摘掉,换成一个 <!--placeholder--> 注释,响应回来再整块建回来。重建的节点没有 data-node-hydration,按钮的 data-dioxus-id 也从 3 变成了 2(旧的 ElementId 回收后被复用)。

下面按调用顺序把这些对到源码上。

1. fallback:render_handler

dioxus::launch(app) 在服务端编译时走到 fullstack-server 的 serve_server,用 serve_dioxus_application 造路由:

        self.register_server_functions()
            .serve_static_assets()
            .fallback(get(FullstackState::render_handler))
            .with_state(FullstackState::new(cfg, app))

先把 inventory 里登记的每个 server function 挂成一条路由(日志第一行 Registering: POST /api/double),再把 public/ 里除 index.html 以外的文件逐个挂成静态路由,剩下的所有路径都落到 fallback。GET / 不匹配任何一条,于是进了 FullstackState::render_handler。它只取请求的 Parts(请求体丢掉),交给 render_to,再用返回的状态码、响应头和一个 Stream 拼出 Response,正文是 Body::from_stream(rx),所以是 chunked。fallback 外面包的是 get(..),只认 GET,这一层在 axum 那边怎么拼起来,见 axum 卷的 dioxus 生成的 Router;静态文件和 server function 路由见一次请求的渲染。

2. render_to:先查缓存,再钉到本地线程

SsrRendererPool::render_to 先用请求的 path 和 query 查增量渲染缓存(check_cached_route,没配 incremental 时直接返回 None),再从池里借一个 Renderer(池里预先放了 4 个 pre_render = true 的),然后把整个渲染过程包成一个闭包 create_render_future,交出去:

        // Spawn the render future onto the local pool
        let join_handle = rt.spawn_pinned(create_render_future);

        // Wait for the initial result which determines the status code
        let (status, headers) = initial_result_rx
            .await
            .map_err(|err| SSRError::Incremental(IncrementalRendererError::Other(err.into())))??;

rt 是 tokio-util 的 LocalPoolHandle,线程数取 available_parallelism。VirtualDom 里到处是 Rc 和 RefCell,不是 Send,不能交给 tokio 的多线程调度器在线程之间搬;spawn_pinned 在池里挑一个线程,在它的 LocalSet 上跑这个 future,从头到尾不换线程(LocalSet 见 tokio 卷 JoinSet 与 LocalSet)。handler 这边只等一个 oneshot:渲染那边决定好状态码和响应头就发过来,HTML 正文则通过另一个通道 rx 一块一块地流出去。

3. 每个请求一个 VirtualDom

create_render_future 一开头就新建 VirtualDom,在根上放几样只属于这个请求的东西:

            let mut virtual_dom = virtual_dom_factory();
            let document = Rc::new(ServerDocument::default());
            virtual_dom.provide_root_context(document.clone());
            // ...
            // Provide the document and streaming context to the root of the app
            let streaming_context =
                virtual_dom.in_scope(ScopeId::ROOT, || FullstackContext::new(parts));
            virtual_dom.provide_root_context(document.clone() as Rc<dyn dioxus_document::Document>);
            virtual_dom.provide_root_context(streaming_context.clone());

            virtual_dom.in_scope(ScopeId::ROOT, || {
                // Wrap the memory history in a fullstack history provider to provide the initial route for hydration
                provide_fullstack_history_context(history);

                // Provide a hydration compatible error boundary that serializes errors for the client
                dioxus_core::provide_create_error_boundary(
                    dioxus_fullstack_core::init_error_boundary,
                );
            });

            // rebuild the virtual dom
            virtual_dom.rebuild_in_place();
  • ServerDocument:收集组件里的 document::Title、Meta 等,第 6 节写 <head> 时用(见 document 与 eval)。
  • FullstackContext:包着请求的 Parts。实验里 server function 的函数体能读到 GET /,靠的是 FullstackContext::current() 的两步查找:先查 task-local(经 HTTP 调用 server function 时由 handler 设置),查不到再从当前 dioxus runtime 的 scope 往上找 context。SSR 时函数体跑在这个 VirtualDom 的任务里,找到的就是这一个。
  • MemoryHistory:用请求的 path 作初始路由,router 据此决定渲染哪个页面(见 Router 运行时)。
  • 水合版的错误边界:错误会被序列化,客户端水合时原样抛出。

virtual_dom_factory 每次调用都 VirtualDom::new_with_props(root, ())。请求之间不共享任何组件状态,代价是每个请求都从根开始把组件树跑一遍(服务端日志里每次 GET / 都有 app 运行)。rebuild_in_place 就是 rebuild(&mut NoOpMutations):组件照跑、ElementId 照分配,但指令全部丢弃,服务端不需要它们,HTML 是从 VNode 树直接写出来的(第 7 节)。

4. 等 suspense:服务端直接调用 server function

app 第一次运行时,use_server_future 先在 HydrationContext 里预留一个槽位,再用 use_resource 起一个任务,并当场 poll 一次(resource.task().poll_now())。double(1) 要 sleep,第一次 poll 是 Pending,于是组件返回 RenderError::Suspended。如果 future 第一次 poll 就完成(把 sleep 去掉,同样的实验里组件只跑一次),就不会挂起。

这里调用的 double 是宏在服务端生成的那一半。#[post] 展开后,#[cfg(feature = "server")] 分支的最后几行是:

                // Call the function directly
                return #fn_on_server_name #ty_generics(
                    #(#extracted_idents,)*
                    #(#body_json_names,)*
                    #(#server_names,)*
                ).await;

不编码、不发请求,直接 .await 用户写的函数体。日志里 double(1) 的「所在请求」是 GET / 就是这个原因。宏的两半各生成什么,见 server function。

回到 render_to:

            // If streaming is disabled, wait for the virtual dom to finish all suspense work
            // before rendering anything
            if streaming_mode == StreamingMode::Disabled {
                virtual_dom.wait_for_suspense().await;
            } else {
                // Otherwise, just wait for the streaming context to signal the initial chunk is ready
                loop {
                    // ...
                }
            }

ServeConfig 默认 StreamingMode::Disabled,走第一个分支:wait_for_suspense 循环等挂起的任务有进展、重跑被挂起的组件,直到没有挂起的任务。double(1) 100 毫秒后返回,use_server_future 的 future 在返回值之前把它写进预留的槽位(storage_entry.insert(&out, caller)),组件重跑,第二条 app 运行 出现。curl 测到的 time_total 是 0.106 秒,几乎全是这 100 毫秒:在这之前,连状态码都还没发出去。挂起与恢复的机制见 Suspense 与错误边界,OutOfOrder 模式下先发占位、后发替换脚本的做法见流式渲染。

📝NOTE
冷知识:默认不流式,是 0.6.0 才改的

StreamingMode 这个开关来自 PR #3288(2024-12-04 合并,首次随 0.6.0 发布)。在它之前,fullstack 只有乱序流式一种做法:首帧里挂起的 suspense 边界先发占位,内容好了再放进一个带 hidden 属性的 div 发下去,等 wasm 跑起来再换到占位的位置。issue #3276 的标题是「SSR produces HTML that defeats the purpose of SSR」,抱怨的正是这一点:内容在一个带 hidden 属性的 div 里,wasm 跑起来之前用户什么也看不到。PR 的说明是「This PR makes the out of order streaming option optional to allow fullstack sites to support content loading without javascript」,新加的 streaming_mode 文档写着「By default, streaming is disabled.」,render_to 里多了上面那个 if streaming_mode == StreamingMode::Disabled 分支。所以本页 curl 拿到的 HTML 里,doubled = Some(Ok(2)) 直接就在 div#out 里。

5. 状态码从哪来

suspense 等完之后,render_to 看根错误边界(ScopeId::ROOT_ERROR_BOUNDARY)里有没有错误。有的话按类型挑状态码:HttpError 用它自己的状态码,StatusCode 原样用,ServerFnError::ServerError { code, .. } 用 code,router 的 ParseRouteError 是 404;挑到了就整页只返回状态码和一句消息,挑不到就是 500。没有错误时,用 FullstackContext 里组件设置过的状态码(默认 200)和响应头,通过那个 oneshot 发给 handler。之后再出的错已经改不了状态码了。这些分支和 commit_initial_chunk 见一次请求的渲染。

6. <head> 和第一块

            // Wait long enough to assemble the `<head>` of the document before starting to stream
            let mut pre_body = String::new();
            if let Err(err) = Self::render_head(&cfg, &mut pre_body, &virtual_dom) {
                _ = into.start_send(Err(err));
                return;
            }

            let stream = Arc::new(StreamingRenderer::new(pre_body, into));

index.html 在启动时被 IndexHtml::new 按几个固定的标记切成几段:<title> 前后、</head> 到 id="main" 那个标签的 > 为止、id="main"> 之后到 </body> 之前、</body> 之后。render_head 依次写 <title> 之前的部分、标题(ServerDocument 里有就用组件设的,否则用模板的)、<title> 之后的部分、ServerDocument 收集到的 head 元素,最后 render_before_body 写 </head> 到 <div id="main">,紧跟着 INITIALIZE_STREAMING_JS。StreamingRenderer::new 把这一整段作为第一块送进通道:curl 看到的第一块 746 字节,正好以那个 <script> 的 </script> 结尾。

7. 带水合标记的 HTML

第二块从 renderer.render(&virtual_dom) 开始。pre_render = true 时,Renderer 沿 VNode 树写 HTML,静态部分来自每个模板预先切好的字符串片段,动态部分边写边编号(dynamic_node_id 从 0 开始,每遇到一个需要认领的位置加一):

  • 模板根元素:data-node-hydration="0",div#out 是这个模板的第一个根。
  • 动态文本:前面一个 <!--node-id1-->,后面一个 <!--#-->。浏览器解析 HTML 时相邻文本会合并成一个文本节点,有了这对注释,客户端才知道动态文本从哪开始、到哪结束。
  • 带动态属性的元素:data-node-hydration="2,click:1",编号后面跟着这个元素上的监听器,click 是事件名,1 表示冒泡(event_bubbles)。

写文本节点的那一段:

                        DynamicNode::Text(text) => {
                            // in SSR, we are concerned that we can't hunt down the right text node since they might get merged
                            if self.pre_render {
                                write!(buf, "<!--node-id{}-->", self.dynamic_node_id)?;
                                self.dynamic_node_id += 1;
                            }
                            // ...
                            if self.pre_render {
                                write!(buf, "<!--#-->")?;
                            }
                        }

编号 0、1、2 的顺序,就是第 10 节客户端遍历的顺序。字符串缓存怎么切、属性和组件怎么写,见 dioxus-ssr。

8. 水合数据:16 个字节

组件树写完,render_after_main 写水合数据:

        let resolved_data = SsrRendererPool::serialize_server_data(virtual_dom, ScopeId::ROOT);
        // We always send down the data required to hydrate components on the client
        let raw_data = resolved_data.data;
        write!(
            to,
            r#"<script>window.initial_dioxus_hydration_data="{raw_data}";"#,
        )?;

serialize_server_data 从根开始按深度优先把各个 scope 上的 HydrationContext 合并起来,第 0 项固定留给错误;整个 Vec<Option<Vec<u8>>> 用 ciborium 编成 CBOR,再 base64。把实验里那串解开:

16 字节: 84 81 18 f6 f6 f6 85 18 a1 18 62 18 4f 18 6b 02

84 是一个 4 项的数组。第 0 项 81 18 f6 是一个只有一个字节 0xf6 的字节串(serde 把 Vec<u8> 当序列编,每个字节是一个整数,18 f6 即 246),而 0xf6 是 CBOR 的 null,即错误槽位里的 None:没有错误。第 1、2 项是两个 f6:预留了槽位、没写数据。第 3 项 85 18 a1 18 62 18 4f 18 6b 02 是 5 个字节 a1 62 4f 6b 02,即 CBOR 的 {"Ok": 2}:use_server_future 存进去的 Ok(2)。

槽位是按位置对应的,没有名字:客户端只要按同样的顺序调用这些 hook,就能取回同一个下标上的值,所以两边的 hook 调用顺序必须一致。debug 构建里那两个调试数组有 7 项,而数据只有 4 项,Result<i32, ServerFnError> 的类型名排在第 6 项、数据却在第 3 项:合并时数据的第 0 项被跳过、调试数组却没有跟着跳,两边错开了(HTMLData::extend)。数据本身不受影响。槽位怎么分配、两边顺序不一致时报什么,见水合。

render_after_main 最后写模板里 id="main" 之后的部分,其中就是加载 wasm 的 <script type="module" async>;它和组件树、水合数据同在第二块(362 字节)。第三块是 </body> 之后的模板(16 字节)。如果还有没好的 suspense 边界(只在 OutOfOrder 模式下会出现),它们的替换块会夹在第二块和第三块之间。

9. 浏览器:在水合数据之上 rebuild

wasm 加载后,web 的 run 在 hydrate feature 下走水合分支(dioxus 的 fullstack feature 会打开 dioxus-web/hydrate):

            websys_dom.skip_mutations = true;
            // ...
            let hydration_data = get_initial_hydration_data().to_vec();
            // ...
            let server_data =
                HydrationContext::from_serialized(&hydration_data, debug_types, debug_locations);
            // ...
            server_data.in_context(|| {
                virtual_dom.in_scope(ScopeId::ROOT, || {
                    // ...
                });
                virtual_dom.rebuild(&mut websys_dom);
            });
            websys_dom.skip_mutations = false;

            let rx = websys_dom.rehydrate(&virtual_dom).unwrap();

get_initial_hydration_data 是一段内联 JS:atob(window.initial_dioxus_hydration_data) 再转成 Uint8Array。HydrationContext::from_serialized 把 CBOR 解回那 4 个槽位,in_context 把它放进一个线程局部变量,rebuild 期间每个 create_entry 按顺序领一个下标。

rebuild 和纯客户端时完全一样:组件照跑,ElementId 照分配,diff 照样调 WriteMutations 的每个方法。区别只在 WebsysDom 的每个方法开头都有一句 if self.skip_mutations() { return; },指令一条也不写进 DOM。WebsysDom 里的注释解释了为什么用一个布尔开关而不是换一个 WriteMutations 实现:diff 对 WriteMutations 是泛型的,多一个实现就多一份单态化的 diff 代码,wasm 体积会明显变大。

use_server_future 在客户端第一次运行时,先取槽位里的数据:

    // If this is the first run and we are on the web client, the data might be cached
    #[cfg(feature = "web")]
    let initial_web_result =
        use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(Some(storage_entry.get()))));

任务第一次被 poll 时,initial_web_result 里是 Some(Ok(o)),直接返回 o,用户的 future 根本不 .await,也就不发请求。实验里水合期间没有 POST /api/double、客户端 app 只跑一次,就是这条路径。

📝NOTE
没有蠢问题

问: 我照着「move || async move { double(count()).await }」的写法写 use_server_future,水合以后点按钮,count 变了,doubled 却一直是旧值,也没有请求发出去。哪里不对?

答: count() 写在 async 块里,只有 future 被 poll 时才会读。服务端那次读了,但订阅的是服务端的 VirtualDom;客户端第一次运行走的是上面那条「直接返回槽位里的值」的路径,用户的 future 一次也没被 poll,count 从没被读过,use_resource 的 ReactiveContext 也就没有订阅它。把同一个实验改成 async 块的写法(debug 构建),点击后的输出是:

--- 点击 #inc ---
+1253ms [console.log] INFO src/main.rs:21 app 运行 count = 2
#main: <div id="main" data-dioxus-id="0">...<div id="out" data-node-hydration="0">count = 2, doubled = Some(Ok(2))</div>...

组件因为 rsx! 里读了 count 而重跑,但没有 POST /api/double,doubled 停在 Some(Ok(2))。use_server_future 的文档专门警告了这一点:依赖只在「造 future 的那个闭包」里跟踪,不在 future 里跟踪。所以要在闭包里先读出来:use_server_future(move || double(count())),count() 作为参数在闭包里求值,本页的实验用的就是这种写法。

10. rehydrate:认领服务端的节点

WebsysDom::rehydrate 先登记流式块的回调(window.hydration_callback,并把 hydrate_queue 里排队的块补上),再从根 scope 开始 start_hydration_at_scope:

        let mut ids = Vec::new();
        let mut to_mount = Vec::new();

        // Recursively rehydrate the nodes under the scope
        self.rehydrate_scope(scope, dom, &mut ids, &mut to_mount)?;

        self.interpreter.base().hydrate(ids, under);

rehydrate_scope 按和服务端 Renderer 相同的顺序遍历刚刚 rebuild 出来的 VNode 树,把每个需要认领的位置上客户端分到的 ElementId 依次推进 ids。客户端分到的是什么,可以在原生 VirtualDom 上对同样的 rsx! 跑一次 rebuild_to_vec() 看(use_server_future 换成常量 Some(Ok(2)),最后一行是 dioxus_ssr::pre_render):

LoadTemplate { index: 0, id: ElementId(1) }
CreateTextNode { value: "count = 1, doubled = Some(Ok(2))", id: ElementId(2) }
ReplacePlaceholder { path: [0], m: 1 }
LoadTemplate { index: 1, id: ElementId(3) }
NewEventListener { name: "click", id: ElementId(3) }
AppendChildren { id: ElementId(0), m: 2 }
<div id="out" data-node-hydration="0"><!--node-id1-->count = 1, doubled = Some(Ok(2))<!--#--></div><button id="inc" data-node-hydration="2,click:1">+</button>

div#out 是 ElementId(1)、文本是 2、按钮是 3,所以 ids = [1, 2, 3]。JS 侧的 hydrate 拿着这张表找服务端的节点:querySelectorAll("[data-node-hydration]") 逐个读出编号,this.nodes[ids[编号]] = 节点;编号后面还有监听器的,写上 data-dioxus-id 并 createListener。然后用 TreeWalker 扫注释,node-id1 的下一个节点就是文本节点,放进 nodes[ids[1]],两个注释删掉。实验里按钮的 data-dioxus-id="3"、注释消失、#main 上的 click 监听器,都是这一步的结果。

认领完成后,JS 侧的 nodes 数组和 VirtualDom 的 ElementId 对上了,接下来的点击就和纯客户端渲染没有区别。rehydrate 返回的 rx 接收后到的流式块,web 主循环在 select! 里同时等它和 wait_for_work。认领的细节、placeholder 注释、suspense 边界的分批水合见水合。

之后:客户端重跑才走 HTTP

点击让 count 变成 2。use_resource 订阅了 count,取消旧任务、起新任务;这次 initial_web_result 已经被取走(None),用户的 future 被真正 .await:客户端那一半的 double 把参数编码成 JSON,POST /api/double,服务端的 handler 解码、跑同一个函数体,结果再编码回来(调用主线,见 server function)。

新任务刚起时状态是 Pending,use_server_future 的结尾会再一次 return Err(suspend(task).unwrap_err()),组件挂起,最近的 suspense 边界(这里是 VirtualDom 自带的根边界,没有 fallback)把内容换成一个占位注释:MutationObserver 里先 remove 两个节点、add <!--placeholder-->,响应回来后再把整块建回来。想在客户端重跑期间保留旧内容,需要自己放一个 SuspenseBoundary 和 fallback,或者客户端用不会挂起组件的 use_resource;这些取舍见 Suspense 与错误边界。

❗IMPORTANT
动脑筋:double 的函数体一共跑了几次?

服务端启动后,依次做三件事:curl 一次 GET /;headless Chrome 打开同一个页面,等水合完成;在 Chrome 里点一次按钮。服务端日志里会出现几行「double(..) 开始」?每一行的「所在请求」是什么?(M2 Max、macOS 27、Chrome 156,上面的程序,release 构建。)

动脑筋答案

服务端日志里所有「开始」的行(从上面两段日志里摘出来,原样):

2026-10-27T15:34:06.210359Z  INFO exp_dioxus_mainline_fs: double(1) 开始,所在请求:GET /
2026-10-27T15:34:12.691864Z  INFO exp_dioxus_mainline_fs: double(1) 开始,所在请求:GET /
2026-10-27T15:34:13.422916Z  INFO exp_dioxus_mainline_fs: double(2) 开始,所在请求:POST /api/double

三次:

  • 第一次是 curl 的 GET /:每个页面请求新建一个 VirtualDom,use_server_future 在服务端直接调用函数体(第 3、4 节)。
  • 第二次是 Chrome 的 GET /,同样是服务端渲染。浏览器端水合时不调用:use_server_future 第一次运行直接返回水合数据里的 Ok(2)(第 9 节),网络记录里也没有 POST。
  • 第三次是点击之后,count 变成 2,use_server_future 在客户端重跑,这次真的发了 POST /api/double,函数体跑在这个 POST 请求里。

如果用 debug 构建、直接运行 ./server(不经 dx serve),日志里还会多出若干次「所在请求:GET /_dioxus?build_id=0」:debug 构建的 wasm 会定时尝试连接 dx 的 devtools websocket,地址是当前页面的 /_dioxus,没有 dx 在前面代理时,这个请求落进 fallback,被当成一次页面请求完整渲染一遍。

小结

  • 页面请求是 axum 路由的 fallback:render_handler 只拿请求的 Parts,render_to 先查增量缓存,再用 spawn_pinned 把渲染钉在一个本地线程上,因为 VirtualDom 不是 Send。
  • 每个请求新建一个 VirtualDom,根上放 ServerDocument、带请求信息的 FullstackContext、初始路由和可序列化的错误边界;rebuild_in_place 丢弃所有指令。
  • 默认 StreamingMode::Disabled:wait_for_suspense 等 use_server_future 全部完成,状态码才定下来;服务端渲染时 server function 是直接调用,不走 HTTP。
  • 响应分三块:<head> 加流式脚本;带 data-node-hydration 和 <!--node-idN--> 标记的组件树、水合数据和加载 wasm 的脚本;</body>。除了 #main 里面,其余都是 index.html 模板原样。
  • 水合数据是按位置排列的槽位,CBOR 再 base64;客户端在同一个 HydrationContext 里 rebuild,skip_mutations 让指令一条不落地,use_server_future 直接取槽位里的值。
  • rehydrate 按同样的顺序收集客户端的 ElementId,JS 侧按编号认领节点、挂监听器、删掉文本注释;之后客户端重跑 use_server_future 才发 HTTP 请求,而且会让组件再挂起一次。

💬 关联讨论

发起讨论
加载中...