Rust in Everything
基于 dioxus 0.7.10

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 或事件回调。

💬 关联讨论

发起讨论
加载中...