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:
| 场景 | 提供者 | 在哪 |
|---|---|---|
| 纯客户端 web | WebDocument | run 开头的 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")
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 在服务端跑,结果进水合数据,客户端直接用,不会在浏览器里再跑一遍。
#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("Err(Finished)")<!--#--></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。