document::eval 的三个坑
主线位置:更新主线上用户闭包(事件回调或 effect)里的一次旁路调用:Rust 把一段 JS 交给浏览器执行,结果经一对通道回来。首屏主线上,服务端渲染时同一个调用落到
NoOpDocument。eval的实现见document 与 eval的「eval:一段 JS,一对通道」和「在服务端调 eval」。
flowchart TB
ev["document::eval(js)"] --> where{"在哪执行"}
where -->|服务端渲染| srv["NoOpDocument"]
srv --> fin["Err(Finished)"]
where -->|浏览器| wrap["拼进 async function"]
wrap --> compile{"new Function 编译"}
compile -->|语法错误| boom["异常抛穿 wasm"]
boom --> stuck["elements 借用不归还"]
compile -->|成功| run["执行用户代码"]
run -->|return| leak["跳过 dioxus.close"]
run -->|走到结尾| close["dioxus.close 释放通道"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0、Chrome 156 上运行,依赖 dioxus = "=0.7.10"(router、fullstack),dx 0.7.9 --release 构建的 fullstack 应用。headless Chrome 经 DevTools Protocol 打开页面、点按钮、读 globalThis.__channels。
现象
一个页面放齐三种用法:use_server_future 里调 eval(服务端渲染时执行);按钮里连续调 10 次带 return 的 eval;按钮里执行一段写错的 JS。另有一个 #add 按钮往列表里加一行,用来触发「需要新建元素」的渲染:
#[component]
fn Ev() -> Element {
let mut n = use_signal(|| 0);
let mut rows = use_signal(|| 0);
let mut out = use_signal(String::new);
let server_eval = use_server_future(move || async move {
format!("{:?}", document::eval("return 1 + 1;").await)
})?;
// ...
rsx! {
div { id: "count", "count = {n}" }
div { id: "srv", "server eval: {server_eval():?}" }
button { id: "inc", onclick: move |_| n += 1, "+" }
button { id: "add", onclick: move |_| rows += 1, "add a row" }
ul { id: "rows",
for i in 0..rows() {
li { key: "{i}", "row {i}" }
}
}
button { id: "with_return",
onclick: move |_| async move {
let mut sum = 0;
for _ in 0..10 {
sum += document::eval("return 1;").join::<i32>().await.unwrap_or(0);
}
out.set(format!("with return: sum = {sum}"));
},
"10 evals with return"
}
button { id: "bad_js",
onclick: move |_| async move {
let r = document::eval("this is not js").await;
out.set(format!("bad js: {r:?}"));
},
"bad js"
}
// ...
}
}
release 构建,水合后依次操作(live of N 是 __channels 里还没被置空的项数和总数):
#srv: server eval: Some("Err(Finished)")
...
channels at start: 1 live of 1
...
after 10 evals with return: 11 live of 21 | with return: sum = 10
after a forced GC: 11 live of 21
...
--- invalid JS in the eval string ---
click #bad_js -> click() returned normally
[console.error] wasm-bindgen: imported JS function that was not marked as `catch` threw an error: Unexpected identifier 'is'
[exception] SyntaxError: Unexpected identifier 'is'
...
after clicking #inc: count = 2
[exception] RuntimeError: unreachable
after clicking #add: rows = 1
...
after clicking #inc again: count = 2
(... 处是下面修法里那几个按钮的输出,它们按顺序穿插在同一次运行里,完整输出在修法一节。点 #bad_js 之前计数已经是 1、列表已经有 1 行。)
- 服务端:
#srv是Err(Finished),这个值在服务端算出、进了水合数据,客户端直接用。 return:10 次都拿到了返回值(和是 10),但留下了 10 个活着的通道;CDP 强制 GC 之后还在。- 写错的 JS:点击本身正常返回,控制台一条
SyntaxError。接着点#inc,计数照样从 1 变成 2,看起来没事;点#add要新加一个<li>,抛出RuntimeError: unreachable(wasm 里的 panic),列表没变;之后再点#inc,计数停在 2,应用不再响应。
出处
return 为什么泄漏。 web 上的 WebEvaluator::create 把用户代码拼进 PROMISE_WRAPPER:
const PROMISE_WRAPPER: &str = r#"
return (async function(){
{JS_CODE}
dioxus.close();
})();
"#;
通道对象构造时把自己放进 globalThis.__channels,只有 close() 才把那一项置空。用户代码 return 了,就走不到 dioxus.close(),通道连同它持有的 Rust 侧求值器一直留着。
写错的 JS 为什么让应用停住。 编译用户代码的是 Function::new_with_args,它在 js-sys 里没有标 catch,SyntaxError 不会变成 Err,而是作为 JS 异常直接抛穿 wasm。这发生在 async 回调被第一次 poll 的同步部分里,也就是 Runtime::handle_event 还没返回的时候。wasm 的栈被 JS 异常直接展开,Rust 的析构函数不执行,handle_event 开头那个 self.elements.borrow() 借出的 Ref 永远不归还。之后只读这个 RefCell 的事(处理事件、改文本)还能做;第一次要分配新 ElementId 的渲染走到 next_element 的 borrow_mut(),panic,主循环随之终止。debug 构建的 panic 信息是 RefCell already borrowed(见document 与 eval),release 只剩 RuntimeError: unreachable。create 里 Err(err) => ... EvalError::InvalidJs(..) 那个分支只接 call1 的错误,而调用 async function 不会同步抛错,所以 InvalidJs 在这条路上永远拿不到。
服务端为什么是 Finished。 服务端渲染时 document() 是 ServerDocument,它的 eval 转给 NoOpDocument::eval。那里的 Owner 是局部变量,函数返回时就 drop 了,Eval::join 第一步 try_write() 失败,返回 EvalError::Finished,而不是看起来更合理的 Unsupported。如果把它放进 use_server_future,这个错误还会被写进水合数据,浏览器拿到的也是 Err(Finished),不会自己再跑一遍。
修法
要返回值,用 dioxus.send 加 recv,让代码自然走到结尾。 同一页的 #with_send:
button { id: "with_send",
onclick: move |_| async move {
let mut sum = 0;
for _ in 0..10 {
sum += document::eval("dioxus.send(1);").recv::<i32>().await.unwrap_or(0);
}
out.set(format!("with send: sum = {sum}"));
},
"10 evals with send"
}
动态的代码不要拼进 eval 的字符串,当数据传进去,在 JS 里 try 着编译。 外层的 JS 是固定写死的,不会有语法错误;用户的代码作为字符串经 send 进去,new Function 抛的 SyntaxError 在 JS 里就被接住:
button { id: "bad_js_fix",
onclick: move |_| async move {
// the code arrives as data; JS compiles it inside a try
let user_code = "this is not js";
let mut ev = document::eval(
r#"
const code = await dioxus.recv();
try {
dioxus.send({ ok: String(new Function(code)()) });
} catch (e) {
dioxus.send({ err: String(e) });
}
"#,
);
let _ = ev.send(user_code);
let r = ev.recv::<serde_json::Value>().await;
out.set(format!("bad js (fixed): {r:?}"));
},
"bad js fixed"
}
浏览器里才有意义的 eval 放进 use_effect(或事件回调),不要放进会在服务端执行的地方。 effect 只在客户端、水合之后执行:
let mut client_eval = use_signal(String::new);
use_effect(move || {
spawn(async move {
let r = document::eval("return 1 + 1;").await;
client_eval.set(format!("{r:?}"));
});
});
同一次运行的完整输出(顺序是:读 #srv、#cli,点 #with_send,点 #with_return,强制 GC,点 #bad_js_fix,点 #inc、#add,然后才是 #bad_js):
[navigated] /ev
[navigatedWithinDocument] /ev
#srv: server eval: Some("Err(Finished)")
#cli: client eval in use_effect: Ok(Number(2))
channels at start: 1 live of 1
after 10 evals with send: 1 live of 11 | with send: sum = 10
after 10 evals with return: 11 live of 21 | with return: sum = 10
after a forced GC: 11 live of 21
--- invalid JS, passed as data (fix) ---
#evalout: bad js (fixed): Ok(Object {"err": String("SyntaxError: Unexpected identifier 'is'")})
after clicking #inc and #add: count = 1 rows = 1
--- invalid JS in the eval string ---
click #bad_js -> click() returned normally
[console.error] wasm-bindgen: imported JS function that was not marked as `catch` threw an error: Unexpected identifier 'is'
[exception] SyntaxError: Unexpected identifier 'is'
#evalout: bad js (fixed): Ok(Object {"err": String("SyntaxError: Unexpected identifier 'is'")})
after clicking #inc: count = 2
[exception] RuntimeError: unreachable
after clicking #add: rows = 1
after clicking #inc again: count = 2
channels at start的那 1 个活通道就是use_effect里那次带return的 eval。#with_send的 10 次拿到了同样的和,活通道数没变;#with_return多出 10 个。#bad_js_fix把语法错误当作普通结果拿回来,之后#inc、#add都正常(rows = 1)。#cli是Ok(Number(2)):同样一句return 1 + 1;,在浏览器里执行就有结果。
怎么确认没中招
- 在浏览器控制台里看通道数:
globalThis.__channels.filter((x) => x).length。反复做同一个操作,这个数一直涨,就是有 eval 在return。 - 搜
document::eval(和eval(:参数是format!拼出来的,要确认拼进去的东西不可能让 JS 语法出错(引号、换行、用户输入),否则改成send传数据。 - 搜
use_server_future、组件函数体、use_hook里的 eval:它们会在服务端执行,只能得到Err(Finished)。 - 控制台里出现
imported JS function that was not marked as \catch` threw an error`,说明有 JS 异常穿过了 wasm;这之后应用随时可能停住,即使眼下还在响应。
小结
- eval 的 JS 里写
return会跳过包装代码末尾的dioxus.close(),通道和 Rust 侧求值器永不释放;要返回值用dioxus.send加recv。 - JS 语法错误不会变成
EvalError::InvalidJs,而是抛穿 wasm,留下一个永不归还的RefCell借用;下一次要建新元素的渲染 panic,应用停住。动态代码当数据传进固定的 JS,在 JS 里try。 - 服务端渲染时 eval 得到的是
Err(Finished),放进use_server_future还会把这个错误带到客户端。浏览器专用的 eval 放进use_effect或事件回调。