Rust in Everything
基于 dioxus 0.7.10

一次点击

主线位置:更新主线从头到尾。本页不深入任何一层,只把一次点击 count += 1 经过的函数串起来:先在原生 VirtualDom 上看它产生的 Mutations,再在 headless Chrome 里看真实的监听器和 DOM 变化;每一段末尾链到详细讲它的那一章。

sequenceDiagram
  participant B as 浏览器
  participant W as wasm 事件闭包
  participant RT as Runtime
  participant S as Signal count
  participant L as web 主循环
  participant VD as VirtualDom
  participant JS as interpreter
  B->>W: click 冒泡到 main
  Note over W: 往上找 data-dioxus-id
  W->>RT: handle_event click
  RT->>S: 监听器 count += 1
  S->>RT: 写锁释放 mark_dirty
  Note over RT: 发 SchedulerMsg Immediate
  RT-->>W: 返回
  W-->>B: 返回 看 prevent_default
  Note over L: 下一个微任务
  L->>VD: wait_for_work 返回
  L->>VD: render_immediate
  Note over VD: run_scope 再 diff
  VD->>L: set_node_text 进缓冲
  L->>JS: flush_edits
  JS->>B: textContent 改写
  L->>VD: 下一轮里跑 effect
步骤文件 · 函数关键的一两行详见
1. 冒泡事件在根上只挂一个监听器interpreter/src/ts/core.ts · createListenerthis.root.addEventListener(event_name, this.handler)事件委托
2. 从目标往上找 data-dioxus-id,包成 Eventweb/src/dom.rs · WebsysDom::new 里的 handler、walk_element_for_idruntime.handle_event(name.as_str(), event.clone(), element)事件委托
3. 由 ElementId 找挂载点,沿模板冒泡收集监听器core/src/runtime.rs · Runtime::handle_event、handle_bubbling_eventif attr.name.get(2..) == Some(name) && target_path.is_descendant(this_path)事件
4. count += 1 拿写锁,锁释放时通知订阅者signals/src/signal.rs · SignalSubscriberDrop::drop、update_subscriberssubscribers.retain(|reactive_context| reactive_context.mark_dirty())Signal
5. 组件的 ReactiveContext 往调度通道发消息core/src/reactive_context.rs · new_for_scope、mark_dirty_ = sender.unbounded_send(SchedulerMsg::Immediate(id));Signal、调度
6. 主循环醒来,wait_for_work 把 scope 标脏后返回web/src/lib.rs · run;core/src/virtual_dom.rs · wait_for_workif self.has_dirty_scopes() { return; }VirtualDom 与 Runtime
7. 按高度取脏 scope,重跑组件core/src/virtual_dom.rs · render_immediate;core/src/scope_arena.rs · run_scopewhile let Some(work) = self.pop_work()调度、Scope 与 hooks
8. 同一个模板只比动态部分,文本变了发 SetTextcore/src/diff/node.rs · diff_node、diff_vtextto.set_node_text(&right.value, id);diff
9. 指令写进 sledgehammer 缓冲,flush_edits 一次交给 JSweb/src/mutations.rs · set_node_text、flush_edits"{this.nodes[$id$].textContent = $text$;}"Mutations、WebsysDom 与 interpreter
10. DOM 改完之后,下一轮 wait_for_work 里跑 effectcore/src/virtual_dom.rs · poll_taskswhile let Some(effect) = self.pop_effect() { effect.run(); ... }Memo 与 effect

实验一:原生 VirtualDom 上的一次点击

先不碰浏览器。VirtualDom 不知道 DOM 长什么样,渲染器只是给它喂事件、收指令,所以在原生程序里可以把浏览器那一半换成手写的几行:用 Event::new 造一个事件,交给 dom.runtime().handle_event(..),再用 render_immediate_to_vec() 把这次更新的指令收集成 Vec 打印出来。事件数据的造法照抄 dioxus 自己的测试 packages/core/tests/event_propagation.rs:PlatformEventData 里放一个 SerializedMouseData,并用 set_event_converter 装上 SerializedHtmlEventConverter,这样 onclick 的包装层才能把它转成 MouseData(这个转换器要打开 dioxus-html 的 serialize feature)。

依赖锁在 dioxus = "=0.7.10"(router、fullstack)、dioxus-core、dioxus-ssr、dioxus-html(serialize)都是 =0.7.10,外加 tokio 和 tracing-subscriber:

use std::{any::Any, rc::Rc, time::Duration};

use dioxus::prelude::*;
use dioxus_core::{ElementId, Event};
use dioxus_html::{PlatformEventData, SerializedHtmlEventConverter, SerializedMouseData};

fn app() -> Element {
  let mut count = use_signal(|| 0);
  println!("  [app 运行] count = {}", count.peek());
  use_effect(move || println!("  [effect] count = {}", count()));
  rsx! {
    div { class: "c",
      "count = {count}"
      button { onclick: move |_| count += 1, "+" }
    }
  }
}

async fn wait(dom: &mut VirtualDom, label: &str) {
  let ready = tokio::time::timeout(Duration::from_millis(50), dom.wait_for_work()).await;
  println!("{label}: wait_for_work {}", if ready.is_ok() { "返回了(有脏 scope)" } else { "50ms 内没返回(没有脏 scope)" });
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  if std::env::var("TRACE").is_ok() {
    tracing_subscriber::fmt()
      .with_env_filter("dioxus_signals=trace,dioxus_core::reactive_context=trace,dioxus_core::virtual_dom=trace")
      .without_time()
      .with_ansi(false)
      .init();
  }
  set_event_converter(Box::new(SerializedHtmlEventConverter));
  let mut dom = VirtualDom::new(app);

  println!("--- 1. rebuild_to_vec ---");
  let m = dom.rebuild_to_vec();
  for e in &m.edits {
    println!("{e:?}");
  }
  println!("{}", dioxus_ssr::render(&dom));
  wait(&mut dom, "rebuild 之后").await;

  println!("--- 2. handle_event(\"click\", .., ElementId(2)) ---");
  let event = Event::new(
    Rc::new(PlatformEventData::new(Box::<SerializedMouseData>::default())) as Rc<dyn Any>,
    true,
  );
  dom.runtime().handle_event("click", event, ElementId(2));
  println!("handle_event 返回");
  wait(&mut dom, "点击之后").await;

  println!("--- 3. render_immediate_to_vec ---");
  let m = dom.render_immediate_to_vec();
  for e in &m.edits {
    println!("{e:?}");
  }
  println!("{}", dioxus_ssr::render(&dom));
  wait(&mut dom, "渲染之后").await;
}

wait 给 wait_for_work 50 毫秒:它只在有脏 scope 时返回,没有就一直等下去,超时说明「没有活可干」。M2 Max、macOS 27、rustc 1.99.0,debug 构建,不设 TRACE 的输出:

--- 1. rebuild_to_vec ---
  [app 运行] count = 0
LoadTemplate { index: 0, id: ElementId(1) }
AssignId { path: [1], id: ElementId(2) }
NewEventListener { name: "click", id: ElementId(2) }
CreateTextNode { value: "count = 0", id: ElementId(3) }
ReplacePlaceholder { path: [0], m: 1 }
AppendChildren { id: ElementId(0), m: 1 }
<div class="c">count = 0<button>+</button></div>
  [effect] count = 0
rebuild 之后: wait_for_work 50ms 内没返回(没有脏 scope)
--- 2. handle_event("click", .., ElementId(2)) ---
handle_event 返回
点击之后: wait_for_work 返回了(有脏 scope)
--- 3. render_immediate_to_vec ---
  [app 运行] count = 1
SetText { value: "count = 1", id: ElementId(3) }
<div class="c">count = 1<button>+</button></div>
  [effect] count = 1
渲染之后: wait_for_work 50ms 内没返回(没有脏 scope)

TRACE=1 再跑一次,打开 signals 和 core 里几个 trace!(它们只在 debug_assertions 下编进去),第 2、3 段变成:

--- 2. handle_event("click", .., ElementId(2)) ---
TRACE dioxus_signals::signal: Write on signal at src/bin/click.rs:14:34 finished, updating subscribers
TRACE dioxus_core::reactive_context: Marking reactive context created at src/bin/click.rs:10:3 as dirty
TRACE dioxus_core::reactive_context: Marking reactive context created at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/reactive_context.rs:116:21 as dirty
handle_event 返回
TRACE VirtualDom::wait_for_work:VirtualDom::process_events: dioxus_core::virtual_dom: Marking task Task { id: DefaultKey(1v1), unsend: PhantomData<*const ()> } (spawned in ScopeId(3)) as dirty
TRACE VirtualDom::wait_for_work:VirtualDom::process_events: dioxus_core::virtual_dom: Marking scope ScopeId(3) as dirty
点击之后: wait_for_work 返回了(有脏 scope)
--- 3. render_immediate_to_vec ---
  [app 运行] count = 1
TRACE VirtualDom::render_immediate: dioxus_signals::signal: Subscribing to the reactive context ReactiveContext(for scope: ScopeId(3, "root"))
TRACE VirtualDom::render_immediate: dioxus_signals::signal: Subscribing to the reactive context ReactiveContext(for scope: ScopeId(3, "root"))
TRACE VirtualDom::render_immediate: dioxus_signals::signal: Subscribing to the reactive context ReactiveContext(for scope: ScopeId(3, "root"))
SetText { value: "count = 1", id: ElementId(3) }
<div class="c">count = 1<button>+</button></div>
TRACE VirtualDom::wait_for_work:VirtualDom::process_events:VirtualDom::poll_tasks: dioxus_signals::signal: Subscribing to the reactive context ReactiveContext created at src/bin/click.rs:10:3
  [effect] count = 1
渲染之后: wait_for_work 50ms 内没返回(没有脏 scope)

从这两段输出能读出几件事:

  • 第一次渲染的指令里,按钮拿到 ElementId(2),紧跟着一条 NewEventListener { name: "click", id: ElementId(2) };文字 count = 0 是一个单独的文本节点,ElementId(3)。所以第 2 段手工点击传的是 ElementId(2)。
  • handle_event 同步跑完了用户闭包:它返回之前,写 signal、标脏两个 reactive context 都已经发生(click.rs:14:34 是 count += 1,click.rs:10:3 是 use_effect,reactive_context.rs:116 是组件自己的那个)。但组件没有在 handle_event 里重跑,[app 运行] 要到第 3 段才出现。
  • 标脏只是往通道里发消息。消息在 wait_for_work 的 process_events 里才被取出来:组件那条变成「scope 3 脏了」,effect 那条变成「effect 所在的任务该 poll 了」。
  • 这次更新只有一条指令:SetText { value: "count = 1", id: ElementId(3) }。div、button、监听器都没动。
  • effect 在 SetText 之后才跑,而且是在下一次 wait_for_work 里跑的。

实验二:浏览器里的同一次点击

同一个组件编成 wasm,在 headless Chrome 里点一次。组件只多了 id 和日志(dioxus::logger::tracing::info! 在浏览器里打到 console):

use dioxus::logger::tracing::info;
use dioxus::prelude::*;

fn app() -> Element {
  let mut count = use_signal(|| 0);
  info!("app 运行 count = {}", count.peek());
  use_effect(move || info!("effect count = {}", count()));
  rsx! {
    div { class: "c",
      "count = {count}"
      button {
        id: "inc",
        onclick: move |_| {
          count += 1;
          info!("onclick 写完 count");
        },
        "+"
      }
    }
  }
}

fn main() {
  dioxus::launch(app);
}

dx build --platform web(dx 0.7.9,debug,纯客户端、没有 fullstack),public/ 目录用 python3 -m http.server 在 127.0.0.1:18760 上提供。Chrome 156 用 --headless=new --remote-debugging-port=18761 --host-resolver-rules="MAP * ~NOTFOUND, EXCLUDE 127.0.0.1" 启动(最后一个参数让 dev 模板里那条 Google Fonts 的 @import 解析失败,不出网)。驱动它的是一段 Node 22 脚本,直接用自带的 WebSocket 说 Chrome DevTools Protocol:Runtime.evaluate 取 DOM、DOMDebugger.getEventListeners 查元素上挂了哪些监听器、Runtime.consoleAPICalled 收 console。点击那一段在页面里执行:

const text = () => document.querySelector('div.c').textContent;
console.log('before click(): ' + text());
document.querySelector('#inc').click();
console.log('click() 返回后(同步): ' + text());
await Promise.resolve();
console.log('一个微任务之后: ' + text());
await new Promise((r) => setTimeout(r, 0));
console.log('setTimeout 0 之后: ' + text());

点击之前还在 #main 上挂了一个 MutationObserver,记录这次点击引起的所有 DOM 变化。输出(console 里 dioxus-logger 的 %c 样式参数已去掉):

[console.log] INFO src/main.rs:6 app 运行 count = 0
[console.log] INFO src/main.rs:7 effect count = 0
--- 点击前 ---
#main: <div id="main" data-dioxus-id="0"><div class="c">count = 0<button id="inc" data-dioxus-id="2">+</button></div></div>
监听器 #main: click
监听器 div.c: (none)
监听器 #inc: (none)
--- 点击 ---
[console.log] before click(): count = 0+
[console.log] INFO src/main.rs:15 onclick 写完 count
[console.log] click() 返回后(同步): count = 0+
[console.log] INFO src/main.rs:6 app 运行 count = 1
[console.log] INFO src/main.rs:7 effect count = 1
[console.log] 一个微任务之后: count = 1+
[console.log] setTimeout 0 之后: count = 1+
MutationObserver: ["characterData \"count = 0\" -> \"count = 1\""]
--- 点击后 ---
#main: <div id="main" data-dioxus-id="0"><div class="c">count = 1<button id="inc" data-dioxus-id="2">+</button></div></div>

(textContent 末尾的 + 是按钮里的文字。)

  • click 监听器只挂在 #main 上,按钮自己一个也没有。按钮身上有的是 data-dioxus-id="2",和实验一里的 ElementId(2) 是同一个数;根元素是 data-dioxus-id="0"。
  • onclick 写完 count 在 click() 返回之前打出来:用户闭包是在浏览器派发事件的同一个调用栈里同步跑的。
  • click() 返回时 DOM 还是 count = 0。组件重跑、DOM 改写、effect 都发生在 click() 返回之后、测试代码的下一个微任务之前。
  • 整次点击只改了一个文本节点的内容(characterData),没有增删任何节点,对应实验一里那唯一的一条 SetText。

下面按调用顺序把这些对到源码上。

1. 监听器挂在根上

第一次渲染的 NewEventListener 指令在 web 上落到 sledgehammer 生成的 JS 里(interpreter/src/unified_bindings.rs 的 new_event_listener),它先给节点写上 data-dioxus-id,再调 BaseInterpreter.createListener:

  createListener(event_name: string, element: HTMLElement, bubbles: boolean) {
    // ...
    if (bubbles) {
      if (this.global[event_name] === undefined) {
        this.global[event_name] = { active: 1, callback: this.handler };
        this.root.addEventListener(event_name, this.handler);
      } else {
        this.global[event_name].active++;
      }
    } else {
      const id = element.getAttribute("data-dioxus-id");
      if (!this.local[id]) {
        this.local[id] = {};
      }
      element.addEventListener(event_name, this.handler);
    }
  }

bubbles 来自 event_bubbles 这张表,"click" => true。冒泡事件不管页面上有多少个 onclick,#main 上只 addEventListener 一次,global[event_name].active 记引用数,减到 0 才摘掉;不冒泡的事件(focus、scroll 之类)只能挂在元素自己身上。实验二里 #main 有一个 click、按钮上没有,就是第一个分支。

挂上去的 this.handler 也不是 JS 函数,而是 Rust 闭包:initialize 收下它,同时把根元素的 data-dioxus-id 设成 "0"。委托的细节、global / local 两张表见事件委托。

2. 进 wasm:找 data-dioxus-id

那个闭包在 WebsysDom::new 里造:

        let handler: Closure<dyn Fn(&Event)> = Closure::wrap(Box::new({
            let runtime = runtime.clone();
            move |web_sys_event: &web_sys::Event| {
                let name = web_sys_event.type_();
                let element = walk_event_for_id(web_sys_event);
                let bubbles = web_sys_event.bubbles();

                let Some((element, target)) = element else {
                    return;
                };

                // ...
                if !event_type_matches(name.as_str(), web_sys_event) {
                    return;
                }

                let data = virtual_event_from_websys_event(web_sys_event.clone(), target);

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

浏览器事件不经过任何序列化:virtual_event_from_websys_event 只把 web_sys::Event 和目标元素装进 PlatformEventData,等用户的处理函数要 MouseData 时再由 WebEventConverter 按需转换。walk_element_for_id 从 event.target() 起往 parent_element() 走,遇到第一个带 data-dioxus-id 的元素就停。点在按钮上,目标本身就有 data-dioxus-id="2";如果按钮里还套着一个 span,点到 span 也会走到按钮。只有带监听器的元素才有这个属性(第 1 节的 new_event_listener 才写它),所以往上找到的第一个就是离目标最近的、可能有监听器的元素。

handle_event 返回以后,闭包检查 default_action_enabled(),用户调过 prevent_default 才对浏览器事件调 prevent_default()。这一步必须在事件派发的调用栈里同步完成,异步处理函数里调它为什么来不及,见事件委托。

📝NOTE
冷知识:这个闭包原来是 FnMut

0.6.1 及以前,这里是 Closure<dyn FnMut(&Event)>。wasm-bindgen 的 FnMut 闭包不能重入:一个事件的处理过程中如果又同步触发了另一个事件(比如处理函数让另一个元素获得焦点,浏览器立刻派发 focus),同一个闭包被再次调用,就抛 closure invoked recursively or after being dropped,issue #2882 报的就是这个错误。PR #3405(2024-12-20 合并)的说明是「Events can be invoked recursively sometimes which is not allowed with Closure<dyn FnMut>. Switching to Closure<dyn Fn> fixes the issue」,改动就是把类型换成 Closure<dyn Fn(&Event)>,并留下了上面代码前那三行注释。闭包本身不需要可变状态(runtime 是 Rc,里面的状态都在 RefCell 里),所以 Fn 够用。首次随 0.6.2 发布。

3. Runtime::handle_event:由 ElementId 找监听器

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

elements 是一张 slab,下标就是 ElementId,值是 ElementRef:这个元素在哪个挂载的 VNode 里(mount)、在模板里的路径(path,按钮是 [1],即根 div 的第二个子节点)。VirtualDom 里没有 DOM 节点,只有「模板 + 路径」,所以事件要先换回这两样东西。

handle_bubbling_event 在这个挂载的模板里遍历 attr_paths,凡是名字去掉 on 前缀等于 "click"、路径是目标路径前缀的动态属性,就把 AttributeValue::Listener 收进列表;然后倒序调用(离目标近的先调),每调一个检查一次 propagates;一个模板收完再沿 mount.parent 走到父模板。按钮的 onclick 路径正是 [1],收到一个监听器。

listener.call(event) 是 ListenerCallback::call:在监听器创建时所在的 scope 上调闭包(with_scope_on_stack(self.origin, ..)),所以闭包里的 hook 和 context 查找都以组件为准。rsx! 里的 onclick: move |_| .. 经过 dioxus-html 的 impl_event! 包了一层,先把 PlatformEventData 转成 MouseData 再交给用户闭包。冒泡、stop_propagation、不冒泡事件的处理见事件。

4. count += 1:写锁和它的 drop guard

count += 1 是 write_impls! 宏给 Signal 生成的 AddAssign:

            fn add_assign(&mut self, rhs: T) {
                self.with_mut(|v| *v = *v + rhs)
            }

with_mut 就是 f(&mut *self.write())。write() 最终走到 Signal 的 try_write_unchecked,返回一个 WriteLock,里面除了可变借用,还带着一个 SignalSubscriberDrop:

impl<T: 'static, S: Storage<SignalData<T>> + 'static> Drop for SignalSubscriberDrop<T, S> {
    fn drop(&mut self) {
        #[cfg(debug_assertions)]
        {
            tracing::trace!(
                "Write on signal at {} finished, updating subscribers",
                self.origin
            );
        }
        self.signal.update_subscribers();
    }
}

实验一第 2 段的第一行 trace 就是这里打的。写本身不通知任何人,通知发生在 WriteLock 被 drop 的那一刻。update_subscribers 把订阅者集合整个取出来,对每个调 mark_dirty,返回 false(context 已经被回收)的顺便删掉,再放回去:

            let mut subscribers = std::mem::take(&mut *inner.subscribers.lock().unwrap());
            subscribers.retain(|reactive_context| reactive_context.mark_dirty());
            // Extend the subscribers list instead of overwriting it in case a subscriber is added while reactive contexts are marked dirty
            inner.subscribers.lock().unwrap().extend(subscribers);

先 take 再放回,是因为 mark_dirty 可能跑用户代码、再去订阅这个 signal,持锁调用会死锁(源码注释原话)。订阅者是谁、什么时候订阅,见 Signal。

📝NOTE
没有蠢问题

问: 通知订阅者时,写锁放开了吗?如果订阅者马上来读 count,会不会撞上还没释放的可变借用?

答: 已经放开了。WriteLock 有两个字段,先 write(可变借用)后 data(这里就是 SignalSubscriberDrop)。Rust 按字段声明顺序 drop,所以先还借用、再跑 update_subscribers。另外,update_subscribers 自己也要 self.inner.read() 去拿订阅者集合,借用如果还在,这一步就会先失败。

5. mark_dirty:只是往通道里塞一条消息

订阅 count 的有两个 ReactiveContext:组件的和 use_effect 的。组件那个由 ReactiveContext::new_for_scope 在建 scope 时创建,它的 update 闭包只做一件事:

        let sender = runtime.sender.clone();
        let update_scope = move || {
            _ = sender.unbounded_send(SchedulerMsg::Immediate(id));
        };

mark_dirty 拿到 context 的写锁就调 update,拿不到(context 已回收)就返回 false。use_effect 的 context 是 ReactiveContext::new_with_origin 建的,update 往它自己的 UnboundedSender<()> 里发一个 (),唤醒 effect 在 hook 里 spawn 的那个任务;任务的 waker 再往同一个调度通道发 TaskNotified(SchedulerMsg 的四种消息之一)。所以实验一的 trace 里,handle_event 返回后先出现「Marking task ... as dirty」,再出现「Marking scope ScopeId(3) as dirty」:两条消息都是在 handle_event 里发出的,到 process_events 才被处理。

这也是 handle_event 返回时组件还没重跑的原因:从事件到重渲染之间隔着一个通道。同一个事件里写多少次 signal,通道里多几条 Immediate(3),但 dirty_scopes 是一个集合,scope 3 只跑一次(见调度)。

6. 主循环醒来

浏览器里,dioxus_web::launch 最后调到 launch_virtual_dom,它用 wasm_bindgen_futures::spawn_local 把 run 跑成一个永不结束的 future。它的循环体(去掉 devtools 和水合的分支):

    loop {
        // ...
        {
            let work = virtual_dom.wait_for_work().fuse();
            pin_mut!(work);
            // ...
                select! {
                    _ = work => {},
                    hyd = rx_hydration => {
                        // ...
                    }
                }
        }
        // ...
        virtual_dom.render_immediate(&mut websys_dom);
        // ...
        websys_dom.flush_edits();
    }

wait_for_work 先 process_events:把通道里现有的消息全取出来(Immediate(id) 变成 mark_dirty,按 ScopeOrder { height, id } 进 dirty_scopes),有脏 scope 就立刻返回;没有就 poll 任务和 effect,再 .await 下一条消息。点击之前主循环就停在这个 .await 上。

第 5 步的 unbounded_send 唤醒了这个 future,wasm-bindgen-futures 的执行器用 queueMicrotask 安排下一次 poll(js-sys 0.3.106 的 futures/queue.rs)。所以实验二里,click() 返回时 DOM 还没变,而测试代码 await Promise.resolve() 一次之后已经变了:dioxus 的那次 poll 排在测试代码的微任务前面,整轮渲染、flush_edits、effect 都在那一次 poll 里同步做完。VirtualDom 的几个入口怎么组合,见 VirtualDom 与 Runtime;web 主循环的其余分支见 WebsysDom 与 interpreter。

7. render_immediate:重跑组件,diff 出一条 SetText

render_immediate:

    pub fn render_immediate(&mut self, to: &mut impl WriteMutations) {
        // ...
        self.process_events();

        // ...
        let _runtime = RuntimeGuard::new(self.runtime.clone());
        while let Some(work) = self.pop_work() {
            match work {
                Work::PollTask(task) => {
                    _ = self.runtime.handle_task_wakeup(task);
                    // Make sure we process any new events
                    self.queue_events();
                }
                Work::RerunScope(scope) => {
                    // If the scope is dirty, run the scope and get the mutations
                    self.runtime.clone().while_rendering(|| {
                        self.run_and_diff_scope(Some(to), scope.id);
                    });
                }
            }
        }

        self.runtime.finish_render();
    }

pop_work 每次取「最高处」的工作:ScopeOrder 先比 height 再比 id,所以父组件先于子组件重跑,子组件如果因此被删掉就不用白跑;脏 scope 和待 poll 的任务也按 ScopeOrder 比,ScopeOrder 相等(同一个 scope 的任务)时先 poll 任务。这次点击之后,scope 3 自己和它里面 effect 的那个任务都在队列里,先 poll 任务(它只是把 effect 挂进队列),再重跑 scope 3(用户组件 app 固定是 ScopeId::APP,即 3,前面三个是 dioxus 自己包的根、根 suspense 边界和根错误边界,见 VirtualDom 与 Runtime)。

run_and_diff_scope 先 run_scope:hook_index 归零,在组件的 ReactiveContext 里 reset_and_run_in(|| props.render())。reset 清掉旧订阅,渲染时读到哪个 signal 就重新订阅哪个,实验一第 3 段的「Subscribing ... ScopeId(3, "root")」就是这次重新订阅。

然后 diff_node 比新旧 VNode:

        // If the templates are different, we need to replace the entire template
        if self.template != new.template {
            // ...
            return self.replace(std::slice::from_ref(new), parent, dom, to);
        }

        self.move_mount_to(new, dom);
        // ...
        if let Some(to) = to.as_deref_mut() {
            self.diff_attributes(new, dom, to);
        }

        // Now diff the dynamic nodes
        let mount_id = new.mount.get();
        for (dyn_node_idx, (old, new)) in self
            .dynamic_nodes
            .iter()
            .zip(new.dynamic_nodes.iter())
            .enumerate()
        {
            self.diff_dynamic_node(mount_id, dyn_node_idx, old, new, dom, to.as_deref_mut())
        }

两次渲染出自同一个 rsx!,template 是同一个 &'static Template,div、按钮、静态文字 + 都不参与比较。动态部分只有两样:动态节点 0 是文本 "count = {count}",diff_vtext 发现 "count = 0" 和 "count = 1" 不等,调 to.set_node_text(&right.value, id),id 是这个文本节点挂载时分到的 ElementId(3);动态属性 0 是 onclick,新旧都是 AttributeValue::Listener,diff_attributes 对这种组合直接判为「没变」。模板为什么能做到只比动态部分,见 Template 与 VNode 和 diff。

📝NOTE
没有蠢问题

问: 组件每重跑一次,move |_| count += 1 就是一个新闭包。diff 不发新的监听器,下次点击调到的会不会是旧闭包?

答: 不会。DOM 上的监听器只认事件名和 data-dioxus-id,根本不持有 Rust 闭包,所以 diff 不需要为新闭包发任何指令(diff_attributes 的注释原话:「Interpreters reference event listeners by name and element id, so we don't need to write them even if the closure has changed」)。闭包放在 VNode 的动态属性里,而 diff_node 开头的 move_mount_to 把挂载点的 mount.node 换成新的 VNode。下次 handle_event 从 mounts 里取到的是新 VNode,调到的也就是新闭包。

8. WebsysDom:攒进缓冲,一次交给 JS

render_immediate 对 to 是泛型的(impl WriteMutations)。原生实验里 to 是 Mutations,把指令收集成 Vec<Mutation>,打印出来就是 SetText { .. };浏览器里 to 是 WebsysDom,它的实现把每个方法转给 sledgehammer 生成的 Interpreter:

    fn set_node_text(&mut self, value: &str, id: ElementId) {
        if self.skip_mutations() {
            return;
        }
        self.interpreter.set_text(id.0 as u32, value)
    }

self.interpreter.set_text 不立刻碰 DOM,只把操作码和参数编码进一块缓冲。它在 JS 那一侧对应的代码是 unified_bindings.rs 里写死的一行:

    fn set_text(id: u32, text: &str) {
        "{this.nodes[$id$].textContent = $text$;}"
    }

this.nodes 是 JS 侧按 ElementId 下标存的节点数组,第一次渲染的 CreateTextNode { id: ElementId(3) } 把那个文本节点放进了 nodes[3]。render_immediate 返回后主循环调 flush_edits,self.interpreter.flush() 一次 wasm 到 JS 的调用把缓冲里的指令全部执行完。实验二的 MutationObserver 只看到一次 characterData 变化,就是这一行 textContent =。skip_mutations 是水合用的开关,见一次页面请求。指令集见 Mutations,缓冲的编码见 WebsysDom 与 interpreter。

web 主循环里原本还有「等空闲时间再 diff、等动画帧再改 DOM」的设计,代码还在,但被注释掉了,注释写的原因是「it has a negative impact on response times for events」。现在是 render_immediate 之后紧接着 flush_edits,不等 requestAnimationFrame。

9. effect 最后跑

use_effect 的任务被唤醒以后,并不直接跑用户闭包,而是 queue_effect 把它挂进 pending_effects。render_immediate 结尾的 finish_render 发现有待跑的 effect,就发一条 SchedulerMsg::EffectQueued。主循环 flush_edits 之后回到循环开头,wait_for_work → process_events → poll_tasks:

            // At this point, we have finished running all tasks that are pending and we haven't found any scopes to rerun. This means it is safe to run our lowest priority work: effects
            while let Some(effect) = self.pop_effect() {
                effect.run();
                // Check if any new scopes are queued for rerun
                self.queue_events();
                if self.has_dirty_scopes() {
                    return;
                }
            }

所以 effect 读到的 DOM 一定是改完的。实验二的 console 里 effect count = 1 紧跟在 app 运行 count = 1 后面,两者之间没有别的 JS 插进来:重渲染、flush_edits、effect 是同一次 poll 里连着做的。effect 写 signal 又标脏了 scope,就提前返回,主循环再来一轮。effect 的依赖跟踪见 Memo 与 effect。

❗IMPORTANT
动脑筋:click() 返回的那一刻,文字是几?

实验二的页面里,count 起始为 0。在页面里同步执行:

document.querySelector('#inc').click();
console.log(document.querySelector('div.c').textContent);
await Promise.resolve();
console.log(document.querySelector('div.c').textContent);

两行 console.log 各打出什么?onclick 里的 info! 和组件里的 app 运行 各出现在哪两行之间?(M2 Max、macOS 27、Chrome 156,dx build --platform web 的 debug 构建。)

动脑筋答案

实验二里正是这段代码(多了几行标记),原样输出:

[console.log] before click(): count = 0+
[console.log] INFO src/main.rs:15 onclick 写完 count
[console.log] click() 返回后(同步): count = 0+
[console.log] INFO src/main.rs:6 app 运行 count = 1
[console.log] INFO src/main.rs:7 effect count = 1
[console.log] 一个微任务之后: count = 1+
  • 第一行 console.log 打的是 count = 0:click() 同步派发事件,wasm 闭包同步跑完 handle_event,count 的值已经是 1,但组件还没重跑、DOM 没改(第 5 节:标脏只是往通道里发了一条消息)。
  • onclick 写完 count 在 click() 返回之前:用户闭包在事件派发的调用栈里。
  • 第二行打的是 count = 1:unbounded_send 唤醒了主循环的 future,执行器用 queueMicrotask 安排了一次 poll,排在测试代码 await Promise.resolve() 的续体前面。app 运行 count = 1、flush_edits、effect count = 1 都在那一次 poll 里做完(第 6、9 节)。

小结

  • 冒泡事件在根元素上只挂一个监听器,监听器就是 Rust 闭包;元素身上只有 data-dioxus-id,它就是 VirtualDom 里的 ElementId。
  • handle_event 由 ElementId 找回「挂载的 VNode + 模板路径」,在模板里按路径冒泡收集监听器,同步调用用户闭包。
  • count += 1 的通知发生在 WriteLock drop 时;组件的 ReactiveContext 只往调度通道发 SchedulerMsg::Immediate,重渲染要等主循环下一次 poll。
  • render_immediate 按高度重跑脏组件;同一模板只比动态部分,这次点击只有一条 SetText,监听器闭包换了也不发指令。
  • WebsysDom 把指令攒进 sledgehammer 缓冲,flush_edits 一次交给 JS;effect 在下一轮 wait_for_work 里、DOM 改完之后才跑。
  • 浏览器里:click() 返回时 DOM 还是旧的,下一个微任务之前已经更新完。

💬 关联讨论

发起讨论
加载中...