Rust in Everything
基于 dioxus 0.7.10

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:调组件函数

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

按顺序:

  1. 压栈。 with_scope_on_stack 把 scope_id 压进 Runtime.scope_stack(同时压入它的 suspense 位置),组件里的 current_scope_id()、use_hook、consume_context 都读这个栈顶。
  2. hook_index 归零。 这一次渲染从第 0 个 hook 开始数。
  3. before_render 回调(use_before_render 注册的)。
  4. 在组件自己的 ReactiveContext 里跑。 reset_and_run_in 先清掉上一次的订阅,再把这个 context 压进 reactive_context.rs 里的线程局部栈 CURRENT;组件函数里每次 signal.read() 都订阅栈顶的 context。所以组件只订阅它这一次渲染读到的 signal,上次读、这次没读的不再触发重跑。
  5. props.render()。 VProps::render 先 clone 一份 props,再在 std::panic::catch_unwind 里调组件函数;组件 panic 了不会冲垮整个 VirtualDom,而是变成一个 CapturedPanic 错误(只在原生平台上有效:wasm 上 panic 直接中止,源码注释写的是 "we can't even capture the panic",那里什么也不做)。下面「hook 放进条件里」的实验就会看到这条路径。
  6. deep_clone。 返回的 VNode 整棵复制一份(Fragment 里的子 VNode 也复制),再交给 diff。原因见下面的冷知识。
  7. handle_element_return。 Err(RenderError::Error) 打一条 tracing::error! 并 throw_error 给最近的错误边界;Err(RenderError::Suspended) 把任务登记到最近的 suspense 边界、suspended_tasks 加一;Ok 则 render_count 加一。
  8. after_render 回调,然后把自己从 dirty_scopes 里删掉:父组件 diff 时顺手重跑了的子组件,不会再被调度器取出来跑第二遍。

run_scope 只产出新的 Element,不碰旧的 last_rendered_node,也不写任何指令;和旧节点比较、写指令是调用方 diff_scope 的事,见 diff。

📝NOTE
冷知识:为什么每次渲染都要把 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 错误。

📝NOTE
冷知识:热补丁时 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 列表里排第几,就按倒序在第几个时机执行。
📝NOTE
冷知识:删 scope 时为什么要顺手删掉排队的 effect

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"))
  • 建:深度优先。 Parent 4 号,Child 5 号,GrandChild 6 号,回到 Sibling 7 号;高度就是父的高度加一,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 列表里的普通一格。
  • 整个 VirtualDom drop: 顺序一样是深的先放。这条路径不经过 diff,而是 VirtualDom 的 Drop 按高度倒序释放(见 VirtualDom 与 Runtime),同高度的 Child(7 号)先于 Sibling(6 号),是 slab 按下标排列、排序稳定、再整体倒序的结果。
❗IMPORTANT
动脑筋: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。

💬 关联讨论

发起讨论
加载中...