Rust in Everything
基于 dioxus 0.7.10

流式渲染

主线位置:首屏主线在 render_to 里的另一条分支。默认的 StreamingMode::Disabled 等所有 suspense 完成再写 HTML(一次页面请求走的就是它);OutOfOrder 在首块提交后就开始写,没好的 suspense 边界先写 fallback,好了再把替换块追加到响应末尾。浏览器那一侧,替换块由水合代码搬回原位(认领节点的机制见水合)。

sequenceDiagram
  participant VD as VirtualDom
  participant SR as StreamingRenderer
  participant BR as 浏览器
  participant WA as wasm
  VD->>SR: 首块提交 定状态码
  SR->>BR: head 和 dx_hydrate 定义
  SR->>BR: 页面 边界处写 fallback
  BR->>WA: 加载 wasm 开始水合
  VD->>SR: 边界 A 好了 freeze
  SR->>BR: div ds-0-r hidden 和 dx_hydrate 0
  BR->>WA: hydration_callback
  WA->>BR: 搬进原位 删掉 div
  VD->>SR: 其余边界陆续好了
  SR->>BR: body 结尾

实验应用和 server function 那一页是同一个 fs2-app(dioxus = "=0.7.10",router + fullstack),启动时读环境变量 FS2_MODE,含 stream 就用 ServeConfig::new().enable_out_of_order_streaming()。页面 /stream:

#[component]
fn Slow(ms: u64, label: String) -> Element {
  let text = use_server_future(move || sleep_echo(ms, label.clone()))?;
  let text = text().unwrap()?;
  rsx! { p { class: "slow", "{text}" } }
}

#[component]
fn Fail(ms: u64) -> Element {
  let r = use_server_future(move || fail_after(ms))?;
  let text = r().unwrap()?;
  rsx! { p { "{text}" } }
}

#[component]
fn Stream() -> Element {
  rsx! {
    h1 { "streaming demo" }
    SuspenseBoundary {
      fallback: |_| rsx! { p { "loading A..." } },
      Slow { ms: 300, label: "A" }
    }
    SuspenseBoundary {
      fallback: |_| rsx! { p { "loading B..." } },
      Slow { ms: 1000, label: "B" }
      SuspenseBoundary {
        fallback: |_| rsx! { p { "loading B.1..." } },
        Slow { ms: 1500, label: "B.1" }
      }
    }
    SuspenseBoundary {
      fallback: |_| rsx! { p { "loading C..." } },
      Fail { ms: 600 }
    }
    p { "footer" }
  }
}

sleep_echo(ms, label) 是一个 #[post] server function,服务端 tokio::time::sleep(ms) 后返回 "{label} after {ms}ms";fail_after(ms) sleep 之后返回 Err(ServerFnError::new(..))。/stream-ok 是只有 A 那一个边界的同样页面。dx build --platform web --fullstack --release(dx 0.7.9),FS2_MODE=stream IP=127.0.0.1 PORT=18800 ./server。M2 Max、macOS 27、rustc 1.99.0、Chrome 156。

打开它,以及首块什么时候发

StreamingMode 只有两个值,#[default] 是 Disabled。打开它用 ServeConfig::enable_out_of_order_streaming 或 streaming_mode(StreamingMode::OutOfOrder),通过 LaunchBuilder::with_cfg(server_only! { .. }) 传给服务端。默认值为什么是 Disabled,见一次页面请求里那条冷知识。

打开之后,render_to 在 rebuild_in_place 之后不再 wait_for_suspense,而是循环到两个条件之一成立:

                loop {
                    // Check if the router has finished and set the streaming context to finished
                    let streaming_context_finished = virtual_dom
                        .in_scope(ScopeId::ROOT, || streaming_context.streaming_state())
                        == StreamingStatus::InitialChunkCommitted;

                    // Or if this app isn't using the router and has finished suspense
                    let suspense_finished = !virtual_dom.suspended_tasks_remaining();
                    if streaming_context_finished || suspense_finished {
                        break;
                    }

                    // Wait for new async work that runs during suspense (mainly use_server_futures)
                    virtual_dom.wait_for_suspense_work().await;

                    // Do that async work
                    virtual_dom.render_suspense_immediate().await;
                }

「首块已提交」由 commit_initial_chunk 设置。服务端的 dioxus-router 打开了 streaming feature,Router 组件里有一句 use_after_suspense_resolved(|| commit_initial_chunk()):Router 之上没有挂起的 suspense 时,它第一次渲染就提交。不用 router 的应用要自己在根组件里 use_hook(commit_initial_chunk),否则只有第二个条件能让循环结束,也就是等所有 suspense 都完成,和 Disabled 没有区别。

循环一结束,状态码和响应头就定下来,经 oneshot 发给 handler(这一段和 commit_initial_chunk 的含义见一次请求的渲染)。实验里浏览器在请求发出 3 毫秒后就收到了 200,那时三个边界一个都没好。

首块:边界处写 fallback

渲染首块用的还是 dioxus_ssr::Renderer,只是挂了一个组件回调(streaming_render_component_callback):每遇到一个组件,看它是不是还有挂起任务的 suspense 边界。是的话交给 StreamingRenderer::render_placeholder,并记下「这个 scope 对应哪个挂载点」:

    pub(crate) fn render_placeholder<W: Write + ?Sized>(
        &self,
        html: impl FnOnce(&mut W) -> std::fmt::Result,
        into: &mut W,
    ) -> Result<Mount, std::fmt::Error> {
        let id = self.current_path.read().unwrap().clone();
        // Increment the id for the next placeholder
        self.current_path.write().unwrap().id += 1;
        // While we are inside the placeholder, set the suspense path to the suspense boundary that we are rendering
        let old_path = std::mem::replace(&mut *self.current_path.write().unwrap(), id.child());
        html(into)?;
        // Restore the old path
        *self.current_path.write().unwrap() = old_path;
        Ok(Mount { id })
    }

MountPath 是一条带父指针的路径,Display 成逗号分隔的数字:同一层的边界依次是 0、1、2,进入一个边界内部时换成它的 child(),从 0 重新数,所以 B 里面的 B.1 是 1,0。这里写进 HTML 的只是边界当前的内容(挂起时就是 fallback),HTML 里没有任何标记说「这里是挂载点 1」。客户端靠的是同样的发现顺序:水合时按遍历顺序把每个 suspense 边界记进 SuspenseHydrationIds,路径 [1, 0] 就是「第 2 个根边界的第 1 个子边界」。

首块的输出见下面实验的第二块:三个 fallback 和 footer,都带着正常的 data-node-hydration 编号,水合数据里这几个边界的槽位都是空的。

替换块:hidden 的 div 加一句 dx_hydrate

首块之后,render_to 一直循环到没有挂起的任务:

            while virtual_dom.suspended_tasks_remaining() {
                virtual_dom.wait_for_suspense_work().await;
                let resolved_suspense_nodes = virtual_dom.render_suspense_immediate().await;

                // Just rerender the resolved nodes
                for scope in resolved_suspense_nodes {
                    // ...
                    if let Some(pending_suspense_boundary) = pending_suspense_boundary {
                        let mut resolved_chunk = String::new();
                        // After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
                        let render_suspense = |into: &mut String| {
                            renderer.reset_hydration();
                            renderer.render_scope(into, &virtual_dom, scope)
                        };
                        let resolved_data = Self::serialize_server_data(&virtual_dom, scope);
                        if let Err(err) = stream.replace_placeholder(
                            pending_suspense_boundary.mount,
                            render_suspense,
                            resolved_data,
                            &mut resolved_chunk,
                        ) {
                            throw_error!(IncrementalRendererError::RenderError(err));
                        }

                        stream.render(resolved_chunk);
                        // Freeze the suspense boundary to prevent future reruns of any child nodes of the suspense boundary
                        if let Some(suspense) =
                            SuspenseContext::downcast_suspense_boundary_from_scope(
                                &virtual_dom.runtime(),
                                scope,
                            )
                        {
                            suspense.freeze();
                            // ...
                        }
                    }
                }
            }

render_suspense_immediate 返回这一轮从挂起变成完成的边界。每个边界单独成一块,由 replace_placeholder 写:<div id="ds-{路径}-r" hidden>,里面是这个边界现在的内容(reset_hydration 让 data-node-hydration 从 0 重新编号,每块各自独立认领),然后 <script>window.dx_hydrate([{路径}], "{base64}")</script>,base64 是 serialize_server_data 只收集这个边界之下的水合数据(第 0 项同样是错误槽位)。debug 构建多传两个参数,类型名和调用位置。全部完成后写 </body> 之后的模板,结束响应。

实验:分块到达的时间

curl --noproxy '*' -s --no-buffer --raw http://127.0.0.1:18800/stream,交给一个 Python 脚本按 chunked 编码切块,打印到达时间(相对脚本开始读)、块长度和内容(release 构建,原样):

+     0ms   742 '<!DOCTYPE html>\n<html>\n    <head>\n        <title>dioxus | ⛺</title>\n        <meta content="text/html;charset=utf-8" http-equiv="Content-Type">\n        <meta name="viewport" content="width=device-width, initial-scale=1">\n        <meta charset="UTF-8">\n    <link rel="preload" as="script" href="/./assets/exp-dioxus-fs2-app-dxh68d5ac7ae5534c7c.js" crossorigin></head>\n    <body>\n        <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])};\n</script>'
+     0ms   425 '<h1 data-node-hydration="0">streaming demo</h1><p data-node-hydration="1">loading A...</p><p data-node-hydration="2">loading B...</p><p data-node-hydration="3">loading C...</p><p data-node-hydration="4">footer</p><script>window.initial_dioxus_hydration_data="h4EY9ogYZxgvGHMYdBhyGGUYYRht9vb29vY=";</script></div>\n    <script type="module" async src="/./assets/exp-dioxus-fs2-app-dxh68d5ac7ae5534c7c.js"></script>\n            '
+   224ms   209 '<div id="ds-0-r" hidden><p class="slow" data-node-hydration="0"><!--node-id1-->A after 300ms<!--#--></p></div><script>window.dx_hydrate([0], "goEY9pIYoRhiGE8YaxhtGEEYIBhhGGYYdBhlGHIYIBgzGDAYMBhtGHM=")</script>'
+   524ms   420 '<div id="ds-2-r" hidden><!--placeholder0--></div><script>window.dx_hydrate([2], "gphDGHgYQRhlGHIYchhvGHIYIBhyGHUYbhhuGGkYbhhnGCAYcxhlGHIYdhhlGHIYIBhmGHUYbhhjGHQYaRhvGG4YOhggGGYYYRhpGGwYZRhkGCAYYRhmGHQYZRhyGCAYNhgwGDAYbRhzGCAYKBhkGGUYdBhhGGkYbBhzGDoYIBhOGG8YbhhlGCmYNhihGGMYRRhyGHIYoRhrGFMYZRhyGHYYZRhyGEUYchhyGG8YchiiGGcYbRhlGHMYcxhhGGcYZRhyGGYYYRhpGGwYZRhkGCAYYRhmGHQYZRhyGCAYNhgwGDAYbRhzGGQYYxhvGGQYZRgZARj0")</script>'
+   924ms   259 '<div id="ds-1-r" hidden><p class="slow" data-node-hydration="0"><!--node-id1-->B after 1000ms<!--#--></p><p data-node-hydration="2">loading B.1...</p></div><script>window.dx_hydrate([1], "g4EY9pMYoRhiGE8YaxhuGEIYIBhhGGYYdBhlGHIYIBgxGDAYMBgwGG0Yc/Y=")</script>'
+  1424ms   224 '<div id="ds-1,0-r" hidden><p class="slow" data-node-hydration="0"><!--node-id1-->B.1 after 1500ms<!--#--></p></div><script>window.dx_hydrate([1,0], "goEY9pUYoRhiGE8YaxhwGEIYLhgxGCAYYRhmGHQYZRhyGCAYMRg1GDAYMBhtGHM=")</script>'
+  1424ms    16 '</body>\n</html>\n'
+  1424ms  0 (end)

前两块几乎同时到:<head> 加 dx_hydrate 的定义是一块,首帧加水合数据加加载 wasm 的 <script> 是一块,和 Disabled 模式的分法一样,只是首帧里是 fallback。之后各块之间的间隔正好是 sleep 的差:A 到 C 300 毫秒(300 → 600),C 到 B 400 毫秒,B 到 B.1 500 毫秒。

  • 块的顺序是完成的顺序,不是页面上的顺序:0、2、1、1,0。
  • B.1 在 1500 毫秒完成,不是 1000 + 1500。嵌套的 B.1 不是等 B 好了才开始:挂起的边界在后台照样渲染子树,三个 sleep_echo 在同一毫秒开始(debug 构建的服务端日志:sleep_echo(300, A) start、sleep_echo(1000, B) start、sleep_echo(1500, B.1) start 都在 11:55:13.591)。
  • B 的替换块里,B.1 那一处又是 fallback(loading B.1...),B.1 自己的块是 ds-1,0-r。B 完成时 B.1 还挂着,回调把它当成新的挂载点,路径是 B 的 child()。
  • C 的替换块里是 <!--placeholder0-->:Fail 出错了,边界里没有内容,错误装在这一块水合数据的第 0 项里(后面讲)。

浏览器这一侧

window.dx_hydrate 是在首块里定义的(initialize_streaming.ts 编译后内联):

// Chunks may load before the wasm has loaded and the callback to hydrate the dom is registered. We queue up hydration ids and data until the wasm is ready
window.hydrate_queue = [];

// @ts-ignore
window.dx_hydrate = (
  id: number[],
  data: string,
  debug_types: string[] | null,
  debug_locations: string[] | null
) => {
  // First convert the base64 encoded string to a Uint8Array
  const decoded = atob(data);
  const 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]);
  }
};

替换块可能比 wasm 先到,先到的排进 hydrate_queue。wasm 水合完首块后,rehydrate 调 register_rehydrate_chunk_for_streaming_debug 登记 hydration_callback,并把队列里的逐个补上;回调把消息送进 web 主循环的通道,主循环调 WebsysDom::rehydrate_streaming。它做的事依次是:

  1. 按路径拼出 ds-1,0-r 这样的 id,getElementById 找到那个 hidden 的 div;找不到就报 ElementNotFound。
  2. 用 SuspenseHydrationIds 把路径换成客户端的 ScopeId;客户端没有对应的边界就报 SuspenseHydrationIdNotFound。
  3. 把 div 的子节点压进 interpreter 的栈,用这一块的数据造 HydrationContext;错误槽位里有错误就 throw_error 抛给这个边界。
  4. 在这个 HydrationContext 里对边界 resolve_suspense:边界的子树在客户端第一次真正渲染,use_server_future 从数据里取值;skip_mutations 让它只换掉占位,不重建节点。flush_edits 之后,原位的 fallback 被换成 div 里的那些服务端节点。
  5. 删掉空了的 div,从这个边界开始 start_hydration_at_scope,认领节点、挂监听器。

headless Chrome(--remote-debugging-port=18801,Node 22 的 CDP 脚本)打开 /stream-ok,3 秒后 #main 是(<script> 的内容省略成 …):

<div id="main" data-dioxus-id="0"><script>…</script><h1 data-node-hydration="0">streaming demo, no errors</h1><p class="slow" data-node-hydration="0">A after 300ms</p><p data-node-hydration="2">footer</p><script>…</script></div>

loading A... 被换成了替换块里的 <p class="slow">,节点上还留着替换块自己的编号 data-node-hydration="0",<!--node-id1--> 注释已被认领时删掉,ds-0-r 那个 div 不在了。

没有 JS 的客户端

同一个 /stream,CDP 先 Emulation.setScriptExecutionDisabled,再打开页面,取 body.innerText 和 body 的 HTML:

--- innerText of body ---
streaming demo

loading A...

loading B...

loading C...

footer
--- body ---
<body>
        <div id="main"><script>…</script><h1 data-node-hydration="0">streaming demo</h1><p data-node-hydration="1">loading A...</p><p data-node-hydration="2">loading B...</p><p data-node-hydration="3">loading C...</p><p data-node-hydration="4">footer</p><script>…</script></div>
    <script type="module" async="" src="/./assets/exp-dioxus-fs2-app-dxh68d5ac7ae5534c7c.js"></script>
            <div id="ds-0-r" hidden=""><p class="slow" data-node-hydration="0"><!--node-id1-->A after 300ms<!--#--></p></div><script>…</script><div id="ds-2-r" hidden=""><!--placeholder0--></div><script>…</script><div id="ds-1-r" hidden=""><p class="slow" data-node-hydration="0"><!--node-id1-->B after 1000ms<!--#--></p><p data-node-hydration="2">loading B.1...</p></div><script>…</script><div id="ds-1,0-r" hidden=""><p class="slow" data-node-hydration="0"><!--node-id1-->B.1 after 1500ms<!--#--></p></div><script>…</script>

</body>

内容都在文档里,但在 body 的末尾、#main 之外,而且带 hidden:用户看到的永远是 fallback。把 hidden 的 div 搬回原位的只有 wasm,没有任何 JS 做这件事,dx_hydrate 只是排队。只读原始 HTML 的爬虫能读到文字,但读不到它在页面上的位置。streaming.rs 开头的模块文档仍写着「Dioxus currently uses a the second out of order streaming approach which requires javascript」,那是 0.6 之前默认值还是乱序流式时的说法。

还有一处受影响的是 <head>:render_head 写完后调 document.start_streaming(),之后组件再插 document::Title、Meta 等,ServerDocument 打一条警告「Attempted to insert content into the head after the initial streaming frame」:<head> 已经随第一块发出去了,这些元素不会出现在服务端的 HTML 里。

freeze:之后的错误只能送到客户端

一个边界的替换块发出去以后,服务端调 SuspenseContext::freeze。之后这个边界下的 scope 在服务端不再重跑:should_run_during_suspense 对冻结的边界返回 false。原因很直接:这段 HTML 已经发出去了,再改也送不到浏览器。

错误也一样。默认情况下组件的错误往上冒到最近的错误边界,再往上到根错误边界,render_to 在首块之前检查根错误边界,有错就把整页换成状态码加一句话。首块之后这条路走不通了:状态码已经发了,上层的 HTML 也发了。所以每个首块里挂起的根级边界一开始就由 start_capturing_errors 在它的 scope 上放一个新的 ErrorContext,把错误截在边界自己这里;嵌套的子边界在父边界冻结时补上。错误进了这一块水合数据的第 0 项,客户端在第 3 步把它抛给对应的边界,让它在客户端继续往上冒。

实验里 C 的 Fail 在 600 毫秒时返回错误。curl 看到的是 200,C 的替换块是一个 <!--placeholder0--> 和一段较长的水合数据。浏览器里(CDP,release 构建,打开 /stream):

+  319ms [net] GET /stream
+  323ms [net<-] 200 /stream
+  331ms [net] GET /assets/exp-dioxus-fs2-app-dxh68d5ac7ae5534c7c.js
+  333ms [net<-] 200 /assets/exp-dioxus-fs2-app-dxh68d5ac7ae5534c7c.js
+  342ms [net] GET /assets/exp-dioxus-fs2-app_bg-dxh77b4c6ec9d228e.wasm
+  344ms [net<-] 200 /assets/exp-dioxus-fs2-app_bg-dxh77b4c6ec9d228e.wasm
+  365ms [console.log] INFO src/main.rs:72 Stream page renders color: whitesmoke; background: #444 color: gray; font-style: italic color: inherit
+  924ms [console.log] ERROR ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_arena.rs:111 Error while rendering component `exp_dioxus_fs2_app::Fail`: error running server function: failed after 600ms (details: None) color: red; background: #444 color: gray; font-style: italic color: inherit
+ 1823ms [console.log] ERROR ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-web-0.7.10/src/hydration/hydrate.rs:103 Rehydration failed. SuspenseHydrationIdNotFound color: red; background: #444 color: gray; font-style: italic color: inherit
--- innerText of body ---
error running server function: failed after 600ms (details: None)
--- #main ---
<div id="main" data-dioxus-id="0"><script>…</script><div style="color: red;"><pre>error running server function: failed after 600ms (details: None)</pre></div><script>…</script></div>

页面上没有 ErrorBoundary,客户端的错误一路冒到 VirtualDom 自带的根错误边界,整页换成它的默认渲染:一个红色的 <pre>。A、B 都没了,之后到达的 B.1 的块找不到自己的边界,报 SuspenseHydrationIdNotFound。HTTP 状态码是 200,而用户看到的是一页错误。

同一个页面在 Disabled 模式下(不设 FS2_MODE,debug 构建):

HTTP/1.1 500 Internal Server Error
content-length: 18

failed after 600ms
time_total=0.603793

600 毫秒时错误冒到根错误边界,整页变成 500 和一句消息,B(1000 毫秒)和 B.1(1500 毫秒)没等完就返回了。两种模式下这个错误都让整页失败,区别在于谁来告诉用户:Disabled 是服务端用状态码,OutOfOrder 是 wasm 在客户端换掉页面,没有 JS 的客户端会一直看着 loading C...。

在出错的边界外面放一个 ErrorBoundary(/stream-eb:A 一个边界,C 的 SuspenseBoundary 外面包 ErrorBoundary { handle_error: |e: ErrorContext| rsx! { p { id: "caught", "caught: {e:?}" } }, .. }),两种模式就都只坏这一块。Disabled 下 curl 拿到 200,错误在服务端就被接住、渲染进了首帧:

<h1 data-node-hydration="0">streaming with an error boundary</h1><p class="slow" data-node-hydration="1"><!--node-id2-->A after 300ms<!--#--></p><p id="caught" data-node-hydration="3"><!--node-id4-->caught: ErrorContext { error: RefCell { value: Some(CapturedError(error running server function: failed after 600ms (details: None))) } }<!--#--></p><p data-node-hydration="5">footer</p><script>…</script></div>

OutOfOrder 下 C 的替换块仍是 <!--placeholder0--> 加错误数据,ErrorBoundary 在首块里早已发出、冻结不了也改不了,错误要到浏览器里才被它接住。CDP 打开 /stream-eb 3 秒后的 #main:

<div id="main" data-dioxus-id="0"><script>…</script><h1 data-node-hydration="0">streaming with an error boundary</h1><p class="slow" data-node-hydration="0">A after 300ms</p><p id="caught">caught: ErrorContext { error: RefCell { value: Some(CapturedError(error running server function: failed after 600ms (details: None))) } }</p><p data-node-hydration="3">footer</p><script>…</script></div>

#caught 没有 data-node-hydration,它是客户端新建的节点;没有 JS 的客户端在这里看到的还是 loading C...。

📝NOTE
冷知识:最早,冻结的边界里出的错会让服务端 panic

issue #2570「Errors don't bubble through a SuspenseBoundary」报告的是:ErrorBoundary 包着 SuspenseBoundary,里面的组件返回错误时,fullstack 得到一个 unwrap 错误。先是 PR #2575 修掉 panic,它的描述说这种情况发生在「an error boundary is above a suspense boundary and the suspense boundary throws an error up into the client controlled error boundary」;接着 PR #2772「Bubble errors from the server to the client on suspense boundaries」给出现在的做法:「Instead of trying to rerun the error boundary that is frozen, we send the error to the client and continue bubbling the error there」。也就是上面 start_capturing_errors 截住错误、serialize_errors 写进第 0 项、rehydrate_streaming 里 throw_error 那三处。两个 PR 都在 2024 年合并,首次随 0.6.0-alpha.2 发布(正式版 0.6.0)。

和增量缓存一起用

开了 incremental 时,流式响应结束后 render_to 再用同一个 VirtualDom 渲染一遍完整的、不带替换块的页面存进缓存;下一次同一路径命中缓存时直接整页返回,不再流式。见增量渲染与配置。

❗IMPORTANT
动脑筋:/stream-ok 有几块?

/stream-ok 只有一个边界(A,300 毫秒),用上面的 release 构建、FS2_MODE=stream。curl --raw 看到的 HTTP 块一共几个(不算结尾的 0 长度块)?如果改成 FS2_MODE 为空(Disabled),又是几个?(M2 Max、macOS 27、rustc 1.99.0。)

动脑筋答案

同一个 chunks.py,两种模式各跑一次(重新编译过,资源文件名里的 hash 和上文不同;中间用 ... 截断)。每段前一行是 curl 的 -w 输出,ttfb 是收到第一个字节的时间:

FS2_MODE=stream /stream-ok
status=200 ttfb=0.002774 total=0.305469
+     0ms   742 '<!DOCTYPE html>\n<html>\n    <head>\n        <title>dioxus | ⛺</title>\n   ... hydrate_queue.push([id,bytes,debug_types,debug_locations])};\n</script>'
+     0ms   354 '<h1 data-node-hydration="0">streaming demo, no errors</h1><p data-node ... sets/exp-dioxus-fs2-app-dxhc2f438e65a4990f9.js"></script>\n            '
+   279ms   209 '<div id="ds-0-r" hidden><p class="slow" data-node-hydration="0"><!--no ... , "goEY9pIYoRhiGE8YaxhtGEEYIBhhGGYYdBhlGHIYIBgzGDAYMBhtGHM=")</script>'
+   279ms    16 '</body>\n</html>\n'
+   279ms  0 (end)
FS2_MODE= /stream-ok
status=200 ttfb=0.303684 total=0.303826
+   289ms   742 '<!DOCTYPE html>\n<html>\n    <head>\n        <title>dioxus | ⛺</title>\n   ... hydrate_queue.push([id,bytes,debug_types,debug_locations])};\n</script>'
+   289ms   439 '<h1 data-node-hydration="0">streaming demo, no errors</h1><p class="sl ... sets/exp-dioxus-fs2-app-dxhc2f438e65a4990f9.js"></script>\n            '
+   289ms    16 '</body>\n</html>\n'
+   289ms  0 (end)

OutOfOrder 是 4 块:head、首帧(A 处是 fallback)、A 的替换块、</body>。Disabled 是 3 块,和一次页面请求里看到的一样:head、完整的页面、</body>。

总时间都是 300 毫秒左右,差别在第一个字节:OutOfOrder 3 毫秒,Disabled 304 毫秒。Disabled 连 <head> 都要等 suspense 全部完成才写,因为组件还可能往里加 document::Title 之类;OutOfOrder 让浏览器在第 0 毫秒就拿到 head 和页面骨架,可以先去下载 wasm。

小结

  • ServeConfig::enable_out_of_order_streaming() 打开流式;首块在 commit_initial_chunk 之后发,router 在它上面没有挂起的边界时自动提交,没有 router 的应用不手动提交就等于不流式。首块一发,状态码和响应头就定了。
  • 首块里挂起的边界写 fallback,HTML 里没有挂载点标记;MountPath 按发现顺序编号(0、1、嵌套的 1,0),客户端按同样的顺序对应。
  • 每个边界完成时追加一块:<div id="ds-路径-r" hidden> 加这个边界的 HTML 和 dx_hydrate([路径], 数据),块的顺序是完成顺序;嵌套边界在后台和父边界并行。
  • 浏览器里 dx_hydrate 先排队,wasm 登记 hydration_callback 后把每块的节点搬到原位、用这块的数据跑边界、再认领;没有 JS 时内容一直在页面末尾藏着,用户只看到 fallback。
  • 发出去的边界被 freeze,不再在服务端重跑;之后的错误由边界自己的 ErrorContext 截住,写进这一块的数据,到客户端再抛。没有 ErrorBoundary 时,HTTP 200 的页面会被 wasm 整页换成错误。

💬 关联讨论

发起讨论
加载中...