Rust in Everything
基于 dioxus 0.7.10

TypeError: Cannot convert undefined or null to object

主线位置:更新主线的「diff → WriteMutations → interpreter 批量改 DOM」。diff 发出一条 RemoveEventListener,interpreter 执行它时把同一元素上其他监听器也一起弄坏了。指令从哪来、interpreter 怎么执行,见事件委托的「移除一个监听器」;本页只讲怎么撞上、怎么绕开。

flowchart TB
  diff["diff 发 RemoveEventListener"] --> js["remove_event_listener"]
  js --> del["先删 data-dioxus-id"]
  del --> rm["再 removeListener"]
  rm --> kind{"去掉的事件冒泡吗"}
  kind -->|冒泡| b1["剩下的 onclick 找不到 id"]
  b1 --> b2["静默失效,应用照常"]
  kind -->|不冒泡| n1["读 id 得到 null"]
  n1 --> n2["抛 TypeError"]
  n2 --> n3["主循环停止,不再渲染"]

本页实验在 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 打开页面、派发事件、读属性。

现象

监听器要单独出现和消失,最直接的写法是把它放进一个按条件变化的 Vec<Attribute>,用 .. 展开到元素上。#two 常驻一个 onclick,按条件多一个 onmouseover(冒泡事件);#three 常驻 onclick,按条件多一个 onfocus(不冒泡):

#[component]
fn Rl() -> Element {
  let mut count = use_signal(|| 0);
  let mut two_extra = use_signal(|| true);
  let mut three_extra = use_signal(|| true);
  let mut four_extra = use_signal(|| true);

  let extra_bubbling: Vec<Attribute> = if two_extra() {
    vec![dioxus::html::events::onmouseover(move |_| info!("#two onmouseover"))]
  } else {
    vec![]
  };
  let extra_non_bubbling: Vec<Attribute> = if three_extra() {
    vec![dioxus::html::events::onfocus(move |_| info!("#three onfocus"))]
  } else {
    vec![]
  };

  rsx! {
    div { id: "counter", "count = {count}" }
    button { id: "inc", onclick: move |_| count += 1, "+" }
    button { id: "toggle2", onclick: move |_| two_extra.toggle(), "toggle #two" }
    button { id: "toggle3", onclick: move |_| three_extra.toggle(), "toggle #three" }
    button { id: "toggle4", onclick: move |_| four_extra.toggle(), "toggle #four" }
    div { id: "two", onclick: move |_| info!("#two onclick"), ..extra_bubbling, "two" }
    input { id: "three", onclick: move |_| info!("#three onclick"), ..extra_non_bubbling }
    // ... #four, see below
  }
}

脚本先点一下、悬停一下确认两个监听器都在,再点 toggle 去掉一个,然后点那个元素、点 #inc。release 构建:

--- #two: drop onmouseover (bubbling), keep onclick ---
[console.log] INFO src/main.rs:377 #two onclick
[console.log] INFO src/main.rs:361 #two onmouseover
before: #two data-dioxus-id = 7
after toggle2: #two data-dioxus-id = null
(clicked #two)
after clicking #inc: count = 1
--- #three: drop onfocus (non-bubbling), keep onclick ---
[console.log] INFO src/main.rs:378 #three onclick
before: #three data-dioxus-id = 8  listeners: focus
[console.error] wasm-bindgen: imported JS function that was not marked as `catch` threw an error: Cannot convert undefined or null to object
[exception] TypeError: Cannot convert undefined or null to object
after toggle3: #three data-dioxus-id = null  listeners: focus
after two clicks on #inc: count = 0
  • 冒泡事件:去掉 onmouseover 之后,#two 的 data-dioxus-id 没了。再点 #two,#two onclick 不再打印,也没有任何报错。应用的其余部分正常(#inc 能加一)。
  • 不冒泡事件:点 toggle3 的那次渲染抛出 TypeError,focus 监听器也没摘掉。之后点两次 #inc,计数还是 0:主循环再也没有渲染过。

出处

diff 比较同一元素的动态属性列表,旧列表有、新列表没有的监听器发一条 RemoveEventListener(remove_attribute)。web 上执行它的是 remove_event_listener:

    fn remove_event_listener(event_name: &str<u8, evt>, id: u32, bubbles: u8) {
        "{let node = this.nodes[$id$]; node.listening -= 1; node.removeAttribute('data-dioxus-id'); this.removeListener(node, $event_name$, $bubbles$);}"
    }

它不看 listening 减完是不是 0,先删 data-dioxus-id,再调 removeListener:

  • 冒泡事件的监听器都在根上,靠事件目标往上找 data-dioxus-id 来定位元素。#two 的属性没了,点击时一路找到根,handle_event 拿到根的 id 什么也不做,onclick 虽然还登记在 core 里,再也收不到事件。
  • 不冒泡事件的监听器挂在元素自己身上,removeListener 走到 removeNonBubblingListener,它第一行就读 data-dioxus-id,拿到 null,接着对 undefined 取属性,抛 TypeError。异常发生在 interpreter 的 run() 里,一直抛穿 wasm 的 flush_edits 和主循环,这一批指令后面的部分没执行,主循环也停了。

0.8 分支的重写(PR #5554)里,对应的函数先 removeListener,listening 减到 0 才删属性;0.7.10 没有这个修复。

修法

监听器常驻,条件写进闭包里。#four 和 #two 一样有两个监听器,但 onmouseover 从不消失,开关只决定它做不做事:

    // fix: both listeners always exist, the condition lives in the closure
    div { id: "four",
      onclick: move |_| info!("#four onclick"),
      onmouseover: move |_| {
        if four_extra() {
          info!("#four onmouseover");
        }
      },
      "four"
    }
--- #four (fix): listeners stay, the condition is inside the closure ---
[console.log] INFO src/main.rs:384 #four onmouseover
after toggle4: #four data-dioxus-id = 9
[console.log] INFO src/main.rs:381 #four onclick
(mouseover + click on #four)
after clicking #inc: count = 1

toggle 之后 #four 的 data-dioxus-id 还在,悬停不再打印(条件为假),点击照常,计数正常。

怎么确认没中招

  • 搜 Vec<Attribute>、..attrs 这类展开写法,以及 dioxus::html::events::on 开头的函数调用:只要展开的列表里有监听器、而且其中的监听器会随条件出现或消失,就可能踩到。
  • 浏览器里的检查:做完一次会去掉监听器的操作后,在控制台执行 document.querySelectorAll('[data-dioxus-id]').length,和操作之前比,少了的就是被误删的。
  • 控制台里出现 Cannot convert undefined or null to object,堆栈里有 removeNonBubblingListener,就是这个问题;之后页面不再更新。

小结

  • 0.7.10 的 remove_event_listener 去掉一个监听器时,先无条件删 data-dioxus-id,同一元素上剩下的监听器跟着失效。
  • 去掉的是冒泡事件:剩下的监听器静默不触发。去掉的是不冒泡事件(focus、blur、scroll、mouseenter、mouseleave 等,完整的表在 event_bubbles):抛 TypeError,主循环停止,页面不再渲染。
  • 修法:监听器常驻,条件放进闭包。

💬 关联讨论

发起讨论
加载中...