Memo 与 effect
主线位置:更新主线的两处。memo 夹在「写 signal」和「组件被标脏」之间:signal 通知 memo,memo 重算,值变了才再通知组件。effect 在主线最末尾:
render_immediate、flush_edits之后,下一次wait_for_work里才跑。effect 在调度器里的位置(最低优先级、finish_render发EffectQueued)在调度讲过,本页讲它和 memo 自己的那一半:各自的ReactiveContext什么时候被标脏、标脏之后做什么。
flowchart TB
write1["写 signal count"] --> rcm["memo 的 ReactiveContext"]
rcm --> dirtyflag["dirty 置真 通道发消息"]
dirtyflag --> task["memo 任务被 poll"]
dirtyflag --> lazy["有人先读 memo"]
task --> recompute["recompute 重跑闭包"]
lazy --> recompute
recompute --> eq{"新值等于旧值?"}
eq -->|是| stop["不通知"]
eq -->|否| setv["写内部 signal 通知订阅者"]
write1 --> rce["effect 的 ReactiveContext"]
rce --> qe["queue_effect"]
qe --> runeff["DOM 改完后运行 effect"]
源码:packages/signals/src/memo.rs(Memo 本体)、packages/hooks/src/use_memo.rs、use_effect.rs、use_reactive.rs、use_callback.rs,以及 packages/core/src/effect.rs(调度器里的 effect 队列)。
Memo 的结构
Memo 是一个 signal 加一份「怎么重算」:
struct UpdateInformation<T> {
dirty: Arc<AtomicBool>,
callback: RefCell<Box<dyn FnMut() -> T>>,
}
pub struct Memo<T> {
inner: Signal<T>,
update: CopyValue<UpdateInformation<T>>,
}
两个字段都是 Copy 句柄,所以 Memo 也是 Copy(release 48 字节,正好两个 24 字节的句柄)。Memo::new_with_location 把它们建起来:
let dirty = Arc::new(AtomicBool::new(false));
let (tx, mut rx) = futures_channel::mpsc::unbounded();
let callback = {
let dirty = dirty.clone();
move || {
dirty.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = tx.unbounded_send(());
}
};
let rc = ReactiveContext::new_with_callback(callback, current_scope_id(), location);
// Create a new signal in that context, wiring up its dependencies and subscribers
let mut recompute = move || rc.reset_and_run_in(&mut f);
let value = recompute();
// ...
let state: Signal<T> = Signal::new_with_caller(value, location);
// ...
spawn_isomorphic(async move {
while rx.next().await.is_some() {
// Remove any pending updates
while rx.try_recv().is_ok() {}
memo.recompute();
}
});
- memo 有自己的
ReactiveContext,update回调做两件事:把dirty置真,往自己的通道发一个()。闭包每次都在这个 context 里用reset_and_run_in跑,所以 memo 的依赖和组件一样,只算最近一次运行读到的 signal(Signal 讲过退订)。 - 第一次计算在
new里同步完成,结果放进内部的Signal<T>。 - 一个任务等通道消息,收到就先把积压的消息清空,再
recompute。任务用spawn_isomorphic而不是spawn建:同步型的工作在 suspense 期间也照常运行(spawn和任务的生命周期见异步)。
recompute 是值相等时截断的地方:
fn recompute(&self)
where
T: PartialEq + 'static,
{
let mut update_copy = self.update;
let update_write = update_copy.write();
let peak = self.inner.peek();
let new_value = (update_write.callback.borrow_mut())();
if new_value != *peak {
drop(peak);
let mut copy = self.inner;
copy.set(new_value);
}
// Always mark the memo as no longer dirty even if the value didn't change
update_write
.dirty
.store(false, std::sync::atomic::Ordering::Relaxed);
}
旧值用 peek 取,不订阅;新值不等于旧值才 set,set 的写锁释放时通知读过这个 memo 的组件。这就是 use_memo 要求 R: PartialEq 的原因,也是它和「直接在组件里算」的区别:依赖变了,memo 一定重算,但下游只在结果变了时才重跑。
什么时候重算:两条路
依赖变了以后,memo 什么时候重算?任务那条路是「尽快」:通道里有消息,任务被唤醒,下一次调度器 poll 任务时就重算,不管有没有人读。另一条路在 Memo 的 try_read_unchecked 里:
// Read the inner generational box instead of the signal so we have more fine grained control over exactly when the subscription happens
let read = self.inner.inner.try_read_unchecked()?;
let needs_update = self
.update
.read()
.dirty
.swap(false, std::sync::atomic::Ordering::Relaxed);
let result = if needs_update {
drop(read);
// We shouldn't be subscribed to the value here so we don't trigger the scope we are currently in to rerun even though that scope got the latest value because we synchronously update the value: https://github.com/DioxusLabs/dioxus/issues/2416
self.recompute();
self.inner.inner.try_read_unchecked()
} else {
Ok(read)
};
// Subscribe to the current scope before returning the value
if let Ok(read) = &result {
if let Some(reactive_context) = ReactiveContext::current() {
tracing::trace!("Subscribing to the reactive context {}", reactive_context);
reactive_context.subscribe(read.subscribers.clone());
}
}
读的时候 dirty 还是真(任务还没来得及跑),就当场同步重算,保证读到的永远是新值。所以答案是「两者都有」:默认由任务尽快重算,读得比任务早就在读的地方重算。
sig1-exp/src/bin/memo_lazy.rs 把两条路都走一遍。组件只读 half,不直接读 count:
fn app() -> Element {
let count = use_signal(|| 0);
let half = use_memo(move || {
let h = count() / 2;
println!(" [memo 闭包] count={} -> {h}", count.peek());
h
});
HANDLES.set(Some((count, half)));
println!(" [app 运行] half={half}");
rsx! { "{half}" }
}
main 依次把 count 设成 1、2、3、4(在 dom.in_runtime 里写,后两次写完紧接着同步读一次 half),每次之后反复「wait_for_work(50 毫秒超时)→ render_immediate」直到没有脏 scope。debug 输出:
--- rebuild
[memo 闭包] count=0 -> 0
[app 运行] half=0
(没有脏 scope 了)
--- count = 1
[memo 闭包] count=1 -> 0
(没有脏 scope 了)
--- count = 2
[memo 闭包] count=2 -> 1
wait_for_work 返回,render_immediate
[app 运行] half=1
(没有脏 scope 了)
--- count = 3,紧接着同步读 half
[memo 闭包] count=3 -> 1
同步读到 half = 1
[memo 闭包] count=3 -> 1
(没有脏 scope 了)
--- count = 4,紧接着同步读 half
[memo 闭包] count=4 -> 2
同步读到 half = 2
wait_for_work 返回,render_immediate
[memo 闭包] count=4 -> 2
[app 运行] half=2
(没有脏 scope 了)
逐段看:
count = 1:没人读 memo,闭包照样跑了一次,是wait_for_work里 poll 到 memo 的任务时跑的。结果还是 0,recompute不set,组件没被标脏。count = 2:任务重算得 1,set通知组件,组件重跑。count = 3和4:写完马上读,读的那一刻dirty为真,当场重算(「同步读到」之前那一行)。但任务收到的消息还在通道里,它被 poll 时又无条件recompute了一次:闭包跑了两遍,第二遍白算。recompute不看dirty,任务也不看。count = 4时,惰性重算的set已经把组件标脏,render_immediate里先跑了 memo 的任务(任务和脏 scope 按高度交错取,同高度任务在前,见调度),再跑组件。
所以依赖 memo 的闭包要能承受「多算一次」:它应当是纯计算,不要在里面做有副作用的事。Memo 还实现了 Writable,set 写的就是内部 signal;写进去的值只保持到下一次重算。
订阅发生在重算之后
上面 try_read_unchecked 里,订阅当前 context 的那几行在 recompute 之后。顺序反过来会怎样:组件先订阅内部 signal,然后惰性重算 set 新值,写锁释放时通知订阅者,其中就有正在渲染的这个组件,它被标脏,渲染完又要再跑一遍,而它这一次已经读到新值了。源码注释里的 #2416 说的就是这个。
issue #2416「use_memo does not properly diff inputs and drops updates」报告「some updates are dropped, and some updates are identical to the previous one」。PR #2506(「Deduplicate reactive scope updates/Reset subscriptions on reruns/fix use memo double update」)一次改了三件事,说明原文:「When multiple reactive context subscriptions are updated, this PR deduplicates reruns for memos, effects and scopes」「When a scope, memo, or effect rerun this PR resets the subscriptions to remove any subscriptions to values that are no longer read (#2158)」「When memo is updated synchronously, this PR only subscribes to the memo value after that update to prevent the double rerun in #2416」。第三条就是上面这几行的顺序;第二条是 reset_and_run_in(core/src/reactive_context.rs 这个文件也是这个 PR 新建的);第一条对应 new_with_origin 里「通道里已经有消息就不再发」的判断和 use_effect 的 effect_queued。仓库里第一个包含它的 tag 是 v0.6.0-alpha.0,正式版 0.6.0。
use_memo
use_memo 只有三行:
pub fn use_memo<R: PartialEq + 'static>(mut f: impl FnMut() -> R + 'static) -> Memo<R> {
let callback = use_callback(move |_| f());
let caller = std::panic::Location::caller();
#[allow(clippy::redundant_closure)]
use_hook(|| Memo::new_with_location(move || callback(()), caller))
}
Memo 只在第一次渲染时建(use_hook),但它调用的是一个 use_callback:这个 hook 每次渲染都把里面的闭包 replace 成这次传进来的新闭包。所以 memo 每次重算用的都是最近一次渲染的闭包,它捕获的普通变量(比如 props)是最新的。但「换了闭包」不会触发重算:重算只由 memo 的 ReactiveContext 被标脏引起,而普通变量不是 signal,没人标脏它。下面 use_reactive! 一节的实验会看到后果。
use_effect
pub fn use_effect(mut callback: impl FnMut() + 'static) -> Effect {
let callback = use_callback(move |_| callback());
let location = std::panic::Location::caller();
use_hook(|| {
// Inside the effect, we track any reads so that we can rerun the effect if a value the effect reads changes
let (rc, mut changed) = ReactiveContext::new_with_origin(location);
// Deduplicate queued effects
let effect_queued = Rc::new(Cell::new(false));
// ...
let queue_effect_for_next_render = move || {
if effect_queued.get() {
return;
}
effect_queued.set(true);
let effect_queued = effect_queued.clone();
queue_effect(move || {
rc.reset_and_run_in(|| callback(()));
effect_queued.set(false);
});
};
queue_effect_for_next_render();
spawn(async move {
loop {
// Wait for context to change
let _ = changed.next().await;
// Run the effect
queue_effect_for_next_render();
}
});
Effect { rc }
})
}
和 memo 比,effect 不直接重跑,而是排队:
- 第一次渲染时排一次(
queue_effect_for_next_render())。 - 依赖是闭包在
rc里运行时读到的 signal;它们被写,rc的update往changed通道发消息,spawn出的任务醒来,再排一次。 effect_queued保证排队期间不会重复排:一轮里依赖被写多少次,effect 只跑一次。
queue_effect 把闭包交给 Runtime::queue_effect:scope 还没挂载就先存在 scope 上,挂载了就放进 pending_effects;等 DOM 改完、下一次 wait_for_work 的 poll_tasks 里没有别的事可做时,才逐个 pop_effect 运行(调度的「wait_for_work 与 effect」一节,含实验)。返回的 Effect 句柄只有一个 mark_dirty,手动让它再排一次。
pending_effects 的元素是 core 里的 Effect,一个 scope 一个,不是一个 use_effect 一个:
pub(crate) struct Effect {
// The scope that the effect is attached to
pub(crate) order: ScopeOrder,
// The callbacks that will be run when effects are rerun
effect: RefCell<VecDeque<Box<dyn FnOnce() + 'static>>>,
}
同一个 scope 里的多个 effect 按排队顺序放进同一个 VecDeque,run 时依次调用;不同 scope 的 Effect 按 ScopeOrder 排序,放在 pending_effects 这个 BTreeSet 里。ScopeOrder 先比 height、再比 ScopeId,而 pop_effect 是 pending_effects.pop_first()。这决定了父子组件的 effect 谁先跑,留作动脑筋。
sig1-exp/src/bin/effect_order.rs:
#[component]
fn Child(n: Signal<i32>) -> Element {
println!(" [Child 运行]");
use_effect(move || println!(" [Child effect] n = {n}"));
rsx! { "child" }
}
fn app() -> Element {
let n = use_signal(|| 0);
N.set(Some(n));
println!(" [App 运行]");
use_effect(move || println!(" [App effect] n = {n}"));
rsx! { Child { n } }
}
rebuild_in_place() 之后跑到没有脏 scope 为止,再从外面 n.set(1),同样跑到没有脏 scope。两段里 [App effect] 和 [Child effect] 各自谁先?n = 1 那一段,[App 运行]、[Child 运行] 会出现吗?(M2 Max、macOS 27、rustc 1.99.0,debug 构建。)
effect 是给「渲染之外的副作用」用的:改 DOM、调 JS、写日志。拿 effect 去算派生值(在 effect 里读 a、写 b),b 要等这一轮 DOM 改完、effect 跑过才更新,读 b 的组件要再重跑一轮;派生值应当用 memo,use_effect 的文档(hooks/docs/side_effects.md)开头就是「Effects are specifically created for side effects.」,并让派生状态改用 memo 或 resource。
非 signal 的依赖:use_reactive!
memo 和 effect 只认 signal。props 是普通值时,use_memo(move || x * 10) 里的 x 每次渲染都是新的(use_callback 换了闭包),但 memo 不会因此重算。use_reactive 的办法是把普通值存进一个 signal,每次渲染比较一次,变了就写进去:
pub fn use_reactive<O, D: Dependency>(
non_reactive_data: D,
mut closure: impl FnMut(D::Out) -> O + 'static,
) -> impl FnMut() -> O + 'static {
let mut first_run = false;
let mut last_state = use_signal(|| {
first_run = true;
non_reactive_data.out()
});
if !first_run && non_reactive_data.changed(&*last_state.peek()) {
last_state.set(non_reactive_data.out())
}
move || closure(last_state())
}
use_reactive 返回的闭包里读的是 last_state(),交给 use_memo / use_effect 后,last_state 就成了它们的依赖。比较用 Dependency trait:&A 和最多 8 个引用组成的元组自动实现,元素要 PartialEq + Clone + 'static,每次渲染都 clone 一份去比。注意这是一次渲染时写 signal,但先 peek 比较、只在变了时写,组件自己不订阅 last_state,不会自我触发(Signal 的「渲染时写 signal」)。
use_reactive! 宏只是把 |x, y| body 改写成 use_reactive((&x, &y), move |(x, y)| body),省得手写依赖元组。
sig1-exp/src/bin/memo_props.rs,父组件把一个 signal 的值当普通 i32 传给子组件:
#[component]
fn Child(x: i32) -> Element {
let plain = use_memo(move || x * 10);
let reactive = use_memo(use_reactive!(|x| x * 10));
use_effect(move || println!(" [plain effect] x = {x}"));
use_effect(use_reactive!(|x| println!(" [reactive effect] x = {x}")));
println!(" [Child 运行] x = {x}, plain = {plain}, reactive = {reactive}");
rsx! { "{plain} {reactive}" }
}
fn app() -> Element {
let x = use_signal(|| 1);
X.set(Some(x));
rsx! { Child { x: x() } }
}
依次把 x 设成 2、3,debug 输出:
--- rebuild
[Child 运行] x = 1, plain = 10, reactive = 10
[plain effect] x = 1
[reactive effect] x = 1
--- x = 2
[Child 运行] x = 2, plain = 10, reactive = 20
[reactive effect] x = 2
--- x = 3
[Child 运行] x = 3, plain = 10, reactive = 30
[reactive effect] x = 3
plain永远是 10,[plain effect]只在挂载时跑过一次:props 变了,子组件因为 props 不相等而重跑,但 memo 和 effect 都没有被标脏的理由。reactive在子组件同一次渲染里就是新值:use_reactive在渲染时set了last_state,memo 的 context 被标脏(dirty为真),紧接着"{reactive}"一读,走的是上一节的惰性重算。子组件只跑了一次,没有因为 memo 的set再跑一遍,正是「先重算、后订阅」的效果。[reactive effect]每次都跑,在子组件渲染之后。
另一种做法是把 props 声明成 ReadSignal<i32>,父组件传值时自动包成 signal,子组件里读它就是订阅(见 #[component] 与 Props)。
动脑筋答案
effect_order 的原样输出:
--- rebuild
[App 运行]
[Child 运行]
--- rebuild 返回
[App effect] n = 0
[Child effect] n = 0
--- n = 1
[App effect] n = 1
[Child effect] n = 1
- 两段都是父组件的 effect 先跑。
pending_effects是按ScopeOrder(先height后ScopeId)排序的BTreeSet,pop_effect取最小的,App的高度比Child小。这和 React 不同:React 的 effect 先子后父。 - 挂载那一段,effect 在
rebuild返回之后才跑(下一次wait_for_work里),两个组件都已经渲染完。 n = 1那一段没有[App 运行]和[Child 运行]:两个组件的渲染函数都没读n(App只是把句柄传下去,Child只在 effect 里读),订阅n的只有两个 effect 的 context。写n只让两个 effect 重新排队,组件不重跑。
小结
Memo= 内部Signal<T>+dirty标志 + 重算闭包,带自己的ReactiveContext;依赖被写时置脏并唤醒自己的任务。- 重算有两条路:任务被 poll 时立即重算(不管有没有人读),读的时候还脏就同步重算;同步读过之后任务还会再算一遍。
recompute用PartialEq比较,相等不set,下游不重跑;读 memo 时先重算、后订阅,避免正在渲染的组件被自己的读触发。use_effect在自己的 context 里跑闭包,依赖被写时任务醒来重新排队;effect_queued去重;core 按 scope 把 effect 放进按高度排序的BTreeSet,父先子后,DOM 改完才运行。use_memo/use_effect用use_callback每次换成最新闭包,但普通 props 变化不会触发重算;用use_reactive!把它们放进 signal,或者把 props 声明成ReadSignal。