Rust in Everything
基于 dioxus 0.7.10

document 与 eval

主线位置:首屏主线上「render_head」那一步(服务端把组件声明的 <title>、<meta> 等写进 <head>),和它在客户端的另一半:水合时这些元素不再插一遍,水合之后新挂载的再由浏览器插入。document::eval 不在三条主线上,它是组件和页面里的 JS 互相传消息的通道。

flowchart TB
  comp["document::Meta 等"] --> hook["use_hook 首次渲染"]
  hook --> doc["document 取 context"]
  doc --> srv["ServerDocument"]
  doc --> fweb["FullstackWebDocument"]
  doc --> web["WebDocument"]
  srv --> collect["收集到 Vec"]
  collect --> head["render_head 写进 head"]
  srv --> entry["水合数据记 true"]
  fweb --> check{"服务端写过?"}
  check -->|是| skip["跳过"]
  check -->|否| web
  web --> effect["queue_effect 里插入 head"]

源码在 packages/document/src(Document trait、各组件、Eval)、packages/web/src/document.rs(浏览器实现)、packages/fullstack-server/src/document.rs(服务端实现)和 packages/fullstack-core/src/document.rs(水合用的包装)。实验:原生程序 exp-dioxus/web-base;fullstack 应用 exp-dioxus/web-fs(dx build --platform web --fullstack,debug 和 release 各一份),curl --noproxy '*' 取服务端 HTML,headless Chrome 156 + Node 22 CDP 脚本看水合以后的 DOM。平台 M2 Max、macOS 27、rustc 1.99.0。

Document:每个平台一个实现,从 context 里取

Document trait 只有 eval 是必须实现的,其余方法都有默认实现:set_title 默认是 eval("document.title = ..."),create_meta / create_link / create_script / create_style 默认拼一段 head.js 的 JS 交给 eval。还有一个 create_head_component(&self) -> bool,默认 true,水合靠它(后面一节)。

组件拿 Document 的方式是 document():

pub fn document() -> Rc<dyn Document> {
    match dioxus_core::try_consume_context::<Rc<dyn Document>>() {
        Some(document) => document,
        None => {
            tracing::error!(
                "Unable to find a document in the renderer. Using the default no-op document."
            );
            Rc::new(NoOpDocument)
        }
    }
}

谁提供这个 context:

场景提供者在哪
纯客户端 webWebDocumentrun 开头的 init_document
fullstack 客户端(hydrate feature)FullstackWebDocument<WebDocument>水合前在根 scope 里 init_fullstack_document
fullstack 服务端ServerDocument每个请求的 VirtualDom 根上(一次页面请求 第 3 节)
自己 new 的 VirtualDom没有退回 NoOpDocument,什么都不做

最后一种用原生程序看:

fn app() -> Element {
  use_hook(|| {
    spawn(async {
      let r = document::eval("return 1 + 1;").await;
      println!("eval in a plain VirtualDom: {r:?}");
      use dioxus::document::Document as _;
      let r = dioxus::document::NoOpDocument.eval("return 1;".into()).await;
      println!("NoOpDocument.eval: {r:?}");
    })
  });
  rsx! {
    document::Title { "hello" }
    document::Meta { name: "description", content: "x" }
    p { "body" }
  }
}

rebuild_in_place 之后给 wait_for_work 50 毫秒让任务跑完,再 dioxus_ssr::render(debug 构建,装了 tracing_subscriber):

ERROR Unable to find a document in the renderer. Using the default no-op document.
ERROR Unable to find a document in the renderer. Using the default no-op document.
ERROR Unable to find a document in the renderer. Using the default no-op document.
eval in a plain VirtualDom: Err(Finished)
NoOpDocument.eval: Err(Finished)
ssr: <p>body</p>

Title、Meta 和 eval 各取一次 document,各报一次错。三个 head 组件都返回 VNode::empty(),dioxus_ssr::render 里只剩 <p>:head 元素不在组件树里,只能通过 Document 出去。eval 的错误是 Finished 而不是 Unsupported,原因在本页最后一节。

五个组件:都在第一次渲染时调一次

Meta 是最简单的一个:

pub fn Meta(props: MetaProps) -> Element {
    use_update_warning(&props, "Meta {}");

    use_hook(|| {
        let document = document();
        let insert_link = document.create_head_component();

        if !insert_link {
            return;
        }

        document.create_meta(props);
    });

    VNode::empty()
}
  • 用的是 use_hook,只在组件第一次渲染时执行;之后 props 怎么变都不会再写 head。use_update_warning 在 debug 构建里记下第一次的 props,以后每次渲染比较一次,不同就打一条「Changing the props of … is not supported」的警告。
  • 不用 use_effect,因为 effect 在服务端不跑,而服务端恰恰要在渲染时收集这些元素。
  • 组件被卸载时也没有任何清理:插进 head 的元素留在那里。document/docs/head.md 的「Limitations」一节写明了这两点,以及 head 里的顺序不保证。
  • 渲染结果是 VNode::empty(),DOM 里不留占位。

Link、Script、Style 结构相同,多一步去重:根 scope 上放一个 DeduplicationContext(一个 HashSet<String>),Link 的键是 "{href}|{rel}"(should_insert_link),Script 是 src,Style 是 href;没有 href / src 的内联脚本和样式不去重。注意顺序:先调 create_head_component(),再查去重,所以被去重掉的那个也在水合数据里占一个条目(下文实验里两个相同的 Link 各有一个条目)。Style 有 href 而没有内容时,转成一个 rel="stylesheet" 的 create_link。Script 和 Style 的内容必须是单个文本节点(extract_single_text_node),否则只打一条错误日志。

Title 是唯一能更新的:

    // Update the title as it changes. NOTE: We don't use use_effect here because we need this to run on the server
    let document = use_hook(document);
    let last_text = use_hook(|| {
        // Set the title initially
        document.set_title(text.clone());
        Rc::new(RefCell::new(text.clone()))
    });

    // If the text changes, update the title
    let mut last_text = last_text.borrow_mut();
    if text != *last_text {
        document.set_title(text.clone());
        *last_text = text;
    }

每次渲染都比较文字,变了才 set_title。

浏览器上:在 effect 里直接改 head

WebDocument 把每个方法都包进 queue_effect:

    /// Set the title of the document
    fn set_title(&self, title: String) {
        let myself = self.clone();
        queue_effect(move || {
            myself.eval(format!("document.title = {title:?};"));
        });
    }

    /// Create a new meta tag in the head
    fn create_meta(&self, props: MetaProps) {
        queue_effect(move || {
            _ = append_element_to_head("meta", &props.attributes(), None);
        });
    }

组件在渲染时调 create_meta,真正的 DOM 操作推迟到 effect:等这一轮渲染完、flush_edits 之后才跑(effect 的时机见 调度)。Document trait 的默认 create_head_element 上有一句注释说明了为什么:「The element should only be created inside an effect so it is not called while the component is suspended」,组件所在的 suspense 边界还没好时,这一轮渲染的结果可能被丢掉。append_element_to_head 直接用 web_sys 建元素、设属性、设 text_content、head.append_child,不经过 interpreter,也不走 eval。标题例外,仍然是 eval 一段 document.title = "...";(字符串用 Rust 的 {:?} 转义)。

服务端:ServerDocument 收集,render_head 输出

ServerDocument 是一个 RefCell 里的几个列表:

#[derive(Default)]
struct ServerDocumentInner {
    streaming: bool,
    title: Option<String>,
    meta: Vec<Element>,
    link: Vec<Element>,
    script: Vec<Element>,
}

set_title 只记最后一次的文字。create_meta / create_link / create_script 各自用 rsx! 拼出一个 meta / link / script 元素存进对应的列表;create_style 拼的是 style 元素,存进的却也是 script 列表(源码)。create_link 的 rsx! 里没有 onload,LinkProps::onload 在服务端渲染的 HTML 里不会出现。

render_head 在服务端等完 suspense、开始输出之前调用,写 index.html 里 <title> 之前的部分、标题(ServerDocument 里有就用 title() 渲染出的 <title>,否则用模板的)、<title> 之后的部分,再 render 按 meta、link、script 的顺序把收集到的元素用 dioxus_ssr 渲染出来,最后 document.start_streaming()。之后再有 head 组件调进来(流式渲染里晚到的 suspense 边界),warn_if_streaming 打一条警告「Attempted to insert content into the head after the initial streaming frame...」,元素已经来不及进 <head>,交给客户端去插(流式见 流式渲染)。

实验应用(web-fs):

fn app() -> Element {
  let mut n = use_signal(|| 0);
  let mut eval_out = use_signal(String::new);
  // runs on the server during SSR; the client reuses the serialized value
  let server_eval = use_server_future(move || async move {
    format!("{:?}", document::eval("return 1 + 1;").await)
  })?;
  rsx! {
    document::Title { "count {n}" }
    document::Meta { name: "description", content: "web-fs experiment" }
    document::Link { rel: "icon", href: "/favicon.ico" }
    document::Link { rel: "icon", href: "/favicon.ico" }
    document::Style { "#srv {{ color: teal; }}" }
    document::Script { "window.__script_runs = (window.__script_runs || 0) + 1;" }
    div { id: "srv", "server eval: {server_eval():?}" }
    button { id: "inc", onclick: move |_| n += 1, "+" }
    if n() > 0 {
      Late {}
    }
    // ... 几个调 eval 的按钮,见最后一节
  }
}

#[component]
fn Late() -> Element {
  rsx! {
    document::Meta { name: "late", content: "mounted after hydration" }
    document::Link { rel: "icon", href: "/favicon.ico" }
    span { id: "late", "late" }
  }
}

curl 拿到的 <head>(debug 构建;dx 开发模板里那段 toast 的 <style> 和 <script> 很长,换成了注释):

<head>
        <title>count 0</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">
        <style>/* dx toast css … */</style>
        <script>/* dx toast js … */</script>
    <meta name="description" content="web-fs experiment"/><link rel="icon" href="/favicon.ico"/><style>#srv { color: teal; }</style><script>window.__script_runs = (window.__script_runs || 0) + 1;</script></head>

模板的 <title>dioxus | ⛺</title> 换成了 count 0;两个相同的 Link 只出来一个;style 和 script 排在 link 后面,两者之间按创建顺序。

水合:一个 bool 条目,避免重复插入

浏览器拿到这份 HTML 时,<head> 里已经有这些元素了。客户端水合时组件照样要跑一遍、照样会调 create_meta,如果 WebDocument 再插一份就重复了。办法是服务端在每个 head 组件调 create_head_component 时往水合数据里写一个 bool:

    /// Write the head element into the serialized context for hydration
    /// We write true if the head element was written to the DOM during server side rendering
    #[track_caller]
    pub(crate) fn serialize_for_hydration(&self) {
        // We only serialize the head elements if the web document feature is enabled
        #[cfg(feature = "document")]
        {
            dioxus_fullstack_core::head_element_hydration_entry()
                .insert(&!self.0.borrow().streaming, std::panic::Location::caller());
        }
    }

true 表示「首块里已经输出到 <head> 了」,流式阶段才挂载的组件写 false。客户端的 FullstackWebDocument 把所有方法转给里面的 WebDocument,只改了这一个:

    fn create_head_component(&self) -> bool {
        !head_element_written_on_server()
    }

head_element_written_on_server 用 head_element_hydration_entry 取下一个条目,取不到(.get() 出错)就当 false。条目和 use_server_future 的结果在同一个序列里按创建顺序排,所以服务端和客户端的调用顺序必须一致(见 水合)。水合结束以后,serialize_context 返回一个 suspense_finished: true 的空上下文,get 一律是 DataNotAvailable,于是后来挂载的 head 组件都会被插入。Title 不在这套机制里:它没有调 create_head_component,客户端照样 set_title 一次,写的是同一个值,没有副作用。

debug 构建在页面里附带了每个条目的类型和调用位置,能直接看到这些条目(摘自同一份 HTML 末尾的 <script>,路径前缀 ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/ 换成了 …/):

window.initial_dioxus_hydration_debug_types=["core::option::Option<dioxus_core::render_error::CapturedError>","unknown","unknown","unknown","unknown","unknown","alloc::string::String","unknown","bool","unknown","bool","unknown","bool","unknown","bool","unknown","bool"];
window.initial_dioxus_hydration_debug_locations=["…/dioxus-server-0.7.10/src/ssr.rs:556:29","unknown","unknown","unknown","unknown","unknown","src/main.rs:7:21","unknown","…/dioxus-server-0.7.10/src/document.rs:163:14","unknown","…/dioxus-server-0.7.10/src/document.rs:163:14","unknown","…/dioxus-server-0.7.10/src/document.rs:163:14","unknown","…/dioxus-server-0.7.10/src/document.rs:163:14","unknown","…/dioxus-server-0.7.10/src/document.rs:163:14"];

5 个 bool 条目,位置都是 ServerDocument::create_head_component 里调 serialize_for_hydration 的那一行,对应 Meta、两个 Link(第二个虽然被去重,条目照写)、Style、Script;src/main.rs:7:21 那个 String 是 use_server_future 的结果。其余 unknown 条目不是本页的内容,见 水合。

水合之后用 CDP 读 document.head(同样略去 toast 的两段),再点两次 #inc:

--- head after hydration (dx toast style/script omitted) ---
<title>count 0</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">
<meta name="description" content="web-fs experiment">
<link rel="icon" href="/favicon.ico">
<style>#srv { color: teal; }</style>
<script>window.__script_runs = (window.__script_runs || 0) + 1;</script>
document.title = "count 0"  __script_runs = 1
--- click #inc twice (Title changes, Late mounts) ---
document.title = "count 1"  <title> elements: 1
document.title = "count 2"
<title>count 2</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">
<meta name="description" content="web-fs experiment">
<link rel="icon" href="/favicon.ico">
<style>#srv { color: teal; }</style>
<script>window.__script_runs = (window.__script_runs || 0) + 1;</script>
<meta name="late" content="mounted after hydration">
  • 水合以后 <head> 和服务端送来的完全一样,没有重复;内联脚本只执行了一次(__script_runs = 1)。
  • Title 的文字变了,改的是同一个 <title>(始终只有 1 个)。
  • 水合之后才挂载的 Late 里的 Meta 被插到 <head> 末尾;它的 Link 和根组件里的那个 href|rel 相同,被去重,没有插。

debug 构建里每次点击还多出两条警告:

[console.log] WARN …/dioxus-document-0.7.10/src/elements/mod.rs:34 Changing the props of `Style {}` is not supported 
[console.log] WARN …/dioxus-document-0.7.10/src/elements/mod.rs:34 Changing the props of `Script {}` is not supported 

Style 和 Script 的内容是 children: Element,VNode 的 PartialEq 是 Rc::ptr_eq,父组件每重跑一次,children 都是新的 VNode,use_update_warning 就判定 props 变了,哪怕内容一字未改。Meta、Link 的 props 全是字符串,不会报。release 构建没有这段检查,也没有这两条警告。

eval:一段 JS,一对通道

document::eval 是 document().eval(script.to_string()),返回 Eval:一个 GenerationalBox<Box<dyn Evaluator>>,所以是 Copy 的。它有三个方法:send(发给 JS)、recv().await(收 JS 发来的)、join().await(等 JS 的返回值),值都先转成 serde_json::Value 再按需反序列化(serde 的部分见 serde)。

web 上的 WebEvaluator::create:

        let owner = UnsyncStorage::owner();

        // add the drop handler to DioxusChannel so that it gets dropped when the channel is dropped in js
        let channels = WebDioxusChannel::new(JSOwner::new(owner.clone()));

        // The Rust side of the channel is a weak reference to the DioxusChannel
        let weak_channels = channels.weak();

        // Wrap the evaluated JS in a promise so that wasm can continue running (send/receive data from js)
        let code = PROMISE_WRAPPER.replace("{JS_CODE}", &js);

        let result = match Function::new_with_args("dioxus", &code).call1(&JsValue::NULL, &channels)
const PROMISE_WRAPPER: &str = r#"
    return (async function(){
        {JS_CODE}

        dioxus.close();
    })();
"#;
  • 用户的 JS 被拼进一个 async function,用 new Function("dioxus", code) 编译,以 WebDioxusChannel 对象作为参数 dioxus 调用。JS 里的 dioxus.send(x) / await dioxus.recv() 就是这个对象上两个方向的 Channel(一个数组当队列,一个数组存等待者)。
  • 调用立即返回一个 Promise,wasm 不阻塞;join 等这个 Promise,JSON.stringify 结果再解析成 serde_json::Value。
  • Rust 侧只拿着通道的弱引用(WeakDioxusChannel)。通道对象持有 JSOwner,JSOwner 持有 Eval 的那个 Owner:JS 那边的通道被回收时,Rust 那边的求值器跟着释放。

点 #roundtrip 按钮:

        let mut ev = document::eval(
          r#"
            const x = await dioxus.recv();
            dioxus.send(x * 10);
            return "done " + x;
          "#,
        );
        ev.send(4).unwrap();
        let got: i32 = ev.recv().await.unwrap();
        let joined = ev.join::<String>().await;
        eval_out.set(format!("recv = {got}, join = {joined:?}"));
#evalout: recv = 40, join = Ok("done 4")
📝NOTE
冷知识:dioxus.close() 是为了不让通道被提前回收才加的

issue #2979(「Eval suddenly stops」):一个循环里反复 recv 的 eval,跑一会儿就自己结束了。PR #3877(「Fix: Eval Prematurely Dropping」)的说明是「The eval channel was being garbage collected even though the user's code should still have a hard reference to it」,作者没找到根因,「decided it'd be quicker to implement a system where the channel is only dropped when we manually dioxus.close(); it. The close method is already called internally and won't need to be called by the user.」于是 ts/eval.ts 多了 globalThis.__channels:每个 WebDioxusChannel 构造时把自己存进这个全局数组,close() 时置成 null,强引用一直保持到 close。首次随 0.7.0-alpha.0 发布,0.6.x 没有这个改动。

「内部会调 close」指的是 PROMISE_WRAPPER 里用户代码后面那一行。用户代码自己 return 了,就走不到那一行;而 return 正是取返回值的写法,eval.md 文档里的第一个例子就是 return "Hello World";。实验:页面上两个按钮,各调 10 次 document::eval,一个是 "return 1;",一个是 "1;",前后数 globalThis.__channels 里非空的项:

--- channels left in globalThis.__channels ---
before: 1 live of 4
after 10 evals without return: 1 live of 14
after 10 evals with return: 11 live of 24
after a forced GC: 11 live of 24

开始时的 4 个是 3 次标题更新(count 0、1、2,没有 return,已关闭)和 #roundtrip 那个(return "done " + x,还活着)。不带 return 的 10 个都关了;带 return 的 10 个全留在数组里,CDP 的 HeapProfiler.collectGarbage 之后还在,连同它们持有的 JSOwner 和 Rust 侧的求值器。release 构建的数字相同。每次 return 都留下一个通道,次数多了就是一个缓慢的泄漏;需要返回值又在意这一点的话,可以改用 dioxus.send(值) 加 recv,让代码自然走到结尾。

JS 写错了是另一种情况。Function::new_with_args 在 js-sys 里没有标 catch,语法错误不会变成 Err,而是作为 JS 异常直接抛穿 wasm。create 里匹配 call1 结果的那个 Err(err) => ... EvalError::InvalidJs(..) 分支只接 call1 的错误,而调用一个 async function 不会同步抛错,所以这个分支实际上走不到。点一个执行 document::eval("this is not js").await 的按钮(cdp_badjs.mjs,路径前缀同样换成了 …/):

click #bad_js -> click() returned normally
[exception] SyntaxError: Unexpected identifier 'is'
#evalout: ""
...
[console.error] panicked at …/dioxus-core-0.7.10/src/arena.rs:59:50:
RefCell already borrowed
...
[exception] RuntimeError: unreachable
after clicking #inc: title = "count 0"
after #roundtrip: #evalout = ""

异常发生在事件回调同步执行的那一段(async 回调第一次 poll),从 handle_event 里一直抛到浏览器。wasm 的栈被 JS 异常直接展开,Rust 的析构函数没有执行,Runtime::handle_event 开头借出的 elements 没有归还;下一次点击 #inc 时,渲染要分配 ElementId(arena.rs:59 的 next_element)去 borrow_mut 同一个 RefCell,panic,之后应用不再响应。只改已有文本节点、不新建元素的渲染不经过 next_element,还能照常更新,要等第一次新建元素才停住(常见坑:eval 里有这组对照)。release 构建同样停止响应(只剩 SyntaxError 和 RuntimeError: unreachable 两条异常,没有 panic 信息)。这和 事件委托 冷知识里 #4079 的「website stops responding」是同一类问题:事件回调里任何一个没有 catch 的 JS 异常都会让整个应用停住。

在服务端调 eval

ServerDocument::eval 直接转给 NoOpDocument:

impl Document for NoOpDocument {
    fn eval(&self, _: String) -> Eval {
        let owner = generational_box::Owner::default();
        struct NoOpEvaluator;
        impl Evaluator for NoOpEvaluator {
            fn poll_join(
                &mut self,
                _: &mut std::task::Context<'_>,
            ) -> std::task::Poll<Result<serde_json::Value, EvalError>> {
                std::task::Poll::Ready(Err(EvalError::Unsupported))
            }
            // ...
        }
        Eval::new(owner.insert(Box::new(NoOpEvaluator)))
    }

NoOpEvaluator 的三个方法都返回 EvalError::Unsupported。实验应用在 use_server_future 里调 document::eval("return 1 + 1;").await,把结果 {:?} 成字符串:服务端渲染时这个 future 在服务端跑,结果进水合数据,客户端直接用,不会在浏览器里再跑一遍。

❗IMPORTANT
动脑筋:#srv 里显示的是什么?

server_eval 是一个 Resource<String>,页面上的文字是 "server eval: {server_eval():?}"。服务端的 document::eval(...) 返回 Unsupported、Finished,还是别的?(dx build --platform web --fullstack,debug 和 release 都试。)

动脑筋答案

curl 拿到的那一段 HTML,debug 和 release 一样:

<div id="srv" data-node-hydration="6"><!--node-id7-->server eval: Some(&#34;Err(Finished)&#34;)<!--#--></div>

是 Err(Finished),不是 Unsupported。NoOpDocument::eval 里的 owner 是局部变量,函数返回时被 drop,它拥有的那个 GenerationalBox 随之失效。Eval::join 第一步 self.evaluator.try_write() 就失败了,走的是 Err(_) => Poll::Ready(Err(EvalError::Finished)),NoOpEvaluator::poll_join 根本没被调用;send 和 recv 同理。原生程序里的 NoOpDocument.eval 打印的也是 Err(Finished)。Finished 的 Display 是「eval has already ran」,在服务端看到它,意思其实是「这里不能 eval」。

小结

  • 组件通过 document() 从 context 取 Rc<dyn Document>:纯 web 是 WebDocument,fullstack 客户端是包了一层的 FullstackWebDocument,服务端是 ServerDocument,取不到就用什么都不做的 NoOpDocument 并打错误日志。
  • Meta、Link、Script、Style 只在第一次渲染时(use_hook)写一次,之后不更新、卸载也不删除;Link、Script、Style 按 href|rel / src / href 去重。Title 每次渲染比较文字,变了就更新。debug 下 Style、Script 每次父组件重跑都会报一条「Changing the props」的警告。
  • 浏览器上,WebDocument 在 queue_effect 里用 web_sys 直接往 <head> 追加元素;服务端,ServerDocument 收集成 Element,render_head 在首块里输出。
  • 水合时每个 head 组件对应水合数据里一个 bool,服务端写过就跳过,实测 <head> 没有重复、内联脚本只执行一次;水合之后挂载的组件照常插入。
  • eval 把 JS 包进 async function,用一个 WebDioxusChannel 做 dioxus.send / recv。自 #3877 起通道存在 globalThis.__channels 里直到代码执行到末尾的 dioxus.close(),用 return 结束的 eval 永远不关闭;JS 语法错误不会变成 EvalError::InvalidJs,而是抛穿 wasm、让应用停止响应。
  • 服务端调 eval 得到的是 Err(Finished):NoOpDocument::eval 返回时 Owner 已经被 drop。

💬 关联讨论

发起讨论
加载中...