Rust in Everything
基于 dioxus 0.7.10

Signal

主线位置:更新主线上「用户闭包写 signal → ReactiveContext::mark_dirty → SchedulerMsg」这一段,以及它的前提:上一次 run_scope 里组件读 signal 时留下的订阅。写锁 drop 的顺序和通知本身在一次点击第 4、5 节已经走过一遍,本页讲订阅关系怎么建立、怎么清掉,读写冲突时报什么,以及那些语法糖。首屏主线上,组件第一次渲染读到的 signal 同样在这里建立订阅(SSR 里通常不会再有写入)。

sequenceDiagram
  participant RS as run_scope
  participant RC as 组件的 ReactiveContext
  participant S as Signal count
  participant H as 事件回调
  participant CH as 调度通道
  RS->>RC: reset_and_run_in 清空旧订阅
  RC->>S: 组件读 count 订阅
  Note over RC,S: 双向记录 S 的集合里有 RC
  H->>S: count += 1 拿 WriteLock
  H->>S: WriteLock drop
  S->>RC: update_subscribers mark_dirty
  RC->>CH: SchedulerMsg Immediate

源码在 packages/signals/src(约 4300 行,本页读 signal.rs、read.rs、write.rs、impls.rs)和 packages/core/src/reactive_context.rs。dioxus-signals 定义「可读可写、读时订阅」的值;ReactiveContext 在 dioxus-core 里,因为组件的重跑是 core 调度的。

Signal 就是带订阅者集合的 CopyValue

Signal 和 SignalData:

pub struct Signal<T, S: 'static = UnsyncStorage> {
    pub(crate) inner: CopyValue<SignalData<T>, S>,
}

/// The data stored for tracking in a signal.
pub struct SignalData<T> {
    pub(crate) subscribers: Arc<Mutex<HashSet<ReactiveContext>>>,
    pub(crate) value: T,
}

值和它的订阅者集合放在同一个 generational-box 格子里(CopyValue 和格子见 generational-box)。所以 Signal 和 CopyValue 一样大(release 24 字节),一样 Copy,一样跟着创建它的 scope 被回收。订阅者集合是 Arc<Mutex<..>>,即使 S 是 UnsyncStorage:集合要交给 ReactiveContext 另存一份引用(下文),ReactiveContext 本身在 SyncStorage 里,要求 Send + Sync。

use_signal 就是 use_hook(|| Signal::new_with_caller(f(), caller)):第一次渲染建一个,之后每次渲染返回 hook 里存的同一个句柄。直接在组件里调 Signal::new 不会出错,但每次渲染都在当前 scope 的 Owner 下多占两格,直到组件卸载才释放;Signal::new 的文档注释专门用 ❌ / ✅ 示例警告了这一点。

读:订阅栈顶的 ReactiveContext

读的入口是 Readable trait 的两个方法,Signal 的实现(try_read_unchecked、try_peek_unchecked)只差一个 if:

    fn try_read_unchecked(&self) -> BorrowResult<ReadableRef<'static, Self>>
    where
        T: 'static,
    {
        let inner = self.inner.try_read_unchecked()?;

        if let Some(reactive_context) = ReactiveContext::current() {
            tracing::trace!("Subscribing to the reactive context {}", reactive_context);
            reactive_context.subscribe(inner.subscribers.clone());
        }

        Ok(S::map(inner, |v| &v.value))
    }

    fn try_peek_unchecked(&self) -> BorrowResult<ReadableRef<'static, Self>>
    where
        T: 'static,
    {
        self.inner
            .try_read_unchecked()
            .map(|inner| S::map(inner, |v| &v.value))
    }

用户平时调的 read()、peek()、with、cloned 都在扩展 trait ReadableExt 里,最后都落到这两个方法;read 是 self.try_read().unwrap()。返回的 ReadableRef 就是 generational-box 的读借用守卫,守卫活着,格子的 RefCell 就处于读借用状态。

「订阅谁」由 ReactiveContext::current 决定:线程局部栈 CURRENT 的栈顶。栈由 run_in 压入、弹出,组件、memo、effect 各自在自己的 context 里运行用户代码。事件回调不在任何 context 里运行,所以在 onclick 里 read() 不订阅任何东西,和 peek() 效果一样;spawn 和 use_future 的 async 块也不在任何 context 里,.await 前后都一样;use_resource 例外,它把每次 poll 都包在自己的 context 里(见异步)。

ReactiveContext

ReactiveContext 本身也是一个 Copy 句柄:

pub struct ReactiveContext {
    scope: ScopeId,
    inner: GenerationalBox<Inner, SyncStorage>,
}

Inner 里有三样东西:update: Box<dyn FnMut() + Send + Sync>(被标脏时调用的回调)、subscribers: HashSet<PointerHash<dyn SubscriberList + Send + Sync>>(它订阅了哪些 signal 的订阅者集合)、self_(自己的句柄,Drop 时用来把自己从那些集合里删掉)。debug 下再加创建位置和所属 scope,trace 里的 ReactiveContext(for scope: ScopeId(3, "root"))、ReactiveContext created at src/bin/click.rs:10:3 就是它的 Display。

订阅是双向记录的。subscribe 一边 subscriptions.add(*self)(把自己放进 signal 的集合),一边把这个集合的 Arc 记进自己的 Inner.subscribers。集合按 Arc 指针哈希(PointerHash),context 按格子 id 哈希,同一对只记一次。反向这一份是为了能退订:clear_subscribers 把自己记下的每个集合取出来,从里面 remove(self)。reset_and_run_in 就是「先 clear_subscribers,再 run_in」,run_scope 每次调组件函数都用它(见 Scope 与 hooks)。所以组件的订阅只反映最近一次渲染读到了什么。

signal 的集合在 Signal::subscribers() 里能拿到(Readable 的第三个方法),Subscribers::visit 可以遍历。sig1-exp/src/bin/sig_subscribe.rs:组件按 flag 决定读不读 count,每一步之后打印两个 signal 的订阅者:

fn app() -> Element {
  let count = use_signal(|| 0);
  let flag = use_signal(|| true);
  HANDLES.set(Some((count, flag)));
  let shown = if flag() { count() } else { -1 };
  println!("  [app 运行] flag={} count={} shown={shown}", flag.peek(), count.peek());
  rsx! { "{shown}" }
}

fn subs<T: 'static>(label: &str, s: &Signal<T>) {
  let mut v = vec![];
  s.subscribers().visit(|rc| v.push(rc.to_string()));
  println!("  {label} 的订阅者: {v:?}");
}

main 用 dom.in_runtime(..) 从外面写 signal,再给 wait_for_work 50 毫秒:返回就 render_immediate,超时说明没有脏 scope。debug 输出:

  [app 运行] flag=true count=0 shown=0
--- count += 1
  [app 运行] flag=true count=1 shown=1
  count 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
  flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
--- flag = false
  [app 运行] flag=false count=1 shown=-1
  count 的订阅者: []
  flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
--- count += 1(flag 为 false)
  (没有脏 scope,组件没重跑)
  count 的订阅者: []
  flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
--- flag = true
  [app 运行] flag=true count=2 shown=2
  count 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]
  flag 的订阅者: ["ReactiveContext(for scope: ScopeId(3, \"root\"))"]

flag 变成 false 的那一轮,组件没读 count,count 的集合随即变空,之后写 count 不再触发重跑。flag 回到 true,组件又读到 count,订阅恢复。打印里的 flag.peek()、count.peek() 不订阅,不影响结果。

ReactiveContext 有四种来源,区别只在 update 回调做什么:

谁的怎么建update 做什么详见
组件new_for_scope,建 scope 时往调度通道发 SchedulerMsg::Immediate(id)调度
Memonew_with_callback置 dirty 标志,往自己的通道发 ()Memo 与 effect
use_effectnew_with_origin通道里还没有消息就发一个 (),唤醒 hook 里 spawn 的任务Memo 与 effect
use_resourcenew_with_origin同上,任务重新跑 future异步

Inner 存在 context 所属 scope 的 Owner<SyncStorage> 里,scope 删掉它就被回收。signal 的集合里可能还留着它的句柄,但 mark_dirty 对一个已回收的 context try_write 失败、返回 false,update_subscribers 的 retain 顺手把它删掉:死掉的订阅者在下一次写入时才被清理。

写:WriteLock 释放时通知

写的入口是 Writable 的 try_write_unchecked,扩展 trait WritableExt 提供 write、with_mut、set。Signal 的实现(try_write_unchecked)返回一个 WriteLock,里面带着 SignalSubscriberDrop;它被 drop 时调 update_subscribers,把集合整个 take 出来,对每个 context mark_dirty,再 extend 回去。这一段代码、字段的 drop 顺序(先还借用、再通知)和「先 take 再放回」的原因,一次点击第 4 节已经逐行讲过,这里不重复。

要记住的只有三点:

  • 写本身不通知,通知在 WriteLock drop 时。拿着 write() 的守卫做很多事,订阅者要等守卫离开作用域才被标脏。
  • 通知只是 mark_dirty,组件那个 context 只往通道里塞一条消息,不会在写的地方同步重跑任何组件。
  • 写不检查新值和旧值是否相等。count.set(0) 写一个和原来相同的值,订阅者照样被标脏、组件照样重跑。要「值不变就不通知」,用 memo(下一页)。

Signal 还有一个已标记 #[deprecated] 的 write_silent:直接 self.inner.write_unchecked(),不带 SignalSubscriberDrop,写完不通知任何人。它的文档注释用很长的篇幅解释为什么改用 peek:write_silent 是在写的一侧全局关掉通知,别的文件里读这个 signal 的组件会静默地不更新;peek 是在读的一侧、只对这一次读关掉订阅。

借用冲突

signal 的值在 RefCell 里(UnsyncStorage),读守卫和写守卫不能同时存在。先看编译期:write 的签名是 fn write(&mut self) -> WritableRef<'_, Self>,read 是 fn read(&self) -> ReadableRef<'_, Self>,返回的守卫都借着句柄本身。所以对同一个变量先 write() 再 read(),rustc 直接拒绝。sig_borrow 最初的写法:

fn read_while_write() -> Element {
  let mut count = use_signal(|| 0);
  let _w = count.write();
  let _r = count.read();
  rsx! {}
}

fn write_while_read() -> Element {
  let mut count = use_signal(|| 0);
  let _r = count.read();
  let _w = count.write();
  rsx! {}
}

fn read_in_with_mut() -> Element {
  let mut count = use_signal(|| 0);
  count.with_mut(|v| *v = count() + 1);
  rsx! {}
}

rustc 1.99.0 的报错(节选):

error[E0502]: cannot borrow `count` as immutable because it is also borrowed as mutable
  --> src/bin/sig_borrow.rs:9:12
   |
 8 |   let _w = count.write();
   |            ----- mutable borrow occurs here
 9 |   let _r = count.read();
   |            ^^^^^ immutable borrow occurs here
10 |   rsx! {}
11 | }
   | - mutable borrow might be used here, when `_w` is dropped and runs the destructor for type `WriteLock<'_, i32, UnsyncStorage, dioxus::prelude::SignalSubscriberDrop<i32, UnsyncStorage>>`

error[E0502]: cannot borrow `count` as mutable because it is also borrowed as immutable
  --> src/bin/sig_borrow.rs:16:12
   |
15 |   let _r = count.read();
   |            ----- immutable borrow occurs here
16 |   let _w = count.write();
   |            ^^^^^^^^^^^^^ mutable borrow occurs here
17 |   rsx! {}
18 | }
   | - immutable borrow might be used here, when `_r` is dropped and runs the destructor for type `dioxus::dioxus_core::internal::generational_box::GenerationalRef<std::cell::Ref<'_, i32>>`

error[E0502]: cannot borrow `count` as mutable because it is also borrowed as immutable
  --> src/bin/sig_borrow.rs:22:3
   |
22 |   count.with_mut(|v| *v = count() + 1);
   |   ^^^^^^--------^---^^^^^^-----^^^^^^^
   |   |     |        |        |
   |   |     |        |        first borrow occurs due to use of `count` in closure
   |   |     |        immutable borrow occurs here
   |   |     immutable borrow later used by call
   |   mutable borrow occurs here
...

这层检查是附带的:句柄是 Copy,复制一份(或者两个闭包各自捕获一份)就绕过去了,冲突留到运行期。把每个函数里的一个 count 换成它的复制 other:

fn read_while_write() -> Element {
  let mut count = use_signal(|| 0);
  let other = count; // Copy 出第二个句柄
  let _w = count.write();
  let _r = other.read();
  rsx! {}
}

fn write_while_read() -> Element {
  let count = use_signal(|| 0);
  let mut other = count;
  let _r = count.read();
  let _w = other.write();
  rsx! {}
}

fn read_in_with_mut() -> Element {
  let mut count = use_signal(|| 0);
  let other = count;
  count.with_mut(|v| *v = other() + 1);
  rsx! {}
}

fn across_await() -> Element {
  let mut count = use_signal(|| 0);
  use_hook(|| {
    spawn(async move {
      let r = count.read();
      println!("task: holding read, value = {}", *r);
      tokio::time::sleep(Duration::from_millis(100)).await;
      println!("task: done, value = {}", *r);
    })
  });
  rsx! { button { onclick: move |_| count += 1, "+" } }
}

前三个在组件函数里冲突;across_await 的任务拿着读守卫跨过 .await,主程序先让任务跑到 sleep,再派发一次点击。每个函数单独跑一个进程,debug 输出:

=== rw
thread 'main' (1187842) panicked at src/bin/sig_borrow.rs:10:18:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError { borrowed_mut_at: Location { file: "src/bin/sig_borrow.rs", line: 9, column: 18 } })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
rendered: 
=== wr
thread 'main' (1187869) panicked at src/bin/sig_borrow.rs:18:18:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError { borrowed_at: [Location { file: "src/bin/sig_borrow.rs", line: 17, column: 18 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
rendered: 
=== with_mut
thread 'main' (1187921) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-signals-0.7.10/src/read.rs:259:38:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError { borrowed_mut_at: Location { file: "src/bin/sig_borrow.rs", line: 25, column: 9 } })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
rendered: 
=== await
task: holding read, value = 0
click while task holds the read guard

thread 'main' (1188014) panicked at src/bin/sig_borrow.rs:39:37:
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError { borrowed_at: [Location { file: "src/bin/sig_borrow.rs", line: 33, column: 21 }] })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

release 下错误里没有位置:

=== rw --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError)
=== wr --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError)
=== with_mut --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowedMut(AlreadyBorrowedMutError)
=== await --release
called `Result::unwrap()` on an `Err` value: AlreadyBorrowed(AlreadyBorrowedError)

(每段只留错误那一行,panic 位置和 debug 相同。)读这些输出:

  • 「读时已有写」是 AlreadyBorrowedMut,带的是另一方(写守卫)的位置;「写时已有读」是 AlreadyBorrowed,带所有还活着的读守卫的位置。panic 位置本身是出错的这一次调用。
  • 组件函数里的 panic 被 run_scope 的 catch_unwind 接住,变成错误交给错误边界,rendered: 后面是空的;事件回调里的 panic(await 那个)没人接,原生进程直接退出,wasm 里同样中止。
  • count += 1 报在 sig_borrow.rs:39:37,就是 += 那里:AddAssign、with_mut、write 一路都有 #[track_caller]。other() 报在 read.rs:259,原因见下一节。
  • 跨 .await 拿着读守卫,编译器不会拦:任务不要求 Send,守卫可以活过 .await;冲突发生在别处来写的那一刻,panic 在写的一方。读出需要的值、让守卫在 .await 之前结束(let v = count(); 或 *count.read() 拷出来),就不会有这个问题。

SyncSignal(use_signal_sync)的同样冲突不 panic,而是死锁,见 generational-box 的实验。

📝NOTE
没有蠢问题

问: 既然 write(&mut self) 能在编译期抓到一部分冲突,为什么 Signal 还要做成 Copy?不 Copy,冲突不就全在编译期了吗?

答: 不 Copy 就回到了「闭包要么借用、要么拿走」的老问题:onclick: move |_| count += 1 会把 count 移进闭包,同一个组件里别的闭包和 rsx! 就用不了它。Copy 句柄的全部意义就是让每个闭包各拿一份,代价是借用检查挪到运行期。&mut self 只是让最常见的写法(同一个变量上先 write 后 read)提前报错,并不承诺全部抓到。

语法糖:signal()、+=、"{signal}"

signal()。Signal 实现了 Deref<Target = dyn Fn() -> T>(要求 T: Clone),count() 是先 deref 成一个闭包再调用。闭包从哪来?ReadableExt::deref_impl:

        // First we create a closure that captures something with the Same in memory layout as Self (MaybeUninit<Self>).
        let uninit_callable = MaybeUninit::<Self>::uninit();
        // Then move that value into the closure. We assume that the closure now has a in memory layout of Self.
        let uninit_closure = move || Self::read(unsafe { &*uninit_callable.as_ptr() }).clone();

        // Check that the size of the closure is the same as the size of Self in case the compiler changed the layout of the closure.
        let size_of_closure = std::mem::size_of_val(&uninit_closure);
        assert_eq!(size_of_closure, std::mem::size_of::<Self>());
        // ...
            unsafe {
                std::mem::transmute(self)
            },

这是注释里引用的 dtolnay「callable types」技巧:造一个只捕获「和 Self 同样布局的东西」的闭包,它的内存布局就是 Self,于是可以把 &Self 直接 transmute 成 &闭包,再当成 &dyn Fn() -> T 返回。调用时闭包把自己的捕获(其实就是这个 signal)当作 &Self 去 read(),再 clone()。所以 count() 等于 count.read().clone():会订阅,会在冲突时 panic。panic 位置落在 read.rs:259,是因为闭包的 call 不可能带 #[track_caller],位置只能停在这个闭包里。Memo、ReadSignal 等用同一个 deref_impl。

+= 等运算符。write_impls! 宏给 Signal、Memo 等生成 Add、AddAssign、Sub、SubAssign、Mul、MulAssign、Div、DivAssign,要求 T: Copy。AddAssign 是 self.with_mut(|v| *v = *v + rhs):直接在写守卫里读旧值,不经过 read(),所以 count += 1 不订阅;count + 1(Add)走 with,会订阅。

"{count}"。rsx! 的格式化字符串最终调 Display。fmt_impls! 生成的 Display / Debug 是 self.with(|v| Display::fmt(v, f)),with 走 read():在组件里写 "{count}" 就订阅了 count。上面 sig_subscribe 的组件只读了 flag() 和 count(),rsx! 里的 "{shown}" 是普通 i32,订阅只来自前两者。把 signal 直接放进属性或子节点(div { {count} })则走 IntoAttributeValue / IntoDynNode,同样是 read 之后 clone。

渲染时写 signal

组件函数也在自己的 ReactiveContext 里运行,渲染时写 signal 和在事件里写没有区别:写锁释放时,订阅者被标脏。问题在于订阅者可能就是组件自己。sig1-exp/src/bin/sig_write_render.rs:

fn read_then_write() -> Element {
  let mut count = use_signal(|| 0);
  let v = count(); // 先读:订阅
  println!("  [read_then_write 运行] count = {v}");
  count += 1; // 再写:通知刚订阅的自己
  rsx! { "{v}" }
}

fn write_then_read() -> Element {
  let mut count = use_signal(|| 0);
  count += 1; // 先写:这一轮还没有订阅者
  let v = count(); // 再读:订阅
  println!("  [write_then_read 运行] count = {v}");
  rsx! { "{v}" }
}

main 对每个组件 rebuild_in_place(),然后最多四轮「wait_for_work(50 毫秒超时)→ render_immediate」,装了打印 WARN 的 tracing_subscriber。read_then_write 的 debug 输出:

=== read_then_write
  [read_then_write 运行] count = 0
  第 1 轮:wait_for_work 返回(有脏 scope)
  [read_then_write 运行] count = 1
  第 2 轮:wait_for_work 返回(有脏 scope)
  [read_then_write 运行] count = 2
  第 3 轮:wait_for_work 返回(有脏 scope)
  [read_then_write 运行] count = 3
  第 4 轮:wait_for_work 返回(有脏 scope)
  [read_then_write 运行] count = 4

每次渲染都把自己标脏,wait_for_work 永远立即返回,只是被实验的四轮上限截断了。在浏览器里就是主循环一直在重渲染。没有任何警告:debug 和 release 输出完全相同。run_scope 结束时会把 scope 从 dirty_scopes 里删掉,但那条 SchedulerMsg::Immediate 还在通道里,下一次 wait_for_work 的 process_events 又把它放回去。

write_then_read 会怎样,留作本节的动脑筋。

❗IMPORTANT
动脑筋:先写后读,组件跑几次?

上面的 write_then_read,同样 rebuild_in_place() 之后最多跑四轮 wait_for_work。[write_then_read 运行] 一共打印几行,打印的 count 是几?(M2 Max、macOS 27、rustc 1.99.0,debug 和 release。)

0.6 的 debug 构建会对渲染时写 signal 发警告;0.7.10 里没有,这是有意删掉的。

📝NOTE
冷知识:0.7 删掉了「渲染时写 signal」的警告

0.6 的 SignalSubscriberDrop::drop 在 debug 下除了打 trace,还调两个警告(0.5.7 里同样的两项检查直接写在 drop 里):signal_write_in_component_body(文字是「Write on signal at {} happened while a component was running. Writing to signals during a render can cause infinite rerenders when you read the same signal in the component. Consider writing to the signal in an effect, future, or event handler if possible.」)和 signal_read_and_write_in_reactive_scope(同一个 reactive context 既读又写)。PR #4024「Remove signal write warnings」把它们连同 memo.rs、use_reactive.rs 里为它们开的豁免一起删了,说明是:「These warnings have been too noisy in the past and we don't have a good way of improving reliability (detecting infinite loops) in the general case」。use_reactive 和 memo 的惰性重算本身就要在渲染时写 signal,只能一处处加 allow(之前的 #3647 就是为 memo 加豁免)。现在 warnings.rs 里只剩 copy_value_hoisted 一个警告。仓库里第一个包含 #4024 的 tag 是 v0.7.0-alpha.0,正式版 0.7.0 的发布说明里列着它。

动脑筋答案

sig_write_render 里 write_then_read 的原样输出(debug 和 release 相同):

=== write_then_read
  [write_then_read 运行] count = 1
  第 1 轮:50ms 内没返回,停了
  • 只跑一次,打印的是 1:count += 1 在读之前,值先从 0 变成 1。
  • 写的时候组件还没订阅 count:run_scope 用 reset_and_run_in 调组件函数,开头 clear_subscribers 已经把组件从 count 的集合里删掉(第一次渲染时集合本来就是空的)。update_subscribers 遍历一个空集合,没有人被标脏。
  • 之后的 count() 才订阅。下一次有别的原因让组件重跑时,同样是先清空、再写(没人收到)、再读,所以永远不会自己触发自己。但每次重跑 count 都会加一,这依然不是该在渲染里做的事。

小结

  • Signal<T> 是 CopyValue<SignalData<T>>:值和订阅者集合 Arc<Mutex<HashSet<ReactiveContext>>> 在同一个 generational-box 格子里,Copy,跟着创建它的 scope 回收。
  • read 订阅线程局部栈 CURRENT 的栈顶 context,peek 不订阅;事件回调不在任何 context 里,读了也不订阅。
  • ReactiveContext 是 SyncStorage 里的句柄,update 回调决定被标脏时做什么(组件发 Immediate、memo 置脏、effect / resource 唤醒任务);订阅双向记录,reset_and_run_in 每次先退订,组件只订阅最近一次渲染读到的 signal。
  • 通知发生在 WriteLock drop 时,写入不比较新旧值;死掉的 context 在下一次写入时从集合里清掉。
  • 同一个变量先 write 再 read 编译期报 E0502;复制出的句柄冲突时运行期 panic AlreadyBorrowedMut / AlreadyBorrowed,debug 带对方的位置。读守卫跨 .await 编译器不拦。
  • signal() 是 Deref<Target = dyn Fn() -> T> 的 transmute 技巧,等于 read().clone();+= 不订阅,"{signal}" 订阅。
  • 渲染时先读后写会无限重跑,0.7 起没有任何警告;先写后读不会自我触发。

💬 关联讨论

发起讨论
加载中...