Scope 与 hooks
主线位置:更新主线里「
render_immediate→run_scope重跑组件」的run_scope本身:组件函数在哪个 scope、哪个ReactiveContext里运行,use_signal怎么拿回上一次的 signal。首屏主线里每个组件第一次运行也走这里。
flowchart TB
dirty["脏 scope 出队"] --> runscope["run_scope id"]
runscope --> push["scope 压栈 hook_index 归零"]
push --> rc["ReactiveContext reset_and_run_in"]
rc --> render["props.render 调组件函数"]
render --> hooks["use_hook 按序号取值"]
render --> ctx["consume_context 沿父链找"]
render --> ret{"返回什么"}
ret -->|"Ok VNode"| okn["render_count 加一"]
ret -->|"Err Error"| errn["throw_error 给边界"]
ret -->|"Err Suspended"| susp["suspended_tasks 加一"]
okn --> diffn["交给 diff"]
这一页讲一个组件实例在 dioxus-core 里是什么:它的编号、它存了哪些东西、run_scope 怎么调它、hook 和 context 怎么存取、什么时候被删掉。代码在 scope_arena.rs、scope_context.rs、scopes.rs。
ScopeId 和两份状态
ScopeId 就是一个 usize:VirtualDom.scopes 这个 Slab 的下标。0 到 2 号是框架加的包装和两层默认边界,用户的根组件固定是 3 号(ScopeId::APP),见 VirtualDom 与 Runtime。另有一个内部用的 ScopeId::PLACEHOLDER = ScopeId(usize::MAX):模板里的组件槽位还没有建 scope 时,挂载信息里记的就是它。
一个组件实例的状态分两处放。ScopeState 在 VirtualDom.scopes 里,放 diff 用的东西:
pub struct ScopeState {
pub(crate) runtime: Rc<Runtime>,
pub(crate) context_id: ScopeId,
/// The last node that has been rendered for this component. This node may not ben mounted
/// During suspense, this component can be rendered in the background multiple times
pub(crate) last_rendered_node: Option<LastRenderedNode>,
pub(crate) props: BoxedAnyProps,
pub(crate) reactive_context: ReactiveContext,
}
Scope 在 Runtime.scope_states 里,放组件运行时用户代码要碰的东西:
pub(crate) struct Scope {
pub(crate) name: &'static str,
pub(crate) id: ScopeId,
pub(crate) parent_id: Option<ScopeId>,
pub(crate) height: u32,
pub(crate) render_count: Cell<usize>,
// Note: the order of the hook and context fields is important. The hooks field must be dropped before the contexts field in case a hook drop implementation tries to access a context.
pub(crate) hooks: RefCell<Vec<Box<dyn Any>>>,
pub(crate) hook_index: Cell<usize>,
pub(crate) shared_contexts: RefCell<Vec<Box<dyn Any>>>,
pub(crate) spawned_tasks: RefCell<FxHashSet<Task>>,
pub(crate) before_render: RefCell<Vec<Box<dyn FnMut()>>>,
pub(crate) after_render: RefCell<Vec<Box<dyn FnMut()>>>,
/// The suspense boundary that this scope is currently in (if any)
suspense_boundary: SuspenseLocation,
pub(crate) status: RefCell<ScopeStatus>,
}
分两处的原因在上一页讲过:run_scope 时 VirtualDom 正被可变借用,组件函数里的 use_hook 只能通过 &Runtime 改状态,所以 hook 列表、context 表、任务集合都在 Runtime 这边,全是 RefCell / Cell。
parent_id和height在建 scope 时就定下,以后不变。height是到根的距离,调度按它从上往下重跑(见 调度)。hooks是Vec<Box<dyn Any>>,每个 hook 一格,hook_index是本次渲染走到第几个了。shared_contexts也是Vec<Box<dyn Any>>,按类型查找,见下面的 context 一节。spawned_tasks:这个组件spawn出来的任务,scope 删掉时一起取消。render_count:成功渲染的次数,generation()返回它。suspense_boundary:这个 scope 是不是某个 suspense 边界、在不在某个边界下面,在 Suspense 与错误边界。
new_scope 同时建这两份:
pub(super) fn new_scope(
&mut self,
props: BoxedAnyProps,
name: &'static str,
) -> &mut ScopeState {
let parent_id = self.runtime.try_current_scope_id();
let height = match parent_id.and_then(|id| self.runtime.try_get_state(id)) {
Some(parent) => parent.height() + 1,
None => 0,
};
let suspense_boundary = self
.runtime
.current_suspense_location()
.unwrap_or(SuspenseLocation::NotSuspended);
let entry = self.scopes.vacant_entry();
let id = ScopeId(entry.key());
let scope_runtime = Scope::new(name, id, parent_id, height, suspense_boundary);
let reactive_context = ReactiveContext::new_for_scope(&scope_runtime, &self.runtime);
let scope = entry.insert(ScopeState {
runtime: self.runtime.clone(),
context_id: id,
props,
last_rendered_node: Default::default(),
reactive_context,
});
self.runtime.create_scope(scope_runtime);
scope
}
父 scope 不是传进来的,而是「scope 栈的栈顶」:子组件是在父组件的 create_scope / diff_scope 里被创建的,那时父组件正在 with_scope_on_stack 里,栈顶就是它。id 来自 Slab::vacant_entry,slab 会优先复用最近释放的下标,下面的实验能看到这一点。
每个 scope 建好就带一个 ReactiveContext::new_for_scope,它的 update 闭包只做一件事:往调度通道发 SchedulerMsg::Immediate(id)。组件渲染时读过的 signal 订阅的就是它,signal 被写时调 mark_dirty,这个组件就进了脏队列。订阅怎么记录在 Signal。
run_scope:调组件函数
pub(crate) fn run_scope(&mut self, scope_id: ScopeId) -> Element {
// Ensure we are currently inside a `Runtime`.
crate::Runtime::current();
self.runtime.clone().with_scope_on_stack(scope_id, || {
let scope = &self.scopes[scope_id.0];
let output = {
let scope_state = scope.state();
scope_state.hook_index.set(0);
// Run all pre-render hooks
for pre_run in scope_state.before_render.borrow_mut().iter_mut() {
pre_run();
}
let props: &dyn AnyProps = &*scope.props;
let span = tracing::trace_span!("render", scope = %scope.state().name);
span.in_scope(|| {
scope.reactive_context.reset_and_run_in(|| {
let render_return = props.render();
// After the component is run, we need to do a deep clone of the VNode. This
// breaks any references to mounted parts of the VNode from the component.
// ...
let mut render_return = match render_return {
Ok(node) => Ok(node.deep_clone()),
Err(RenderError::Error(err)) => Err(RenderError::Error(err.clone())),
Err(RenderError::Suspended(fut)) => {
Err(RenderError::Suspended(fut.deep_clone()))
}
};
self.handle_element_return(&mut render_return, &scope.state());
render_return
})
})
};
let scope_state = scope.state();
// Run all post-render hooks
for post_run in scope_state.after_render.borrow_mut().iter_mut() {
post_run();
}
// remove this scope from dirty scopes
self.dirty_scopes
.remove(&ScopeOrder::new(scope_state.height, scope_id));
output
})
}
按顺序:
- 压栈。
with_scope_on_stack把scope_id压进Runtime.scope_stack(同时压入它的 suspense 位置),组件里的current_scope_id()、use_hook、consume_context都读这个栈顶。 hook_index归零。 这一次渲染从第 0 个 hook 开始数。before_render回调(use_before_render注册的)。- 在组件自己的
ReactiveContext里跑。reset_and_run_in先清掉上一次的订阅,再把这个 context 压进reactive_context.rs里的线程局部栈CURRENT;组件函数里每次signal.read()都订阅栈顶的 context。所以组件只订阅它这一次渲染读到的 signal,上次读、这次没读的不再触发重跑。 props.render()。VProps::render先 clone 一份 props,再在std::panic::catch_unwind里调组件函数;组件 panic 了不会冲垮整个VirtualDom,而是变成一个CapturedPanic错误(只在原生平台上有效:wasm 上 panic 直接中止,源码注释写的是 "we can't even capture the panic",那里什么也不做)。下面「hook 放进条件里」的实验就会看到这条路径。deep_clone。 返回的VNode整棵复制一份(Fragment 里的子VNode也复制),再交给 diff。原因见下面的冷知识。handle_element_return。Err(RenderError::Error)打一条tracing::error!并throw_error给最近的错误边界;Err(RenderError::Suspended)把任务登记到最近的 suspense 边界、suspended_tasks加一;Ok则render_count加一。after_render回调,然后把自己从dirty_scopes里删掉:父组件 diff 时顺手重跑了的子组件,不会再被调度器取出来跑第二遍。
run_scope 只产出新的 Element,不碰旧的 last_rendered_node,也不写任何指令;和旧节点比较、写指令是调用方 diff_scope 的事,见 diff。
VNode 深拷贝一遍VNode 是 Rc<VNodeInner> 加一个 Cell<MountId>,clone 只增加引用计数,挂载信息跟着同一个 Rc 走。组件完全可以把上次返回的 VNode 存起来(比如放在一个 resource 里)下次原样返回,这时新旧两个 VNode 共享同一份挂载信息,diff 就会把「已经挂载的旧节点」当成新节点处理。#3271("Fix diffing cloned rsx nodes by deep cloning nodes before mounting them")的说明是:"The resource was returning a cloned version of the rsx from the previous run which was mounted. We should never have mounted VNodes in components so that cloning VNodes works as expected. This PR fixes the issue by performing a deep clone of the VNode before diffing so the version of the vnode that lives in diffing/core doesn't share the same Cell<Mount> as the version of the vnode that lives in the component." 首次发布在 v0.6.0-rc.0,正式版 v0.6.0。
use_hook:只认序号
所有 hook 最后都落到 Scope::use_hook(公开的 use_hook 只是 Runtime::with_current_scope(|cx| cx.use_hook(initializer)))。use_signal(f) 就是 use_hook(|| Signal::new_with_caller(f(), caller)),use_context_provider(f) 是 use_hook(|| provide_context(f()))。
pub(crate) fn use_hook<State: Clone + 'static>(
&self,
initializer: impl FnOnce() -> State,
) -> State {
let cur_hook = self.hook_index.get();
// The hook list works by keeping track of the current hook index and pushing the index forward
// while retrieving the hook value.
self.hook_index.set(cur_hook + 1);
let mut hooks = self.hooks
.try_borrow_mut()
.expect("The hook list is already borrowed: This error is likely caused by trying to use hook inside a hook which violates the rules of hooks.");
// Try and retrieve the hook value if it exists
if let Some(existing) = self.use_hook_inner::<State>(&mut hooks, cur_hook) {
return existing;
}
// Otherwise, initialize the hook value. In debug mode, we allow hook types to change after a hot patch
self.push_hook_value(&mut hooks, cur_hook, initializer())
}
// The interior version that gets monomorphized by the `State` type but not the `initializer` type.
// This helps trim down binary sizes
fn use_hook_inner<State: Clone + 'static>(
&self,
hooks: &mut Vec<Box<dyn std::any::Any>>,
cur_hook: usize,
) -> Option<State> {
hooks.get(cur_hook).and_then(|inn| {
let raw_ref: &dyn Any = inn.as_ref();
raw_ref.downcast_ref::<State>().cloned()
})
}
取值的依据只有两样:序号 cur_hook,和 类型 State(downcast_ref)。hook 没有名字,也不记调用位置。第 cur_hook 格存在且类型对得上,就返回它的一个 clone,initializer 不调用;否则交给 push_hook_value:
fn push_hook_value<State: Clone + 'static>(
&self,
hooks: &mut Vec<Box<dyn std::any::Any>>,
cur_hook: usize,
value: State,
) -> State {
// If this is a new hook, push it
if cur_hook >= hooks.len() {
hooks.push(Box::new(value.clone()));
return value;
}
// If we're in dev mode, we allow swapping hook values if the hook was initialized at this index
if cfg!(debug_assertions) && unsafe { subsecond::get_jump_table().is_some() } {
hooks[cur_hook] = Box::new(value.clone());
return value;
}
// Otherwise, panic
panic!(
r#"Unable to retrieve the hook that was initialized at this index.
Consult the `rules of hooks` to understand how to use hooks properly.
You likely used the hook in a conditional. Hooks rely on consistent ordering between renders.
Functions prefixed with "use" should never be called conditionally.
Help: Run `dx check` to look for check for some common hook errors."#
);
}
三种情况:序号超出列表长度,是新 hook,追加;序号在列表里但类型不对,正常情况下 panic;只有 debug 构建并且进程里有 subsecond 的跳转表(也就是在 dx serve 的热补丁下运行)时,才用新值覆盖旧值。
State: Clone 是因为每次渲染都返回一份 clone。所以 hook 里存的通常是句柄:Signal(Copy,值在 generational-box 里,见 generational-box)、Rc、Task 之类;存一个大 Vec 意味着每次渲染都复制一遍。
另一处 expect:hooks 用 try_borrow_mut 借出来,在 initializer 里再调一个 hook(use_hook(|| use_signal(..)))会撞上这次借用,panic 信息就是那句 "The hook list is already borrowed"。
实验:hook 放进条件里
三种写法,第一次渲染和第二次渲染时 flag 不同。组件读线程局部的 CASE 和 FLAG(下面省略了 use 和这两个 thread_local! 的声明),每次改完 FLAG 就 mark_dirty(ScopeId::APP) 再 render_immediate:
fn app() -> Element {
let flag = FLAG.with(|f| f.get());
match CASE.with(|c| c.get()) {
// A: the conditional hook has a different type from the next one
0 => {
if flag {
let s = use_signal(|| "text".to_string());
println!(" [A] s = {s}");
}
let n = use_signal(|| 7i32);
println!(" [A] n = {n}");
rsx! { "n = {n}" }
}
// B: both hooks are Signal<i32>
1 => {
if flag {
let a = use_signal(|| 1i32);
println!(" [B] a = {a}");
}
let b = use_signal(|| 2i32);
println!(" [B] b = {b}");
rsx! { "b = {b}" }
}
// C: the conditional hook is the last one
_ => {
let b = use_signal(|| 2i32);
if flag {
let a = use_signal(|| 1i32);
println!(" [C] a = {a}");
}
println!(" [C] b = {b}");
rsx! { "b = {b}" }
}
}
}
fn run(case: u8, flags: &[bool]) {
CASE.with(|c| c.set(case));
FLAG.with(|f| f.set(flags[0]));
let mut dom = VirtualDom::new(app);
println!("render 1 (flag = {})", flags[0]);
dom.rebuild_in_place();
println!(" html: {}", dioxus_ssr::render(&dom));
for (i, &flag) in flags.iter().enumerate().skip(1) {
FLAG.with(|f| f.set(flag));
println!("render {} (flag = {flag})", i + 1);
dom.mark_dirty(ScopeId::APP);
dom.render_immediate_to_vec();
println!(" html: {}", dioxus_ssr::render(&dom));
}
let edits = dom.render_immediate_to_vec();
println!("one more render_immediate, nothing marked dirty: {} edits", edits.edits.len());
println!(" html: {}", dioxus_ssr::render(&dom));
}
fn main() {
println!("== case A: String hook skipped, then i32 hook");
run(0, &[true, false]);
println!("== case B: i32 hook skipped, then i32 hook");
run(1, &[true, false]);
println!("== case C: conditional hook at the end");
run(2, &[false, true, false, true]);
}
M2 Max、macOS 27、rustc 1.99.0,cargo run(debug):
== case A: String hook skipped, then i32 hook
render 1 (flag = true)
[A] s = text
[A] n = 7
html: n = 7
render 2 (flag = false)
thread 'main' (1057674) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_context.rs:387:9:
Unable to retrieve the hook that was initialized at this index.
Consult the `rules of hooks` to understand how to use hooks properly.
You likely used the hook in a conditional. Hooks rely on consistent ordering between renders.
Functions prefixed with "use" should never be called conditionally.
Help: Run `dx check` to look for check for some common hook errors.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
html: n = 7
one more render_immediate, nothing marked dirty: 6 edits
html: <div style="color:red;"><pre>Encountered panic: Any { .. }</pre></div>
== case B: i32 hook skipped, then i32 hook
render 1 (flag = true)
[B] a = 1
[B] b = 2
html: b = 2
render 2 (flag = false)
[B] b = 1
html: b = 1
one more render_immediate, nothing marked dirty: 0 edits
html: b = 1
== case C: conditional hook at the end
render 1 (flag = false)
[C] b = 2
html: b = 2
render 2 (flag = true)
[C] a = 1
[C] b = 2
html: b = 2
render 3 (flag = false)
[C] b = 2
html: b = 2
render 4 (flag = true)
[C] a = 1
[C] b = 2
html: b = 2
one more render_immediate, nothing marked dirty: 0 edits
html: b = 2
cargo run --release 的输出除线程号外逐行相同:这里没有热补丁的跳转表,debug 构建也走 panic 分支。
- A:类型不同,panic。 第二次渲染时
use_signal(|| 7i32)拿到的序号是 0,第 0 格里是Signal<String>,downcast_ref::<Signal<i32>>失败,序号又在列表范围内,于是 panic,位置是scope_context.rs:387。这个 panic 被VProps::render的catch_unwind接住,变成CapturedPanic错误交给根上的默认ErrorBoundary,进程没有退出。同一次render_immediate里组件返回的是错误,diff_scope不比较,页面还是旧的n = 7;错误边界被标脏的消息要到下一次render_immediate才处理,那一次发了 6 条指令,换成红色的Encountered panic: Any { .. }。错误边界怎么渲染在 Suspense 与错误边界。 - B:类型相同,静默串值。 两个都是
Signal<i32>,第二次渲染时b拿到的是第 0 格,也就是原来a的那个 signal,值是 1。没有任何报错,页面显示b = 1。这比 A 更难发现。 - C:条件 hook 在最后,表面上没事。 第一次没调用,列表只有 1 格;第二次调用时序号 1 超出长度,走「新 hook,追加」;第三次不调用,第 1 格还留在列表里,不会被释放;第四次又拿回同一个 signal。它能工作,只是因为后面再没有别的 hook。
所以规则是:每次渲染里 hook 的调用次数和顺序必须一样。这条规则 dioxus 在运行期只能检查一半(类型),另一半(同类型错位)完全查不出来。panic 文字建议用 dx check 在运行前查常见的 hook 错误。
push_hook_value 里那段 subsecond::get_jump_table() 分支是 #4381("Fix hot patching hook types")加的。之前类型对不上一律 panic。PR 说明:"We don't allow the type of hooks to change after they are first set because that is usually caused by breaking the rules of hooks. However, during hot patching it can be a valid change. This PR allows changing the type of hooks by run the new initialization closure after the first hot patch"。热补丁会把组件函数换成新编译的版本,开发者完全可能把 use_signal(|| 0) 改成 use_signal(|| String::new()),这时第 0 格的类型变了却不是错误。这个分支只在 debug_assertions 下、且跳转表存在时才生效,release 构建和不带热补丁的 debug 运行仍然 panic,上面的实验就是后一种。首次发布在 v0.7.0-rc.0,正式版 v0.7.0。
context:沿父链往上找
provide_context 往当前 scope 的 shared_contexts 里放一个值,同类型的已经有了就原地替换:
pub(crate) fn provide_context<T: 'static + Clone>(&self, value: T) -> T {
let mut contexts = self.shared_contexts.borrow_mut();
// If the context exists, swap it out for the new value
for ctx in contexts.iter_mut() {
// Swap the ptr directly
if let Some(ctx) = ctx.downcast_mut::<T>() {
*ctx = value.clone();
return value;
}
}
// Else, just push it
contexts.push(Box::new(value.clone()));
value
}
consume_context 先查自己,再沿 parent_id 一层层往上:
pub(crate) fn consume_context<T: 'static + Clone>(&self) -> Option<T> {
if let Some(this_ctx) = self.has_context::<T>() {
return Some(this_ctx);
}
let mut search_parent = self.parent_id;
Runtime::with(|runtime| {
while let Some(parent_id) = search_parent {
let parent = runtime.try_get_state(parent_id)?;
if let Some(shared) = parent.has_context::<T>() {
return Some(shared);
}
search_parent = parent.parent_id;
}
None
})
}
- context 表按类型索引:每个 scope 里一种类型只有一个值,查找是对
Vec逐个downcast_ref。要放两个String就得各包一个 newtype。 - 查找从自己开始,所以一个组件自己 provide、自己 consume,拿到的是自己的;子树里最近的一层遮住更上面的同类型值。
- 每次查找都是 O(深度 × 每层的 context 数)。所以 hooks 里的
use_context是use_hook(|| consume_context::<T>()):只在第一次渲染时找一次,以后直接拿 hook 里存的 clone。代价是上层后来用provide_context换了值,已经use_context过的组件看不到;要能跟着变,就提供一个Signal,变的是 signal 里的值,句柄不变。 consume_context(不带use_)不是 hook,每次调用都现查,可以在事件回调和异步任务里用,global_context.rs的注释专门说了这一点。找不到时 panic 文字是Could not find context <类型名>。- signal 的「主人」
Owner也是存在 context 表里的:Scope::owner先has_context::<Owner<S>>(),没有就新建一个provide_context进去。所以一个 scope 删掉时,context 表一清,这个 scope 建的 signal 就一起回收了(见 generational-box)。
use_context_provider / use_context 的用法和全局 signal 在 context 与全局 signal。
scope 什么时候建、什么时候删
建:diff 创建一个 DynamicNode::Component 时,若这个槽位记的还是 ScopeId::PLACEHOLDER,就 new_scope 并立刻 run_scope 一次(create_component_node,见 diff)。已有 scope 的槽位只会比较 props,不会重建。
删:组件节点从树上消失时(if 变成 false、列表少了一项、模板整块被替换),diff 调 remove_component_node:
pub(crate) fn remove_component_node<M: WriteMutations>(
&mut self,
to: Option<&mut M>,
destroy_component_state: bool,
scope_id: ScopeId,
replace_with: Option<usize>,
) {
// If this is a suspense boundary, remove the suspended nodes as well
SuspenseContext::remove_suspended_nodes::<M>(self, scope_id, destroy_component_state);
// Remove the component from the dom
if let Some(node) = self.scopes[scope_id.0].last_rendered_node.clone() {
node.remove_node_inner(self, to, destroy_component_state, replace_with)
};
if destroy_component_state {
// Now drop all the resources
self.drop_scope(scope_id);
}
}
先移除这个组件上次渲染出的节点,移除过程中遇到子组件,就递归调 remove_component_node;子树全部处理完,最后才 drop_scope 自己。所以删除是先子后父,同一层按模板里的顺序。drop_scope 把 ScopeState 从 slab 里移出来扔掉,ScopeState 的 Drop 调 Runtime::remove_scope:
pub(crate) fn remove_scope(self: &Rc<Self>, id: ScopeId) {
{
let borrow = self.scope_states.borrow();
if let Some(scope) = &borrow[id.0] {
// Manually drop tasks, hooks, and contexts inside of the runtime
self.in_scope(id, || {
// Drop all spawned tasks - order doesn't matter since tasks don't rely on eachother
// In theory nested tasks might not like this
for id in scope.spawned_tasks.take() {
self.remove_task(id);
}
// Drop all queued effects
self.pending_effects
.borrow_mut()
.remove(&ScopeOrder::new(scope.height, scope.id));
// Drop all hooks in reverse order in case a hook depends on another hook.
for hook in scope.hooks.take().drain(..).rev() {
drop(hook);
}
// Drop all contexts
scope.shared_contexts.take();
});
}
}
self.scope_states.borrow_mut()[id.0].take();
}
- 整段在
in_scope(id, ..)里:hook 的Drop(比如use_drop注册的闭包)运行时,current_scope_id()还是这个正在被删的 scope,consume_context也照常能用。 - 顺序是任务 → 排队的 effect → hook(倒序)→ context。hook 倒序,是因为后面的 hook 可能依赖前面的;context 最后,是因为 hook 的
Drop可能要读 context(Scope结构体上那条字段顺序的注释说的也是这件事)。 use_drop没有专门的机制:它就是一个use_hook,存一个Rc<LifeCycle>,LifeCycle的Drop调你的闭包,所以它在 hook 列表里排第几,就按倒序在第几个时机执行。
pending_effects 里的 effect 按 ScopeOrder(高度,ScopeId)排序。scope 删掉后,它的 ScopeId 会被 slab 很快分给新组件(下面的实验能看到)。如果删 scope 时不把它排着的 effect 一起删,这个 effect 稍后会以同一个 ScopeId 运行,落在一个已经换了主人的 scope 上。#3201 的标题就是 "Don't run effects from dead recycled scopes",说明是 "Fixes #3196 by removing effects queued for the scope as the scope itself is removed",改动就是 remove_scope 里 // Drop all queued effects 那三行(同一个 PR 还调整了 scheduler.rs)。#3196 报告的是 release 下 reactive_context.rs 里 called Result::unwrap() on an Err value: Dropped(ValueDroppedError) 的 panic。首次发布在 v0.6.0-alpha.5,正式版 v0.6.0。
实验:建、删、再建
Parent 提供一个 Theme("dark"),下面有 Child(自己又提供 Theme("light"),下面有 GrandChild)和 Sibling。每个组件存一两个 drop 时会打印的 hook(名字里 # 后面是它在 hook 列表里的序号),Parent 另有一个 use_drop:
use std::cell::Cell;
use std::rc::Rc;
use dioxus::prelude::*;
use dioxus_core::{Runtime, ScopeId, current_scope_id, try_consume_context};
thread_local! {
static SHOW: Cell<bool> = const { Cell::new(true) };
}
#[derive(Clone, Debug, PartialEq)]
struct Theme(&'static str);
/// A hook value that reports when it is dropped.
struct Noisy(&'static str);
impl Drop for Noisy {
fn drop(&mut self) {
println!(" drop hook {} (current scope {:?})", self.0, current_scope_id());
}
}
fn here(name: &str) {
let id = current_scope_id();
let rt = Runtime::current();
println!(
" run {name}: {:?} height={} parent={:?} Theme={:?}",
id.0,
rt.height(id),
rt.parent_scope(id).map(|p| p.0),
try_consume_context::<Theme>().map(|t| t.0)
);
}
fn app() -> Element {
let show = SHOW.with(|s| s.get());
rsx! {
if show {
Parent {}
}
}
}
#[component]
fn Parent() -> Element {
use_context_provider(|| Theme("dark"));
use_hook(|| Rc::new(Noisy("Parent#1")));
use_drop(|| println!(" use_drop Parent: Theme={:?}", try_consume_context::<Theme>().map(|t| t.0)));
use_hook(|| Rc::new(Noisy("Parent#3")));
here("Parent");
rsx! {
Child {}
Sibling {}
}
}
#[component]
fn Child() -> Element {
use_hook(|| Rc::new(Noisy("Child#0")));
use_context_provider(|| Theme("light"));
use_hook(|| Rc::new(Noisy("Child#2")));
here("Child");
rsx! { GrandChild {} }
}
#[component]
fn GrandChild() -> Element {
use_hook(|| Rc::new(Noisy("GrandChild#0")));
here("GrandChild");
rsx! { "leaf" }
}
#[component]
fn Sibling() -> Element {
use_hook(|| Rc::new(Noisy("Sibling#0")));
here("Sibling");
rsx! { "sibling" }
}
fn main() {
let mut dom = VirtualDom::new(app);
println!("== rebuild");
dom.rebuild_in_place();
println!("== hide Parent");
SHOW.with(|s| s.set(false));
dom.mark_dirty(ScopeId::APP);
dom.render_immediate_to_vec();
println!("== show Parent again");
SHOW.with(|s| s.set(true));
dom.mark_dirty(ScopeId::APP);
dom.render_immediate_to_vec();
println!("== drop the VirtualDom");
drop(dom);
}
hook 里存的是 Rc<Noisy>:use_hook 每次渲染返回一份 clone,clone 的是 Rc,drop 那份 clone 不会打印;只有 hook 列表里那一份被释放时 Noisy 才 drop。输出(debug;show Parent again 那一段留给动脑筋):
== rebuild
run Parent: 4 height=4 parent=Some(3) Theme=Some("dark")
run Child: 5 height=5 parent=Some(4) Theme=Some("light")
run GrandChild: 6 height=6 parent=Some(5) Theme=Some("light")
run Sibling: 7 height=5 parent=Some(4) Theme=Some("dark")
== hide Parent
drop hook GrandChild#0 (current scope ScopeId(6, "scope_life::GrandChild"))
drop hook Child#2 (current scope ScopeId(5, "scope_life::Child"))
drop hook Child#0 (current scope ScopeId(5, "scope_life::Child"))
drop hook Sibling#0 (current scope ScopeId(7, "scope_life::Sibling"))
drop hook Parent#3 (current scope ScopeId(4, "scope_life::Parent"))
use_drop Parent: Theme=Some("dark")
drop hook Parent#1 (current scope ScopeId(4, "scope_life::Parent"))
...
== drop the VirtualDom
drop hook GrandChild#0 (current scope ScopeId(5, "scope_life::GrandChild"))
drop hook Child#2 (current scope ScopeId(7, "scope_life::Child"))
drop hook Child#0 (current scope ScopeId(7, "scope_life::Child"))
drop hook Sibling#0 (current scope ScopeId(6, "scope_life::Sibling"))
drop hook Parent#3 (current scope ScopeId(4, "scope_life::Parent"))
use_drop Parent: Theme=Some("dark")
drop hook Parent#1 (current scope ScopeId(4, "scope_life::Parent"))
- 建:深度优先。
Parent4 号,Child5 号,GrandChild6 号,回到Sibling7 号;高度就是父的高度加一,Sibling和Child同为 5。 - context:
Child自己 provide 了"light",它自己和GrandChild都看到"light";Sibling不在Child下面,看到的是Parent的"dark"。 - 删:先子后父,同层按模板顺序。
GrandChild→Child→Sibling→Parent。每个 scope 内 hook 倒序:Child#2先于Child#0(夹在中间的 1 号是use_context_provider,它存的Theme没有输出);Parent的四个 hook 按 3、2(use_drop)、1、0(Theme)的顺序释放。hook drop 时current_scope_id()就是被删的那个 scope,use_drop的闭包里还能consume_context拿到"dark":Parent自己的 context 表要等全部 hook 释放完才清。Parent#3和Parent#1中间夹着的use_drop说明它确实只是 hook 列表里的普通一格。 - 整个
VirtualDomdrop: 顺序一样是深的先放。这条路径不经过 diff,而是VirtualDom的Drop按高度倒序释放(见 VirtualDom 与 Runtime),同高度的Child(7 号)先于Sibling(6 号),是 slab 按下标排列、排序稳定、再整体倒序的结果。
show Parent again 之后,Parent、Child、GrandChild、Sibling 分别拿到几号 ScopeId?隐藏时 4 到 7 号按 GrandChild(6)、Child(5)、Sibling(7)、Parent(4) 的顺序被释放;再显示时四个组件仍按深度优先的顺序创建:Parent、Child、GrandChild、Sibling。VirtualDom.scopes 是 slab::Slab,vacant_entry 给出的下标来自它的空闲链表。猜四个数字,再看文末输出里的 run ... 四行。
动脑筋答案
== show Parent again
run Parent: 4 height=4 parent=Some(3) Theme=Some("dark")
run Child: 7 height=5 parent=Some(4) Theme=Some("light")
run GrandChild: 5 height=6 parent=Some(7) Theme=Some("light")
run Sibling: 6 height=5 parent=Some(4) Theme=Some("dark")
Parent 4、Child 7、GrandChild 5、Sibling 6。slab 的空闲链表是后进先出:最后释放的 4 最先被用,然后是 7、5、6,正好是释放顺序倒过来。编号和第一次完全不同,GrandChild 的父 id 变成了 7。ScopeId 只在 scope 活着时有意义,别把它存到组件外面当长期标识;上一个冷知识里「排队的 effect 落到换了主人的 scope 上」就是这种复用造成的。
小结
ScopeId是VirtualDom.scopes这个 slab 的下标,释放后会被马上复用。一个组件实例的状态分两份:ScopeState(props、上次的VNode、ReactiveContext)在VirtualDom里,Scope(父 id、高度、hooks、context、任务)在Runtime里,都用ScopeId对齐。run_scope:压 scope 栈、hook_index归零、在组件自己的ReactiveContext里清空旧订阅后调用组件函数(catch_unwind包着,panic 变成错误)、深拷贝返回的VNode、错误交给错误边界、挂起计数加一,最后把自己移出脏队列。它不写指令。use_hook只按序号和类型取值:序号超出就追加;类型不对就 panic(只有带热补丁跳转表的 debug 运行例外);类型对上就直接返回,同类型的 hook 错位不会被发现。实验三种写法在 debug 和 release 下结果相同。- context 按类型存在每个 scope 的表里,
consume_context从自己开始沿parent_id往上找;use_context只在第一次渲染找一次。signal 的Owner也是一个 context。 - 删除时先子后父;每个 scope 依次取消任务、删掉排队的 effect、倒序 drop hook、最后清 context,全程在这个 scope 里执行。
use_drop就是一个Drop时调闭包的 hook。