Rust in Everything
基于 dioxus 0.7.10

props 变了,子组件里的状态没跟着变

主线位置:更新主线里父组件重跑、diff 比较子组件 props 的那一步。props 不相等时子组件会重跑,拿到新 props;但 hook 里的值和闭包是第一次渲染时留下的。props 怎么比较、ReadSignal 字段怎么特殊处理,见 #[component] 与 Props;use_memo 读普通 props 的实验见 Memo 与 effect 的「非 signal 的依赖」。

flowchart TB
  parent["父组件重跑,props 变了"] --> rerun["子组件重跑,拿到新 props"]
  rerun --> sig["use_signal 初始化闭包:不再调用"]
  rerun --> memo["use_memo:没有依赖变化"]
  sig --> stale["显示旧值"]
  memo --> stale

本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom,点击用 Runtime::handle_event 派发。

现象

一个编辑器子组件,父组件切换正在编辑的文档。pit1-exp/src/bin/stale_props.rs 把常见的几种写法并排放:

const DOCS: [(u32, &str); 2] = [(1, "draft one"), (2, "draft two")];

// local copy of a prop, made once
#[component]
fn Editor(title: String) -> Element {
  let text = use_signal(|| title.clone());
  let t = title.clone();
  let upper = use_memo(move || t.to_uppercase());
  rsx! { p { "prop={title} | signal={text} | memo={upper}" } }
}

// the same component; the parent gives it a key
#[component]
fn KeyedEditor(label: String, title: String) -> Element {
  let text = use_signal(|| title.clone());
  rsx! { p { "{label}: signal={text}" } }
}

// copy the prop into the signal whenever it changes
#[component]
fn SyncedEditor(title: String) -> Element {
  let mut text = use_signal(|| title.clone());
  use_effect(use_reactive!(|title| text.set(title)));
  rsx! { p { "synced: signal={text}" } }
}

// prop as ReadSignal: a memo that reads it is a subscriber
#[component]
fn SignalEditor(title: ReadSignal<String>) -> Element {
  let upper = use_memo(move || title().to_uppercase());
  rsx! { p { "ReadSignal: memo={upper}" } }
}

fn app() -> Element {
  let mut doc = use_signal(|| 0usize);
  let (id, title) = DOCS[doc()];
  rsx! {
    button { onclick: move |_| doc.set(1), "open doc 2" }
    Editor { title }
    div { KeyedEditor { key: "{id}", label: "key alone", title } }
    for (id, title) in [(id, title)] {
      KeyedEditor { key: "{id}", label: "key in a list", title }
    }
    SyncedEditor { title }
    SignalEditor { title }
  }
}

点一次「open doc 2」,前后各打印一次 dioxus_ssr::render(每个 <p> 换成一行)。debug 和 release 输出相同:

doc 1:
<button>open doc 2</button>
  prop=draft one | signal=draft one | memo=DRAFT ONE<div>
  key alone: signal=draft one</div>
  key in a list: signal=draft one
  synced: signal=draft one
  ReadSignal: memo=DRAFT ONE
doc 2:
<button>open doc 2</button>
  prop=draft two | signal=draft one | memo=DRAFT ONE<div>
  key alone: signal=draft one</div>
  key in a list: signal=draft two
  synced: signal=draft two
  ReadSignal: memo=DRAFT TWO
  • Editor:props 确实到了(prop=draft two,子组件重跑过),但 use_signal 里还是第一篇的标题,use_memo 也没重算。
  • key alone:给子组件加了 key,没有用,状态照旧。
  • key in a list:同一个组件放进只有一项的 for,key 一变就是新的组件实例,状态从新 props 重新初始化。
  • synced 和 ReadSignal 两种都跟上了。

出处

use_signal(f) 是 use_hook(|| Signal::new_with_caller(f(), caller)),Scope::use_hook 只在这一格还不存在时调用初始化闭包。之后的渲染里 || title.clone() 这个闭包每次都被构造出来,但从不被调用。这不是 bug,use_signal 的意思就是「挂载时的初值」。

use_memo 把闭包交给 use_callback,每次渲染都换成新闭包(新闭包里捕获的是新 title),但 Memo 只在挂载时建一次,它重算的唯一理由是上次计算时读过的 signal 变了。普通 String props 不是 signal,读它不订阅任何东西,所以新闭包一直没有机会运行(Memo 与 effect 里有同样的实验和 use_reactive 的源码)。

key 为什么只在列表里有用:dioxus 只在比较一组兄弟节点时看 key。diff_non_empty_fragment 先看第一项有没有 key,带 key 的走 diff_keyed_children,key 不同就当成不同的节点;整个 core/src/diff/ 里比较 key 的代码只在 iterator.rs。div { KeyedEditor { key: .. } } 里的 key 落在模板的 VNode 上,这个 VNode 不在任何兄弟列表里,没人看它,组件照常按函数指针和 props 走 diff_vcomponent。如果 key 写在块里第一个节点之外,rsx! 还会给一个编译警告「Keys are only allowed on the first node in the block.」(check_for_duplicate_keys),这里写在第一个节点上,连警告都没有。

修法

先想清楚子组件里的那份状态是什么:

想要的写法上面哪一行
只是 props 的派生值不要 hook,直接在渲染里算;计算贵时用 use_reactive! 包一层再交给 use_memo,或把字段声明成 ReadSignalReadSignal
本地可编辑的副本,props 一变就丢弃本地修改use_effect 加 use_reactive!,在 effect 里把新值写进本地 signalsynced
换了「对象」就整个重置(子树里所有 hook 的状态)用 key 让组件重挂载:放进 for,key 取对象 idkey in a list

几点说明:

  • use_reactive! 把普通值存进一个 signal,每次渲染 peek 比较、变了才写,交给 use_memo / use_effect 后就成了依赖。synced 用 effect 写回,effect 在渲染之后跑,所以会多一轮渲染:点击之后只跑一次 render_immediate,synced 那一行还是旧值:

    after one render_immediate: synced: signal=draft one</p>
    

    effect 写入之后的下一轮才是 draft two。用户通常看不出来,但写测试时要把 VirtualDom 跑到空闲再断言。

  • ReadSignal<String> 字段在父组件传值时自动包成 signal,子组件被 diff 跳过、靠订阅重跑;title() 读它就是订阅。

  • 重挂载会重置子组件里所有状态,包括用户正在输入的内容,这正是「切换文档」时想要的;只想更新一个字段时不要用它。

怎么确认没中招

  • 搜 use_signal(|| <props 字段>、use_hook(|| <props 字段>、use_memo(move || ..) / use_effect(move || ..) 闭包里引用了普通 props 字段的地方。每一处问一句:props 变了,这里应不应该变。应该变,就按上表改。
  • 搜单独一个组件上的 key:(不在 for 或迭代器里)。它不会触发重挂载。
  • 写组件测试:父组件改一次 props,把 VirtualDom 跑到空闲,断言 dioxus_ssr::render 里的内容。上面的 stale_props 就是这样的测试,Editor 那一行就是失败的断言。

小结

  • hook 只在挂载时初始化:use_signal(|| props.x) 是初值,不是绑定;use_memo / use_effect 不把普通 props 当依赖。
  • 子组件拿到了新 props(它重跑了),只是 hook 里的东西没变,所以页面上「一半新、一半旧」。
  • 派生值直接算,或用 use_reactive! / ReadSignal;本地副本用 use_effect(use_reactive!(..)) 同步;要整体重置就用 key 重挂载。
  • key 只在兄弟列表里起作用,单个组件上的 key 什么也不做。

💬 关联讨论

发起讨论
加载中...