Rust in Everything
基于 dioxus 0.7.10

VirtualDom 与 Runtime

主线位置:更新主线里「wait_for_work 返回 → render_immediate」这两步的外壳;首屏主线里「每个请求一个 VirtualDom → rebuild_in_place → 等 suspense」的那个 VirtualDom。本页讲这两个结构各放什么、渲染器从哪几个入口驱动它们;入口里面的 run_scope、diff、调度顺序分别在后面几页。

flowchart TB
  renderer["渲染器 web 或 SSR"] -->|"拥有 &mut"| vdom[VirtualDom]
  vdom --> scopes["scopes: Slab ScopeState"]
  vdom --> dirty["dirty_scopes 脏队列"]
  vdom --> rx["rx 调度通道接收端"]
  vdom -->|"Rc"| rt[Runtime]
  scopes -->|"Rc"| rt
  wdom["WebsysDom"] -->|"Rc"| rt
  rt --> states["scope_states hooks 和 context"]
  rt --> tasks["tasks 与 dirty_tasks"]
  rt --> effects[pending_effects]
  rt --> mounts["elements 与 mounts"]
  rt --> tx["sender 调度通道发送端"]
  tls["线程局部 RUNTIMES 栈"] -.->|"当前 runtime"| rt

dioxus-core 的核心状态分在两个结构里:渲染器手里那个 VirtualDom,和被大家共享的 Runtime。这一页的代码都在 virtual_dom.rs 和 runtime.rs。

两半:VirtualDom 和 Runtime

VirtualDom 只有五个字段:

pub struct VirtualDom {
    pub(crate) scopes: Slab<ScopeState>,

    pub(crate) dirty_scopes: BTreeSet<ScopeOrder>,

    pub(crate) runtime: Rc<Runtime>,

    // The scopes that have been resolved since the last render
    pub(crate) resolved_scopes: Vec<ScopeId>,

    rx: futures_channel::mpsc::UnboundedReceiver<SchedulerMsg>,
}
  • scopes:每个组件实例一个 ScopeState,下标就是 ScopeId。ScopeState 里放的是 diff 要用的东西:组件的 props(Box<dyn AnyProps>)、上一次渲染出的 VNode(last_rendered_node)、组件自己的 ReactiveContext。详见 Scope 与 hooks。
  • dirty_scopes:等着重跑的组件,ScopeOrder 按(高度,id)排序,BTreeSet 的第一个就是离根最近的那个。出队顺序在 调度。
  • rx:调度通道的接收端。组件被标脏、任务被唤醒、effect 排队,都是往这个通道里发一条 SchedulerMsg,VirtualDom 在 wait_for_work 里收。
  • resolved_scopes:上一轮里从挂起变成就绪的 suspense 边界,流式 SSR 用它决定补发哪几段 HTML,见 流式渲染。

其余的状态都在 Runtime 里(注释删掉了一部分):

pub struct Runtime {
    // We use this to track the current scope
    // This stack should only be modified through [`Runtime::with_scope_on_stack`] to ensure that the stack is correctly restored
    scope_stack: RefCell<Vec<ScopeId>>,

    // We use this to track the current suspense location. Generally this lines up with the scope stack, but it may be different for children of a suspense boundary
    // This stack should only be modified through [`Runtime::with_suspense_location`] to ensure that the stack is correctly restored
    suspense_stack: RefCell<Vec<SuspenseLocation>>,

    // A hand-rolled slab of scope states
    pub(crate) scope_states: RefCell<Vec<Option<Scope>>>,

    // We use this to track the current task
    pub(crate) current_task: Cell<Option<Task>>,

    /// Tasks created with cx.spawn
    pub(crate) tasks: RefCell<slotmap::SlotMap<DefaultKey, Rc<LocalTask>>>,

    // Currently suspended tasks
    pub(crate) suspended_tasks: Cell<usize>,

    pub(crate) rendering: Cell<bool>,

    pub(crate) sender: futures_channel::mpsc::UnboundedSender<SchedulerMsg>,

    // The effects that need to be run after the next render
    pub(crate) pending_effects: RefCell<BTreeSet<Effect>>,

    // Tasks that are waiting to be polled
    pub(crate) dirty_tasks: RefCell<BTreeSet<DirtyTasks>>,

    // The element ids that are used in the renderer
    // These mark a specific place in a whole rsx block
    pub(crate) elements: RefCell<Slab<Option<ElementRef>>>,

    // Once nodes are mounted, the information about where they are mounted is stored here
    // We need to store this information on the virtual dom so that we know what nodes are mounted where when we bubble events
    // Each mount is associated with a whole rsx block. [`VirtualDom::elements`] link to a specific node in the block
    pub(crate) mounts: RefCell<Slab<VNodeMount>>,
}
字段放什么谁在读写
scope_stack / suspense_stack当前正在跑哪个组件、它在哪个 suspense 边界下current_scope_id()、所有 use_*
scope_states每个 scope 的 Scope:hook 列表、context 表、高度、父 iduse_hook、provide_context / consume_context
tasks / dirty_tasks / current_taskspawn 出来的 future,被唤醒等着 poll 的那些调度、异步
pending_effects本轮渲染完之后要跑的 effectuse_effect、调度
suspended_tasks有多少个任务正让某个组件挂起Suspense、SSR
rendering是否正在跑组件和 diff(while_rendering 置位)对外只通过 vdom_is_rendering() 暴露
elements / mountsElementId 指向哪个模板里的哪个节点;每块挂载的模板的父节点和各个 iddiff、事件 冒泡
sender调度通道的发送端ReactiveContext::mark_dirty、任务的 waker

分成两半的原因在于借用。渲染器持有 &mut VirtualDom,render_immediate 里正拿着这个可变借用去跑组件函数;组件函数里的 use_signal、spawn、consume_context,事件回调里的 signal += 1,都要改共享状态,可它们拿不到 &mut VirtualDom。于是凡是用户代码可能碰到的东西,都放进 Runtime,字段全是 Cell / RefCell,通过 &Runtime 就能改。VirtualDom 自己只留下只有它在 diff 时才动的部分:props、上一次的 VNode、脏队列。

同一个组件的状态也因此拆成两份:VirtualDom.scopes 里的 ScopeState,和 Runtime.scope_states 里的 Scope,两边用同一个 ScopeId 下标对齐(注释里说的 "hand-rolled slab")。ScopeState drop 时调 runtime.remove_scope(id) 把另一半一起清掉,顺序见 Scope 与 hooks。

new:先建好四层 scope

VirtualDom::new 最后落到 new_with_component:

    pub(crate) fn new_with_component(root: VComponent) -> Self {
        let (tx, rx) = futures_channel::mpsc::unbounded();

        let mut dom = Self {
            rx,
            runtime: Runtime::new(tx),
            scopes: Default::default(),
            dirty_scopes: Default::default(),
            resolved_scopes: Default::default(),
        };

        let root = VProps::new(
            RootScopeWrapper,
            |_, _| true,
            RootProps(root),
            "RootWrapper",
        );
        dom.new_scope(Box::new(root), "app");

        #[cfg(debug_assertions)]
        dom.register_subsecond_handler();

        dom
    }

通道的两端在这里分开:接收端留在 VirtualDom,发送端进 Runtime。Runtime::new 还先往 elements 里插了一个 None,让 ElementId(0) 永远代表「挂载的根容器」,rebuild 最后的 AppendChildren id=0 就是往它下面挂。

new_scope 只建了一个 scope:0 号,组件是 RootScopeWrapper。它渲染时依次套上一个 SuspenseBoundary 和一个 ErrorBoundary,再在里面放用户的根组件,所以第一次 rebuild 之后 scope 的编号是固定的,scopes.rs 里写成了常量:

    // ScopeId(0) is the root scope wrapper
    // ScopeId(1) is the default suspense boundary
    // ScopeId(2) is the default error boundary
    // ScopeId(3) is the users root scope
    pub const APP: ScopeId = ScopeId(3);

这两层默认边界的用处:组件里 ? 出来的错误和挂起总有一个地方接住。首屏 SSR 渲染完会去问 ScopeId::ROOT_ERROR_BOUNDARY 有没有错误,再决定 HTTP 状态码(见 一次请求的渲染)。手动标脏用户根组件要写 dom.mark_dirty(ScopeId::APP);ScopeId::ROOT 是外面那层包装。

实验:scope 的编号和当前 runtime

实验 crate 依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-ssr =0.7.10、tokio。指令不用 Mutations 的 {:?},而是一个自己实现 WriteMutations 的记录器 Log:每个方法记一行,名字和 Mutation 的分支一致,load_template 额外写出被克隆的模板根节点是什么标签(Mutation::LoadTemplate 里没有这个信息)。后面几页都用它。

use std::rc::Rc;
use std::time::Duration;

use core1_exp::Log;
use dioxus::prelude::*;
use dioxus_core::{Runtime, ScopeId};

fn rt_ptr() -> usize {
  Rc::as_ptr(&Runtime::current()) as *const u8 as usize
}

fn app() -> Element {
  let mut n = use_signal(|| 0);
  // one task: sleep 10 ms, then write the signal once
  use_hook(|| {
    spawn(async move {
      tokio::time::sleep(Duration::from_millis(10)).await;
      n += 1;
    })
  });
  println!("  [render app] runtime = {:#x}", rt_ptr());
  rsx! {
    div { "n = {n}" }
    Child {}
  }
}

#[component]
fn Child() -> Element {
  println!("  [render Child]");
  rsx! { span { "child" } }
}

fn other() -> Element {
  println!("  [render other] runtime = {:#x}", rt_ptr());
  // a VirtualDom built and rendered inside another one's component
  let mut inner = VirtualDom::new(inner_app);
  inner.rebuild_in_place();
  let html = dioxus_ssr::render(&inner);
  println!("  [render other] after inner rebuild, runtime = {:#x}", rt_ptr());
  rsx! { p { "other " {html} } }
}

fn inner_app() -> Element {
  println!("    [render inner_app] runtime = {:#x}", rt_ptr());
  rsx! { i { "inner" } }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  println!("outside: Runtime::try_current().is_some() = {}", Runtime::try_current().is_some());

  let mut dom = VirtualDom::new(app);
  println!("dom.runtime() = {:#x}", Rc::as_ptr(&dom.runtime()) as *const u8 as usize);
  let mut log = Log::default();
  dom.rebuild(&mut log);
  log.print("rebuild");

  dom.in_runtime(|| {
    let rt = Runtime::current();
    for i in 0..6 {
      if let Some(s) = dom.get_scope(ScopeId(i)) {
        println!("{:?} height={} parent={:?}", s.id(), s.height(), rt.parent_scope(s.id()));
      }
    }
  });
  println!("after rebuild: Runtime::try_current().is_some() = {}", Runtime::try_current().is_some());

  dom.render_immediate(&mut log);
  log.print("render_immediate, nothing dirty");

  let t = std::time::Instant::now();
  dom.wait_for_work().await;
  println!("wait_for_work returned, waited >= 10ms: {}", t.elapsed() >= Duration::from_millis(10));
  dom.render_immediate(&mut log);
  log.print("render_immediate after the task wrote n");

  dom.mark_dirty(ScopeId::APP);
  dom.render_immediate(&mut log);
  log.print("mark_dirty(ScopeId::APP) + render_immediate");

  // a second VirtualDom on the same thread has its own Runtime
  let mut dom2 = VirtualDom::new(other);
  println!("dom2.runtime() = {:#x}", Rc::as_ptr(&dom2.runtime()) as *const u8 as usize);
  dom2.rebuild_in_place();
  println!("ssr(dom)  = {}", dioxus_ssr::render(&dom));
  println!("ssr(dom2) = {}", dioxus_ssr::render(&dom2));
}

M2 Max、macOS 27、rustc 1.99.0,debug 构建,cargo run。前半段输出(mark_dirty 那一段留到本页的动脑筋):

outside: Runtime::try_current().is_some() = false
dom.runtime() = 0x785ac16290
  [render app] runtime = 0x785ac16290
  [render Child]
--- rebuild: 5 edits
  LoadTemplate <div> index=0 id=1
  CreateTextNode "n = 0" id=2
  ReplacePlaceholder path=[0] m=1
  LoadTemplate <span> index=0 id=3
  AppendChildren id=0 m=2
ScopeId(0, "app") height=0 parent=None
ScopeId(1, "dioxus_core::suspense::component::SuspenseBoundary") height=1 parent=Some(ScopeId(0, "app"))
ScopeId(2, "dioxus_core::error_boundary::ErrorBoundary") height=2 parent=Some(ScopeId(1, "dioxus_core::suspense::component::SuspenseBoundary"))
ScopeId(3, "root") height=3 parent=Some(ScopeId(2, "dioxus_core::error_boundary::ErrorBoundary"))
ScopeId(4, "vdom_entry::Child") height=4 parent=Some(ScopeId(3, "root"))
after rebuild: Runtime::try_current().is_some() = false
--- render_immediate, nothing dirty: 0 edits
wait_for_work returned, waited >= 10ms: true
  [render app] runtime = 0x785ac16290
--- render_immediate after the task wrote n: 1 edits
  SetText "n = 1" id=2
...
  • 一个只有一个子组件的应用有 5 个 scope:0 到 2 是框架加的包装和两层默认边界,3 是 app,4 是 Child,高度逐层加一。ScopeId 的 Debug 在 debug 构建里会去当前 runtime 查名字,所以要在 in_runtime 里打印;0 号叫 "app"、3 号叫 "root",是 new_scope 和 VComponent 各自传进去的名字,不是组件函数名。
  • 组件里拿到的 Runtime::current() 和 dom.runtime() 是同一个 Rc;rebuild 返回之后,线程上又没有当前 runtime 了。
  • rebuild 只跑组件、出指令,不 poll 任务。spawn 只是把任务插进 tasks、往通道里发一条 TaskNotified;第一次 poll 发生在下一次 process_events 里,这里就是那次「什么都不脏」的 render_immediate(它开头先 process_events),任务停在 sleep 上。10 毫秒后计时器唤醒任务,wait_for_work 收到消息、poll 任务,任务写 n 把 app 标脏,wait_for_work 返回;render_immediate 重跑 app,只发一条 SetText。

当前 runtime:线程局部的栈

use_signal(|| 0) 没有参数指向哪个 VirtualDom。0.4 及以前组件函数收一个 cx: Scope,hook 都挂在 cx 上;现在 hook 是普通函数,要靠一个隐式的「当前 runtime」找到状态。这个「当前」是 runtime.rs 开头的线程局部变量:

thread_local! {
    static RUNTIMES: RefCell<Vec<Rc<Runtime>>> = const { RefCell::new(vec![]) };
}

Runtime::current 取栈顶,没有就 panic;try_current 返回 Option。入栈出栈只经过一个 guard,RuntimeGuard:

pub struct RuntimeGuard(());

impl RuntimeGuard {
    /// Create a new runtime guard that sets the current Dioxus runtime. The runtime will be reset when the guard is dropped
    pub fn new(runtime: Rc<Runtime>) -> Self {
        Runtime::push(runtime);
        Self(())
    }
}

impl Drop for RuntimeGuard {
    fn drop(&mut self) {
        Runtime::pop();
    }
}

VirtualDom 的每个会跑用户代码的入口(rebuild、render_immediate、poll_tasks、in_runtime),Runtime::in_scope、Runtime::handle_event,以及 Callback::call,一开头都建一个 guard。tokio 用的是同一个办法:Handle::current() 也是读线程局部的 CONTEXT,见 tokio 卷的线程本地上下文。

为什么是栈而不是一个槽:一个线程上可以同时有好几个 VirtualDom,而且可以嵌套。实验后半段,other 组件在自己的渲染过程中新建并渲染了另一个 VirtualDom(dioxus_ssr::render_element 就是这么做的,它在内部 VirtualDom::new_with_props 再 rebuild_in_place):

dom2.runtime() = 0x785ac16410
  [render other] runtime = 0x785ac16410
    [render inner_app] runtime = 0x785ac16590
  [render other] after inner rebuild, runtime = 0x785ac16410
ssr(dom)  = <div>n = 1</div><span>child</span>
ssr(dom2) = <p>other &#60;i&#62;inner&#60;/i&#62;</p>

内层的 rebuild_in_place 把自己的 runtime 压到栈顶,inner_app 看到的是 …6590;返回时 guard 弹掉它,other 接下来看到的又是自己的 …6410。如果只有一个槽,内层返回后外层组件的 use_* 就会找错 runtime 或找不到。

为什么是 Rc:同一个 Runtime 被好几个地方同时持有。VirtualDom.runtime、每个 ScopeState.runtime、web 渲染器的 WebsysDom.runtime、SuspenseContext 里各有一个 Rc<Runtime>;Callback 里存的是 Weak<Runtime>,回调被调用时再 upgrade,失败就报 "Callback was called after the runtime was dropped"。用 Rc 而不是 Arc,是因为整套东西本来就是单线程的:wasm 上只有一个线程,状态又都在 RefCell 里。代价是 VirtualDom 是 !Send,服务端只能把每个请求钉在一个本地线程上渲染,见 一次请求的渲染。

在 runtime 外面调 hook,Runtime::current 的 panic 原文(fn main() { let s = use_signal(|| 0); },前几行):

thread 'main' (981603) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/runtime.rs:100:17:
Must be called from inside a Dioxus runtime.

Help: Some APIs in dioxus require a global runtime to be present.
If you are calling one of these APIs from outside of a dioxus runtime
(typically in a web-sys closure or dynamic library), you will need to
grab the runtime from a scope that has it and then move it into your
new scope with a runtime guard.
...
📝NOTE
冷知识:0.7 把 ScopeId 上的方法搬回了 Runtime

0.6.3 里 Runtime::current() 返回 Result<Rc<Self>, RuntimeError>,一串操作挂在 ScopeId 上:ScopeId::parent_scope(self)、ScopeId::consume_context(self)、ScopeId::needs_update(self) 等,它们在内部隐式调 Runtime::current()。#4723 的说明是:"I frequently ran into issues that were hard to figure out properly because of the many layers of .unwrap() and .flatten() around the presence of the runtime. This PR moves methods away from ScopeId back to the runtime, making it more obvious when Runtime::current() is called." 现在 Runtime::current() 直接返回 Rc<Self>、没有就 panic,要 Option 用 try_current();上面实验里的 rt.parent_scope(id)、rt.height(id) 就是搬过来的方法。首次发布在 v0.7.0-rc.1,正式版 v0.7.0。

对外的入口

方法做什么谁用
rebuild(&mut to)第一次跑根组件,整棵树的创建指令写进 to,最后 AppendChildren 到 ElementId(0)web 首次挂载、水合
rebuild_in_place() / rebuild_to_vec()同上,指令分别丢进 NoOpMutations / 收进 MutationsSSR / 测试
wait_for_work().await收调度消息、poll 任务、跑 effect,直到有脏 scope 才返回web 主循环
process_events()wait_for_work 的同步部分:收完通道里已有的消息,没有脏 scope 就 poll 任务和 effectrender_immediate 开头、测试
render_immediate(&mut to)先 process_events,再按调度顺序重跑脏 scope 并 diff,指令写进 toweb 主循环
wait_for_suspense().await循环等挂起的任务完成、重跑它们所在的 scope,直到没有挂起也没有脏 scope非流式 SSR
wait_for_suspense_work() / render_suspense_immediate()上面那个循环拆开的两步,后者返回本轮完成的 suspense 边界流式 SSR
mark_dirty(id) / mark_all_dirty()手动把 scope 放进脏队列测试、热重载
in_runtime(f) / in_scope(id, f)在当前 runtime(和某个 scope)下跑一段代码渲染器往根上提供 context
provide_root_context(v)往 0 号 scope 的 context 表里放一个值SSR 放 ServerDocument、FullstackContext

rebuild

rebuild:

    pub fn rebuild(&mut self, to: &mut impl WriteMutations) {
        let _runtime = RuntimeGuard::new(self.runtime.clone());
        let new_nodes = self
            .runtime
            .clone()
            .while_rendering(|| self.run_scope(ScopeId::ROOT));

        let new_nodes = LastRenderedNode::new(new_nodes);

        self.scopes[ScopeId::ROOT.0].last_rendered_node = Some(new_nodes.clone());

        // Rebuilding implies we append the created elements to the root
        let m = self.create_scope(Some(to), ScopeId::ROOT, new_nodes, None);

        to.append_children(ElementId(0), m);
    }

它只显式跑了 0 号 scope。子组件是在 create_scope 往下创建节点、遇到 DynamicNode::Component 时才建 scope 并第一次运行的(create_component_node,见 diff),所以 scope 的编号就是深度优先创建的顺序。m 是根模板压到栈上的节点数,最后一条 AppendChildren id=0 m=2 把 div 和 span 一起挂到根容器下。

wait_for_work

wait_for_work:

    pub async fn wait_for_work(&mut self) {
        loop {
            // Process all events - Scopes are marked dirty, etc
            // Sometimes when wakers fire we get a slew of updates at once, so its important that we drain this completely
            self.process_events();

            // Now that we have collected all queued work, we should check if we have any dirty scopes. If there are not, then we can poll any queued futures
            if self.has_dirty_scopes() {
                return;
            }

            // Make sure we set the runtime since we're running user code
            let _runtime = RuntimeGuard::new(self.runtime.clone());

            // There isn't any more work we can do synchronously. Wait for any new work to be ready
            self.wait_for_event().await;
        }
    }

返回条件只有一个:有脏 scope。被唤醒的任务、排队的 effect 都在 process_events → poll_tasks 里就地跑掉,它们若写了 signal、把组件标脏,下一圈检查就返回。wait_for_event 只等通道里的下一条消息,.await 本身不做别的事,所以这个 future 被 select! 丢掉是安全的(文档注释写的 "cancel-safe")。四种 SchedulerMsg 各自的处理在 调度。

render_immediate

render_immediate:

    pub fn render_immediate(&mut self, to: &mut impl WriteMutations) {
        // Process any events that might be pending in the queue
        // Signals marked with .write() need a chance to be handled by the effect driver
        // This also processes futures which might progress into immediately rerunning a scope
        self.process_events();

        // Next, diff any dirty scopes
        // We choose not to poll the deadline since we complete pretty quickly anyways
        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 每次取出最该先做的一件事:离根最近的脏 scope,或者属于更高处 scope 的任务。RerunScope 走 run_and_diff_scope:run_scope 拿到新 VNode,和 last_rendered_node diff,指令写进 to。父组件重跑时,diff 到子组件节点会顺带决定子组件要不要重跑,重跑过的子组件从 dirty_scopes 里删掉,不会再被 pop_work 取到第二次。最后 finish_render 检查有没有排队的 effect,有就往通道里发 EffectQueued:effect 要等渲染器把这批指令真正应用到 DOM 之后,在下一次 wait_for_work 里才跑。

方法名里的 "immediate" 指的是不等挂起的子树:被 suspense 挂起的组件照样参与 diff,但它们的指令不写进 to(scope_should_render 返回 false 时 to 被换成 None),等任务完成再说。挂起的细节在 Suspense 与错误边界。

wait_for_suspense

wait_for_suspense:

    pub async fn wait_for_suspense(&mut self) {
        loop {
            self.queue_events();

            if !self.suspended_tasks_remaining() && !self.has_dirty_scopes() {
                break;
            }

            self.wait_for_suspense_work().await;

            self.render_suspense_immediate().await;
        }
    }

它和 wait_for_work + render_immediate 有两点不同。第一,退出条件是「runtime.suspended_tasks 归零并且没有脏 scope」,而不是「有脏 scope」。第二,wait_for_suspense_work 只 poll task_runs_during_suspense 的任务(挂起的任务和 spawn_isomorphic 出来的),render_suspense_immediate 只重跑在 suspense 边界下面的 scope,而且不往外写指令(传的是 None):服务端不需要指令,最后由 dioxus-ssr 沿 VNode 树直接生成 HTML。两个函数各自数着做了几件事,计数超过 32 就主动 yield_now 一次,把线程让给同一线程上其他请求的渲染。

渲染器怎么组合这些入口

web:一个永不返回的循环

dioxus_web::run 先 rebuild(或水合:skip_mutations = true 再 rebuild,然后 rehydrate),之后进入主循环。去掉热重载和水合分支,剩下的是:

    loop {
        // ...
        {
            let work = virtual_dom.wait_for_work().fuse();
            pin_mut!(work);
            // ...
                select! {
                    _ = work => {},
                    hyd = rx_hydration => {
                        // ...
                    }
                }
        }
        // ...
        // run the virtualdom work phase until the frame deadline is reached
        virtual_dom.render_immediate(&mut websys_dom);

        // wait for the animation frame to fire so we can apply our changes
        // work_loop.wait_for_raf().await;

        websys_dom.flush_edits();
    }

浏览器事件不经过这个循环。WebsysDom 在 JS 的事件回调里直接调 runtime.handle_event(..),用户闭包写 signal,signal 通知组件的 ReactiveContext,ReactiveContext 往通道里发 SchedulerMsg::Immediate(scope);正在 .await 的 wait_for_work 被这条消息唤醒,发现有脏 scope 就返回,循环接着 render_immediate 把 diff 结果写进 WebsysDom 的缓冲,flush_edits 一次交给 JS。这就是更新主线的中段;WebsysDom 那一侧见 WebsysDom 与 interpreter。注释里的「空闲时 diff、动画帧里应用」两步现在是关着的。

SSR:一次性的 rebuild 加等待

fullstack 服务端为每个请求新建一个 VirtualDom(SsrRendererPool::render_to 里的 virtual_dom_factory()),往根上 provide_root_context 文档、请求上下文、路由历史之后:

            // rebuild the virtual dom
            virtual_dom.rebuild_in_place();

            // If streaming is disabled, wait for the virtual dom to finish all suspense work
            // before rendering anything
            if streaming_mode == StreamingMode::Disabled {
                virtual_dom.wait_for_suspense().await;
            } else {
                // Otherwise, just wait for the streaming context to signal the initial chunk is ready
                loop {
                    // ...
                    let suspense_finished = !virtual_dom.suspended_tasks_remaining();
                    if streaming_context_finished || suspense_finished {
                        break;
                    }

                    // Wait for new async work that runs during suspense (mainly use_server_futures)
                    virtual_dom.wait_for_suspense_work().await;

                    // Do that async work
                    virtual_dom.render_suspense_immediate().await;
                }
            }

不流式时就是 rebuild_in_place + wait_for_suspense;流式时把循环拆开,在首块可以提交时提前退出,剩下的边界边完成边发。整个过程中从不调 wait_for_work 和 render_immediate:服务端不处理事件,也不跑 effect。整段流程在 一次请求的渲染。

把这个组合缩到最小,等的是一个 use_resource(..).suspend()?:

use std::time::Duration;

use dioxus::prelude::*;

fn app() -> Element {
  rsx! {
    h1 { "title" }
    SuspenseBoundary {
      fallback: |_| rsx! { "loading..." },
      Slow {}
    }
  }
}

#[component]
fn Slow() -> Element {
  let data = use_resource(|| async {
    tokio::time::sleep(Duration::from_millis(20)).await;
    "data".to_string()
  })
  .suspend()?;
  rsx! { p { "{data}" } }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let mut dom = VirtualDom::new(app);
  dom.rebuild_in_place();
  println!("after rebuild_in_place: suspended_tasks_remaining = {}", dom.suspended_tasks_remaining());
  println!("  {}", dioxus_ssr::render(&dom));
  dom.wait_for_suspense().await;
  println!("after wait_for_suspense: suspended_tasks_remaining = {}", dom.suspended_tasks_remaining());
  println!("  {}", dioxus_ssr::render(&dom));
}
after rebuild_in_place: suspended_tasks_remaining = true
  <h1>title</h1>loading...
after wait_for_suspense: suspended_tasks_remaining = false
  <h1>title</h1><p>data</p>

rebuild_in_place 之后组件挂着,HTML 里是 fallback;wait_for_suspense 把资源的 future 跑完、重跑 Slow,再渲染就是数据。dioxus_ssr::render(&dom) 不改 VirtualDom,只是读每个 scope 的 last_rendered_node,所以同一个 dom 可以渲染两次。

drop:从最深的 scope 开始

VirtualDom 的 Drop:

impl Drop for VirtualDom {
    fn drop(&mut self) {
        // Drop all scopes in order of height
        let mut scopes = self.scopes.drain().collect::<Vec<_>>();
        scopes.sort_by_key(|scope| scope.state().height);
        for scope in scopes.into_iter().rev() {
            drop(scope);
        }

        // Drop the mounts, tasks, and effects, releasing any `Rc<Runtime>` references
        self.runtime.pending_effects.borrow_mut().clear();
        self.runtime.tasks.borrow_mut().clear();
        self.runtime.mounts.borrow_mut().clear();
    }
}

按高度排序后倒着 drop:子组件的 hook 先于父组件释放,和运行中卸载一个子树的顺序一致(Scope 与 hooks 的实验两种情况都跑了)。之后清空 effect、任务和挂载信息:任务的 future 和挂载着的 VNode 里可能藏着 Callback、signal 之类间接引用 runtime 的东西,清掉它们,Runtime 的最后一个 Rc 才能随 VirtualDom 一起释放。

❗IMPORTANT
动脑筋:任务写完 n 之后,再 mark_dirty(ScopeId::APP) 并 render_immediate 一次,会发出几条指令?Child 会重跑吗?

就是上面 vdom_entry 程序里倒数第二段:

  dom.mark_dirty(ScopeId::APP);
  dom.render_immediate(&mut log);
  log.print("mark_dirty(ScopeId::APP) + render_immediate");

app 会重跑一次,这时 n 已经是 1、没有再变。先猜指令条数,再猜输出里会不会出现 [render Child]。

动脑筋答案

  [render app] runtime = 0x785ac16290
--- mark_dirty(ScopeId::APP) + render_immediate: 0 edits
  • 0 条指令。app 被标脏后确实重跑了(打印了 [render app]),产出一个新 VNode;它和上一次的是同一个模板,diff 只比动态部分:文本还是 "n = 1",diff_vtext 发现值相等,什么也不写。
  • Child 没有重跑。它没有 props(()),memoize 恒为 true,diff 到组件节点时直接跳过。被标脏的只有 app 自己,子组件跟不跟着跑由 props 决定,见 diff 的组件一节。

小结

  • VirtualDom 只放渲染器要可变借用的部分:scopes(props、上一次的 VNode、组件的 ReactiveContext)、dirty_scopes、调度通道的接收端。用户代码会碰到的状态全在 Runtime:scope 栈、每个 scope 的 hooks 和 context、任务、effect、ElementId 与挂载信息、通道的发送端,字段全是 Cell / RefCell。
  • VirtualDom::new 只建 0 号 scope,它套出 1 号 SuspenseBoundary、2 号 ErrorBoundary,用户根组件固定是 ScopeId::APP(3)。
  • 当前 runtime 是线程局部的 RUNTIMES 栈,RuntimeGuard 进出;用栈是因为同一线程上可以嵌套渲染多个 VirtualDom(实验里内层 rebuild 之后外层仍拿到自己的 runtime)。Rc 被 VirtualDom、ScopeState、WebsysDom 共享,Callback 持 Weak;单线程的代价是 VirtualDom 为 !Send。
  • 入口的分工:rebuild 出首屏指令;wait_for_work 收消息、poll 任务、跑 effect,有脏 scope 才返回;render_immediate 按调度顺序重跑脏 scope 并 diff;wait_for_suspense 只推进挂起的工作,不出指令。
  • web 主循环是 select! 等 wait_for_work,然后 render_immediate 加 flush_edits,事件在循环外由 handle_event 直接处理;SSR 每个请求 rebuild_in_place 一次,再 wait_for_suspense(或流式的拆开版本),最后读 VNode 树出 HTML。
  • VirtualDom drop 时按高度倒序释放 scope,再清空任务、effect、挂载信息,放掉对 Runtime 的引用。

💬 关联讨论

发起讨论
加载中...