Rust in Everything
基于 dioxus 0.7.10

渲染时写 signal:组件不停重跑

主线位置:更新主线的后半段。run_scope 重跑组件时,组件写了自己订阅的 signal,写锁释放时把自己标脏,SchedulerMsg::Immediate 又回到通道里,wait_for_work 立刻返回,render_immediate 再跑一遍。机制见 Signal 的「渲染时写 signal」和 异步:resource、future 与 spawn 的「在资源里写 signal」。

flowchart TB
  run["run_scope 跑组件"] --> rd["读 signal:订阅"]
  rd --> wr["写同一个 signal"]
  wr --> dirty["mark_dirty 发 Immediate"]
  dirty --> wfw["wait_for_work 立即返回"]
  wfw --> run

本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom。

现象

三个组件,pit1-exp/src/bin/write_render.rs。前两个想做同一件事:列表变短时,把选中的下标夹到范围内。第三个分页加载,把每页结果接到列表后面:

// keep the selection inside the list, written on every render
fn clamp_always() -> Element {
  run();
  let items = use_signal(|| vec!["a", "b", "c"]);
  let mut selected = use_signal(|| 1usize);
  selected.set(selected().min(items.len() - 1));
  rsx! { "selected = {selected}" }
}

// the same, but only write when the value is out of range
fn clamp_if_needed() -> Element {
  run();
  let items = use_signal(|| vec!["a", "b", "c"]);
  let mut selected = use_signal(|| 5usize);
  if selected() >= items.len() {
    selected.set(items.len() - 1);
  }
  rsx! { "selected = {selected}" }
}

async fn fetch_page(page: u32) -> Vec<u32> {
  FETCHES.set(FETCHES.get() + 1);
  tokio::time::sleep(Duration::from_millis(1)).await;
  vec![page * 10, page * 10 + 1]
}

// append each loaded page to the list
fn append_in_resource() -> Element {
  run();
  let page = use_signal(|| 1u32);
  let mut rows = use_signal(Vec::<u32>::new);
  let _ = use_resource(move || async move {
    let new_rows = fetch_page(page()).await;
    let mut all = rows(); // reading `rows` here subscribes the resource to it
    all.extend(new_rows);
    rows.set(all);
  });
  rsx! { "{rows.len()} rows" }
}

run() 给组件运行次数计数。每个组件 rebuild_in_place() 之后,循环「wait_for_work(20 毫秒超时)→ render_immediate」,墙钟满 200 毫秒就停;idle=true 表示某次 wait_for_work 超时了,也就是没有活可干。debug:

idle=false clamp_always        component ran  11283 times, fetches =    0, html = selected = 1
idle=true  clamp_if_needed     component ran      2 times, fetches =    0, html = selected = 2
idle=false append_in_resource  component ran     89 times, fetches =   89, html = 176 rows

release:

idle=false clamp_always        component ran 257941 times, fetches =    0, html = selected = 1
idle=true  clamp_if_needed     component ran      2 times, fetches =    0, html = selected = 2
idle=false append_in_resource  component ran     90 times, fetches =   90, html = 178 rows
  • clamp_always:200 毫秒里跑了一万多次(release 二十多万次),值一直是 1,从来没变过。每次 set 都把读过 selected 的组件自己标脏。
  • clamp_if_needed:跑 2 次就停了。第一次 5 越界,写成 2,组件被标脏;第二次 2 不越界,不写,于是不再有人标脏它。有条件的写最终会收敛,只是多渲染一轮。
  • append_in_resource:资源的 future 读了 rows(),use_resource 每次 poll 都在自己的 ReactiveContext 里,于是资源订阅了 rows;随后 rows.set 让资源重启,重新请求第 1 页再接上去。200 毫秒请求了约 90 次,列表长到 170 多行,同一页的数据一遍遍重复。在浏览器里就是网络面板里同一个请求刷屏。
  • 两种构建都没有任何警告或日志:0.6 的 debug 警告在 0.7 被删掉了(Signal 末尾的冷知识)。

这个循环不让出执行权。最初的驱动循环是用一个 200 毫秒的 tokio::time::timeout 包住整个 loop { wait_for_work; render_immediate },跑 clamp_always 时两分钟都没结束,只能杀掉:wait_for_work 每次都同步就绪,外面的计时器根本没机会被 poll。web 上主循环是同样的形状(一次点击),所以页面会卡死,不只是「多渲染几次」。上面的数字是改成每轮自己看墙钟之后得到的。

出处

写的一方不看值。Writable::set 就是 *self.write() = value;写守卫 drop 时 SignalSubscriberDrop 的 drop 通知所有订阅者,不比较新旧。组件的 ReactiveContext 收到通知后做的事在 new_for_scope 里:往调度通道发一条 SchedulerMsg::Immediate(id)。所以 selected.set(1) 在值已经是 1 时照样让组件重跑。

渲染开始时组件的订阅会被清空,所以「先写后读」不会循环(写的时候还没订阅);「先读后写」才会,selected.set(selected().min(..)) 的实参先求值,就是先读。

资源那一侧:use_resource 里每次 poll 用户 future 都包在 rc.run_in(..) 里,.await 前后读到的 signal 都成了依赖;依赖一变,监视任务 task.write().cancel() 再 task.set(cb(())),从头开始。用户 future 在完成前写了自己读过的 signal,就是在给自己发重启信号。

修法

派生值用 use_memo,不写回;资源里要往列表追加,用 write(),不先读。src/bin/write_render_fix.rs:

// derive the clamped value instead of writing it back
fn clamp_memo() -> Element {
  run();
  let items = use_signal(|| vec!["a", "b", "c"]);
  let selected = use_signal(|| 5usize);
  let shown = use_memo(move || selected().min(items.len() - 1));
  rsx! { "selected = {shown}" }
}

// append each loaded page to the list
fn append_in_resource() -> Element {
  run();
  let page = use_signal(|| 1u32);
  let mut rows = use_signal(Vec::<u32>::new);
  let _ = use_resource(move || async move {
    let new_rows = fetch_page(page()).await;
    rows.write().extend(new_rows); // write() does not subscribe
  });
  rsx! { "{rows.len()} rows" }
}

同样的驱动,debug:

idle=true  clamp_memo          component ran      1 times, fetches =    0, html = selected = 2
idle=true  append_in_resource  component ran      2 times, fetches =    1, html = 2 rows
  • memo 只在依赖变化时重算,并且只在结果不相等时通知读它的组件,天生就是「值不变就不通知」。组件只跑 1 次。
  • write() 不订阅,资源只依赖 page,请求 1 次;组件跑 2 次,第二次是 rows 变了。
  • 真要在渲染里写(比如把 props 同步进本地状态),至少写成 clamp_if_needed 那样:先 peek() 比较,只在不同时写。use_reactive 内部就是这么做的。更好的位置是引起变化的那个事件回调:删掉列表项的回调里顺手把 selected 夹好。

怎么确认没中招

  • 写一个测试,用上面的驱动方式跑组件,断言在有限轮内进入空闲:
let mut dom = VirtualDom::new(app);
dom.rebuild_in_place();
let mut rounds = 0;
while tokio::time::timeout(Duration::from_millis(20), dom.wait_for_work()).await.is_ok() {
  dom.render_immediate_to_vec();
  rounds += 1;
  assert!(rounds < 100, "component never settles");
}

注意超时要包在每一次 wait_for_work 外面,计数也要在循环里自己做;包在整个循环外面的超时永远不会触发,原因见上。

  • 搜渲染路径(组件函数体、use_resource / use_memo 的闭包)里同一个 signal 既被读(x()、x.read()、"{x}"、x.len())又被写(x.set、x.write()、+=)的地方。+= 本身不订阅,但同一个组件里别处的 "{x}" 会。
  • 浏览器里的症状:CPU 占满、页面不响应、网络面板里同一个 server function 请求不断重复。

小结

  • 先读后写同一个 signal:组件无限重跑,use_resource 无限重启;0.7 没有警告,debug 和 release 一样。
  • 写不比较新旧值,「夹到范围内」这种看起来无害的无条件写也会循环;有条件的写会在多渲染一轮后收敛。
  • 循环里 wait_for_work 从不返回 Pending,整个线程被占住,web 上就是页面卡死。
  • 修法:派生值用 use_memo;资源里追加用 write(),要读就用 peek();状态修正放进引起变化的事件回调。

💬 关联讨论

发起讨论
加载中...