Rust in Everything
基于 dioxus 0.7.10

事件

主线位置:更新主线上「序列化事件,调进 wasm → Runtime::handle_event(name, event, ElementId) → 由 ElementId 找到挂载点、沿父元素冒泡找监听器 → 调用户闭包」这一段。浏览器那一侧(根上的委托监听器、data-dioxus-id、把 web_sys::Event 转成 MouseData)在 事件委托;闭包写 signal 之后怎么排队重渲染在 调度。

sequenceDiagram
  participant R as 渲染器
  participant RT as Runtime
  participant MT as mounts
  participant L as 用户闭包
  R->>RT: handle_event name event id
  RT->>RT: elements 查 ElementRef
  RT->>MT: 取挂载点的模板和动态属性
  RT->>L: 同名监听器由深到浅调用
  L-->>RT: 每次调用后看 propagates
  RT->>MT: 换到父挂载点继续
  RT-->>R: 返回
  R->>R: 读 default_action_enabled

实验在原生 VirtualDom 上跑(依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-html =0.7.10 开 serialize,平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建),没有浏览器,事件由程序自己构造、直接交给 handle_event。

怎么在原生 VirtualDom 上发一个事件

core 的测试 packages/core/tests/event_propagation.rs 是这样发点击的:

    set_event_converter(Box::new(dioxus::html::SerializedHtmlEventConverter));

    let mut dom = VirtualDom::new(app);
    dom.rebuild(&mut dioxus_core::NoOpMutations);

    // Top-level click is registered
    let event = Event::new(
        Rc::new(PlatformEventData::new(Box::<SerializedMouseData>::default())) as Rc<dyn Any>,
        true,
    );
    dom.runtime().handle_event("click", event, ElementId(1));

三样东西要对上:

  • 事件数据是 PlatformEventData。onclick 这类属性由 dioxus-html 的 impl_event! 生成,它注册的外层监听器收 Event<PlatformEventData>,再用全局的 HtmlEventConverter 转成 MouseData 交给用户闭包。浏览器里转换器是 WebEventConverter,测试和实验里用 SerializedHtmlEventConverter,所以要先 set_event_converter。
  • 名字不带 on:"click"。
  • ElementId 是带监听器的那个元素的 id,从 rebuild 输出的 NewEventListener 里读。

实验 crate 把这几行包成 mouse_event(&dom, name, id, bubbles),返回发出去的那个 Event,便于事后查它的状态。

监听器存在哪

用户闭包最后变成一个动态属性,值是 AttributeValue::Listener,里面是 ListenerCallback:

type AnyEventHandler = Rc<RefCell<dyn FnMut(Event<dyn Any>)>>;
pub struct ListenerCallback<T = ()> {
    pub(crate) origin: ScopeId,
    callback: AnyEventHandler,
    _marker: PhantomData<T>,
}

ListenerCallback::new 记下创建时的 scope(origin),把用户闭包包成一个收 Event<dyn Any> 的闭包:先 event.data.downcast::<T>().unwrap(),再调用户闭包,对返回值调 .spawn()(下文的 SpawnIfAsync)。call 用 with_scope_on_stack(self.origin, ..) 执行,所以回调里 spawn、读 context、throw_error 都算在定义回调的组件头上。属性名是 "onclick"。外面套一层 Rc,handle_bubbling_event 才能把监听器 clone 出来、放掉对 mounts 的借用之后再调用。

创建节点时 write_attribute 遇到监听器,做两件事:

            AttributeValue::Listener(_) => {
                let element_ref = ElementRef {
                    path: ElementPath { path },
                    mount,
                };
                let mut elements = dom.runtime.elements.borrow_mut();
                elements[id.0] = Some(element_ref);
                to.create_event_listener(&attribute.name[2..], id);
            }

Runtime.elements 是一个 Slab<Option<ElementRef>>,下标就是 ElementId。next_element 分配 id 时插的是 None,只有挂了监听器的元素才被改成 Some(ElementRef):「这个元素在哪个挂载点(mount)、在模板里的什么路径(path)」。挂载点在 Runtime.mounts: Slab<VNodeMount> 里,每个渲染出去的模板实例一个,记着 node(那个 VNode,监听器就在它的 dynamic_attrs 里)和 parent(它被放在父模板的哪个动态节点位置上)。

handle_event:查表、分两路

Runtime::handle_event:

    pub fn handle_event(self: &Rc<Self>, name: &str, event: Event<dyn Any>, element: ElementId) {
        let _runtime = RuntimeGuard::new(self.clone());
        let elements = self.elements.borrow();

        if let Some(Some(parent_path)) = elements.get(element.0).copied() {
            if event.propagates() {
                self.handle_bubbling_event(parent_path, name, event);
            } else {
                self.handle_non_bubbling_event(parent_path, name, event);
            }
        }
    }

冒不冒泡不由事件名决定,由调用方构造 Event 时传的 propagates 决定。web 渲染器传的是浏览器事件自己的 web_sys_event.bubbles()。elements 里是 None(id 存在但没有监听器)或越界时,直接什么都不做。

冒泡:模板内按路径,模板间按父挂载点

handle_bubbling_event 是一个沿挂载点往上走的循环,每一轮处理一个模板实例:

                // Accumulate listeners into the listener list bottom to top
                for (idx, this_path) in node_template.attr_paths.iter().enumerate() {
                    let attrs = &*el_ref.dynamic_attrs[idx];

                    for attr in attrs.iter() {
                        // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one
                        if attr.name.get(2..) == Some(name) && target_path.is_descendant(this_path)
                        {
                            if let AttributeValue::Listener(listener) = &attr.value {
                                listeners.push(listener.clone());
                            }

                            // Break if this is the exact target element.
                            // This means we won't call two listeners with the same name on the same element. This should be
                            // documented, or be rejected from the rsx! macro outright
                            if target_path == this_path {
                                break;
                            }
                        }
                    }
                }
            }

            // Now that we've accumulated all the parent attributes for the target element, call them in reverse order
            // We check the bubble state between each call to see if the event has been stopped from bubbling
            tracing::event!(
                tracing::Level::TRACE,
                "Calling {} listeners",
                listeners.len()
            );
            for listener in listeners.into_iter().rev() {
                listener.call(uievent.clone());
                let metadata = uievent.metadata.borrow();

                if !metadata.propagates {
                    return;
                }
            }

            parent = mount_id.and_then(|id| self.mounts.borrow().get(id).and_then(|el| el.parent));
  • 模板内:attr_paths 是深度优先顺序,target_path.is_descendant(this_path) 判断 this_path 是不是目标路径的前缀(自己也算)。前缀就是祖先,所以收集到的是目标和它在本模板里的祖先上的同名监听器,从浅到深;倒过来调用就是从深到浅,和 DOM 冒泡顺序一致。兄弟和兄弟的后代不是前缀,不会被收集。
  • 模板间:本模板处理完,parent 换成本挂载点的 parent。它的 path 是子模板在父模板里所占的那个动态节点的路径,于是下一轮在父模板里找的就是「包着这个组件或 Fragment 的那些元素」上的监听器。
  • 收集期间借着 mounts,调用户代码之前先放掉(代码块的作用域就是为此),因为回调里可能写 signal、甚至触发别的东西去借 mounts。
  • 每调完一个监听器就看一次 propagates。stop_propagation 之后,同一模板里更浅的监听器和所有父模板都不再调。

不冒泡:只调目标

handle_non_bubbling_event 只看目标所在的那个挂载点,条件换成 target_path == this_path,找到就调用并结束。

实验:冒泡、不冒泡、找不到

fn app() -> Element {
  rsx! {
    div { onclick: move |_| log("  outer div (App template)"),
      section {
        div { onclick: move |e| {
            log("  inner div (App template)");
            if unsafe { STOP_AT_INNER } {
              e.stop_propagation();
            }
          },
          Child {}
        }
      }
    }
  }
}

#[component]
fn Child() -> Element {
  rsx! {
    p { onclick: move |_| log("  p (Child template)"),
      button { onclick: move |_| log("  button (Child template)"), "go" }
      span { "no listener" }
    }
  }
}

rebuild 的指令说明了 id:外层 div 是 1,内层 div 是 2(AssignId [0, 0]),Child 的 p 是 3,button 是 4(AssignId [0])。section 和 span 没有监听器,也就没有 id。然后对 ElementId(4) 发几次事件:

  edit: LoadTemplate index=0 id=1
  edit: NewEventListener click id=1
  edit: AssignId [0, 0] id=2
  edit: NewEventListener click id=2
  edit: LoadTemplate index=0 id=3
  edit: NewEventListener click id=3
  edit: AssignId [0] id=4
  edit: NewEventListener click id=4
  edit: ReplacePlaceholder [0, 0, 0] m=1
  edit: AppendChildren id=0 m=1
-- click on button, bubbles=true
  button (Child template)
  p (Child template)
  inner div (App template)
  outer div (App template)
-- click on button, bubbles=false
  button (Child template)
-- stop_propagation in inner div
...
-- mouseover on button (no such listener anywhere)
-- click on ElementId(1) (outer div root)
  outer div (App template)

(stop_propagation 那一段留给本节末尾的动脑筋。)冒泡时先在 Child 的模板里调 button、p,再跳到 App 的模板,从 Child 所在的动态节点 [0, 0, 0, 0] 往上找,调内层 div、外层 div。名字对不上(mouseover)就一个都不调。

如果把事件发给一个有 id 但没有监听器的元素,什么也不会发生,哪怕它的祖先有监听器:

fn app() -> Element {
  rsx! {
    div { onclick: move |_| log("  div (App template)"),
      Child {}
    }
  }
}

#[component]
fn Child() -> Element {
  rsx! {
    p { "root of Child, no listener" }
  }
}
  edit: LoadTemplate index=0 id=1
  edit: NewEventListener click id=1
  edit: LoadTemplate index=0 id=2
  edit: ReplacePlaceholder [0] m=1
  edit: AppendChildren id=0 m=1
-- click on ElementId(2) (the p, has an id but no listener)
-- click on ElementId(1)
  div (App template)

p 作为模板根拿到了 ElementId(2),但 elements[2] 是 None,handle_event 第一步就退出了。这正是方法文档里那句「The ElementId passed in must be the id of an element with a listener, not a static node or a text node.」的意思:从真实的事件目标找到「最近的、带监听器的元素」是渲染器的事。web 端的入口(web/src/dom.rs 的 walk_event_for_id)从 event.target 往上找第一个带 data-dioxus-id 属性的元素,而 interpreter 只在执行 NewEventListener 时才给元素写上这个属性,见 事件委托。

❗IMPORTANT
动脑筋:内层 div 调了 stop_propagation,这次点 button 会打印几行?

还是上面第一个程序,把 STOP_AT_INNER 设成 true,再对 ElementId(4) 发一次冒泡的 click。

Event:一份数据,一份共享的元数据

Event<T>:

pub struct Event<T: 'static + ?Sized> {
    /// The data associated with this event
    pub data: Rc<T>,
    pub(crate) metadata: Rc<RefCell<EventMetadata>>,
}

#[derive(Clone, Copy)]
pub(crate) struct EventMetadata {
    pub(crate) propagates: bool,
    pub(crate) prevent_default: bool,
}

metadata 是 Rc<RefCell<..>>,clone、map、into_any 都共享同一份。这是整个机制能工作的前提:handle_bubbling_event 给每个监听器传的是 uievent.clone(),dioxus-html 的外层监听器又把它 map 成 Event<MouseData> 交给用户;用户在最里层调 stop_propagation,改的是同一个 propagates,循环里下一次检查就能看到。prevent_default 同理,只是 core 自己从不读它,读它的是渲染器。web 端的事件入口(web/src/dom.rs)在 handle_event 返回后马上检查:

                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();
                }

这段代码运行在浏览器派发事件的那次 JS 回调里,handle_event 同步调完所有监听器,浏览器在这个回调返回之后才决定执行不执行默认行为,所以用户必须在回调同步执行的部分里调 prevent_default。

监听器可以是 async 闭包。SpawnIfAsync 对返回 Future 的情况是这样实现的:

impl<F: std::future::Future<Output = ()> + 'static> SpawnIfAsync<AsyncMarker> for F {
    fn spawn(self) {
        // Quick poll once to deal with things like prevent_default in the same tick
        let mut fut = Box::pin(self);
        let res = fut.as_mut().now_or_never();

        if res.is_none() {
            crate::spawn(async move {
                fut.await;
            });
        }
    }
}

先当场 poll 一次,第一个 .await 之前的代码在 handle_event 里同步跑完;没完成才 spawn 成任务(属于 origin scope),剩下的部分在之后的 wait_for_work 里跑。返回 Result<()> 的回调(同步或异步)出错时调 throw_error,错误交给最近的错误边界,见 Suspense 与错误边界。

📝NOTE
冷知识:prevent_default 方法和 Runtime::handle_event 是同一个 PR 加的

0.5 的 Event 只有一个 propagates: Rc<Cell<bool>>,没有 prevent_default 方法;处理事件的是 VirtualDom::handle_event(&mut self, name, data: Rc<dyn Any>, element, bubbles: bool)。阻止默认行为靠在元素上写一个属性,issue #2383 抱怨的就是 router 的 Link 渲染成带 dioxus-prevent-default="onclick" 的 a 标签,按住 Ctrl/Cmd 点击也不能在新标签页打开。更早的 issue #85(「Need for synchronous PreventDefault across Tao/Wry boundary」)写的是:「The FFI boundary is particularly challenging because we cannot synchronously handle the event.」PR #2792(「Synchronous prevent default」)一并解决了这些,提交说明里的几步是「add prevent default methods to the event」「Move event handling into the runtime」「create a deprecated VirtualDom::handle_event」。所以 0.7.10 的 virtual_dom.rs 里还留着一个 #[deprecated = "Use [VirtualDom::runtime().handle_event] instead"] 的 handle_event,内部就是 Event::new 加 self.runtime().handle_event(..);propagates 和 prevent_default 一起放进了 Rc<RefCell<EventMetadata>>。仓库里第一个包含它的 tag 是 v0.6.0-alpha.3,正式版 0.6.0。

回调结束之后什么时候重渲染

handle_event 不渲染。回调里写 signal,只是往调度通道里发了 SchedulerMsg::Immediate,组件要等渲染器下一次调用 render_immediate 才重跑:

fn app() -> Element {
  let mut n = use_signal(|| 0);
  log(format!("App runs n={n}"));
  rsx! {
    button { onclick: move |_| { n += 1; log("  sync handler wrote n"); }, "n={n}" }
    a { onclick: move |e: MouseEvent| async move {
        e.prevent_default();
        log("  async handler A: prevent_default before await");
        yield_once().await;
        log("  async handler A: after await");
      },
      "A"
    }
    a { onclick: move |e: MouseEvent| async move {
        yield_once().await;
        e.prevent_default();
        log("  async handler B: prevent_default after await");
      },
      "B"
    }
  }
}

yield_once 是一个第一次 poll 时唤醒自己并返回 Pending、第二次返回 Ready 的小 future。先点按钮,handle_event 返回后直接 render_immediate;再分别点 A、B,看 handle_event 刚返回时和跑过一次 wait_for_work(20 毫秒超时)之后的 default_action_enabled():

-- handle_event(click) on button
  sync handler wrote n
-- handle_event returned; nothing rendered yet
App runs n=1
  edit: SetText "n=1" id=2
-- render_immediate returned
-- handle_event(click) on A
  async handler A: prevent_default before await
-- returned: default_action_enabled = false
  async handler A: after await
-- after polling tasks: default_action_enabled = false
-- handle_event(click) on B
-- returned: default_action_enabled = true
  async handler B: prevent_default after await
-- after polling tasks: default_action_enabled = false
  • 组件是在 render_immediate 里重跑的,不是在 handle_event 里。原生程序可以在 handle_event 之后直接 render_immediate(它开头会 process_events 收消息);web 主循环则是 wait_for_work 的 future 因为通道里有了消息而就绪,select! 醒来后 render_immediate、flush_edits。同一个浏览器事件里写多少次 signal 都只重跑一次,见 调度。
  • A 在第一个 await 之前调了 prevent_default,handle_event 一返回就是 false,web 端能及时调 web_sys_event.prevent_default()。B 在 await 之后才调,handle_event 返回时还是 true,等它真正执行时浏览器早已执行了默认行为。

动脑筋答案

-- stop_propagation in inner div
  button (Child template)
  p (Child template)
  inner div (App template)

三行。button、p 照常调;内层 div 自己也会被调用,stop_propagation 只影响之后的监听器:调完它,循环检查 metadata.propagates 为 false,直接 return,外层 div 不再调用。

小结

  • 监听器是值为 AttributeValue::Listener(ListenerCallback) 的动态属性,ListenerCallback 里是 Rc<RefCell<dyn FnMut(Event<dyn Any>)>> 和创建它的 scope。创建节点时,带监听器的元素在 Runtime.elements 里登记 ElementRef { mount, path },并发出 NewEventListener(名字去掉 on)。
  • handle_event 只认带监听器的元素的 id;冒不冒泡看 Event 构造时的 propagates。冒泡时在模板内按路径前缀、模板间沿父挂载点往上收集同名监听器,由深到浅调用,每调一个检查一次 propagates;不冒泡只调目标。
  • Event 的 propagates 和 prevent_default 在一个共享的 Rc<RefCell<EventMetadata>> 里;prevent_default 由渲染器在 handle_event 返回后读取。异步回调先被当场 poll 一次,第一个 await 之前的 prevent_default 才有效。这套 API 来自 #2792(v0.6.0-alpha.3)。
  • handle_event 不渲染:写 signal 只是发消息,重跑发生在渲染器随后调用的 render_immediate 里。

💬 关联讨论

发起讨论
加载中...