Rust in Everything
基于 dioxus 0.7.10

prevent_default 没拦住跳转或提交

主线位置:更新主线的第一步,浏览器事件进入根上的委托监听器、Runtime::handle_event 返回的那一刻。web 的事件闭包在 handle_event 返回后读一次「用户有没有调 prevent_default」,读完就交还给浏览器。委托监听器见事件委托,async 回调怎么被第一次 poll 见事件。

sequenceDiagram
  participant Br as 浏览器
  participant L as 根上的监听器
  participant H as 用户回调
  Br->>L: click 事件
  L->>H: handle_event,第一次 poll
  H-->>L: 第一个 await 处返回 Pending
  L->>Br: 读 default_action_enabled
  Br->>Br: 执行默认行为
  H->>H: await 之后 prevent_default
  Note over H: 已经晚了

本页实验在 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 打开页面,等水合完成后派发事件。

现象

两个页内锚点,区别只在 prevent_default 在 .await 之前还是之后;两个表单,一个的 onsubmit 什么都不调,一个先调 prevent_default 再做异步的事。YieldOnce 第一次 poll 时唤醒自己并返回 Pending,第二次返回 Ready,代表任何一次真正的 .await(请求、定时器):

#[component]
fn Pd() -> Element {
  rsx! {
    button { id: "inc", onclick: move |_| {}, "+" }
    a { id: "pa", href: "#a",
      onclick: move |e: MouseEvent| async move {
        e.prevent_default();
        YieldOnce(false).await;
        info!("#pa prevent_default before await");
      },
      "A"
    }
    a { id: "pb", href: "#b",
      onclick: move |e: MouseEvent| async move {
        YieldOnce(false).await;
        e.prevent_default();
        info!("#pb prevent_default after await");
      },
      "B"
    }
    form { id: "f1", onsubmit: move |_| info!("#f1 onsubmit (no prevent_default)"),
      input { name: "q", value: "one" }
      button { id: "s1", r#type: "submit", "submit 1" }
    }
    form { id: "f2",
      onsubmit: move |e: FormEvent| async move {
        e.prevent_default();
        YieldOnce(false).await;
        info!("#f2 onsubmit prevent_default, values = {:?}", e.values());
      },
      input { name: "q", value: "two" }
      button { id: "s2", r#type: "submit", "submit 2" }
    }
  }
}

链接用 dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) 派发,它的返回值就是「默认行为没被取消」;表单是先在 window 上放一个标记,点提交按钮,再看标记还在不在(整页重新加载后标记就没了)。release 构建:

--- links ---
#pa: dispatchEvent returned false, defaultPrevented=true, hash right after: ""
[console.log] INFO src/main.rs:323 #pa prevent_default before await
#pa: location.hash after 200ms: ""
#pb: dispatchEvent returned true, defaultPrevented=false, hash right after: "#b"
[navigatedWithinDocument] /pd#b
[console.log] INFO src/main.rs:331 #pb prevent_default after await
#pb: location.hash after 200ms: "#b"
--- forms ---
[console.log] INFO src/main.rs:343 #f2 onsubmit prevent_default, values = [("q", Text("two"))]
#f2: marker = "still the same document" url = /pd
[console.log] INFO src/main.rs:335 #f1 onsubmit (no prevent_default)
[navigated] /pd?q=one
[navigatedWithinDocument] /pd?q=one
#f1: marker = undefined url = /pd?q=one
  • #pb 的 prevent_default 确实执行了(日志在),但 dispatchEvent 返回时默认行为没被取消,地址已经变成 #b。换成真实链接,就是整页跳走。
  • #f1 写了 onsubmit,处理函数也跑了,但浏览器照常按 GET 提交,向服务端请求 /pd?q=one,整页重新加载,wasm 应用和它的所有状态从头开始。
  • #pa 和 #f2 在第一个 .await 之前调 prevent_default,都拦住了;#f2 的回调在 .await 之后照样能读表单的值。

出处

web 的事件闭包(dom.rs):

                let event = dioxus_core::Event::new(Rc::new(data) as Rc<dyn Any>, bubbles);
                runtime.handle_event(name.as_str(), event.clone(), element);

                // Prevent the default action if the user set prevent default on the event
                let prevent_default = !event.default_action_enabled();
                if prevent_default {
                    web_sys_event.prevent_default();
                }

prevent_default 只是在 dioxus 的 Event 上记一个标记,真正调浏览器的 preventDefault() 是这几行,而且只有一次机会:Runtime::handle_event 返回之后。浏览器在派发事件的这次 JS 调用返回后就决定执不执行默认行为。async 回调在 handle_event 里被当场 poll 一次,第一个 .await 之前的代码同步执行;之后的部分变成任务,在别的微任务里跑,那时这几行早就执行完了。

表单的情况是 0.7 的改动。0.6 的 web 渲染器里,元素上只要有 onsubmit,默认就不提交,调 prevent_default 反而提交;0.7 删掉了这段特殊处理,submit 和其他事件一样只看 prevent_default。来历见事件委托的冷知识(PR #4422)。从 0.6 升上来的表单如果没调 prevent_default,升级后会变成整页提交。

修法

  • 要拦默认行为,就在回调的第一行、任何 .await 之前调 e.prevent_default(),像 #pa 和 #f2 那样。要不要拦得在同步部分决定:如果「拦不拦」取决于一个异步结果(比如先问服务端能不能离开),就无条件先拦下,等结果出来再自己导航(navigator().push(..))或自己提交。
  • 单页应用里的表单,onsubmit 一律先 prevent_default,数据从 e.values() 取,交给 server function。
  • 站内链接用 router 的 Link,它在同步回调里调 prevent_default,见站内跳转整页刷新。

怎么确认没中招

  • 搜 onsubmit:,看每一个是不是在第一行就 prevent_default。
  • 搜 async move 的事件回调里的 prevent_default,确认它前面没有 .await。
  • 测试里用 dispatchEvent 的返回值判断:返回 false 表示默认行为被取消。上面的 #pb 返回 true,#pa 返回 false。也可以像实验那样,在 window 上放一个标记,提交之后看它还在不在。

小结

  • prevent_default 只在 handle_event 返回之前有效;async 回调里 .await 之后再调,浏览器已经执行了默认行为,日志照样打印。
  • 0.7 里写了 onsubmit 不等于不提交,不调 prevent_default 表单就整页提交,应用状态全丢(0.6 的行为相反)。
  • 修法:在第一个 .await 之前同步调用;需要异步判断时先无条件拦下,之后自己导航或提交。

💬 关联讨论

发起讨论
加载中...