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 表、高度、父 id | use_hook、provide_context / consume_context |
tasks / dirty_tasks / current_task | spawn 出来的 future,被唤醒等着 poll 的那些 | 调度、异步 |
pending_effects | 本轮渲染完之后要跑的 effect | use_effect、调度 |
suspended_tasks | 有多少个任务正让某个组件挂起 | Suspense、SSR |
rendering | 是否正在跑组件和 diff(while_rendering 置位) | 对外只通过 vdom_is_rendering() 暴露 |
elements / mounts | ElementId 指向哪个模板里的哪个节点;每块挂载的模板的父节点和各个 id | diff、事件 冒泡 |
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 <i>inner</i></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.
...
ScopeId 上的方法搬回了 Runtime0.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 / 收进 Mutations | SSR / 测试 |
wait_for_work().await | 收调度消息、poll 任务、跑 effect,直到有脏 scope 才返回 | web 主循环 |
process_events() | wait_for_work 的同步部分:收完通道里已有的消息,没有脏 scope 就 poll 任务和 effect | render_immediate 开头、测试 |
render_immediate(&mut to) | 先 process_events,再按调度顺序重跑脏 scope 并 diff,指令写进 to | web 主循环 |
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
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
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
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
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 开始
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 一起释放。
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。 VirtualDomdrop 时按高度倒序释放 scope,再清空任务、effect、挂载信息,放掉对Runtime的引用。