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,或把字段声明成 ReadSignal | ReadSignal |
| 本地可编辑的副本,props 一变就丢弃本地修改 | use_effect 加 use_reactive!,在 effect 里把新值写进本地 signal | synced |
| 换了「对象」就整个重置(子树里所有 hook 的状态) | 用 key 让组件重挂载:放进 for,key 取对象 id | key 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 什么也不做。