#[component] 与 Props
主线位置:更新主线里,父组件重跑之后 diff 走到一个组件节点,渲染函数相同时就问这个组件 props 的
memoize:返回true就跳过子组件,返回false才run_scope重跑它。首屏主线里每个组件只跑一次,不涉及memoize。本页讲 props 这一侧:宏生成了什么,memoize怎么判断,哪些写法会让它永远判「变了」。diff 怎么调用它在 diff,run_scope在 Scope 与 hooks。
flowchart TB fnrow["fn Row 带参数"] --> attr["component 属性宏"] attr --> props["RowProps 结构体"] attr --> impls["手写 Clone 和 PartialEq"] props --> derive["Props derive"] derive --> builder["RowPropsBuilder"] derive --> memo["Properties memoize"] builder --> vcomp["into_vcomponent"] vcomp --> diffc["diff 组件节点"] memo --> diffc diffc -->|true| skip[跳过子组件] diffc -->|false| rerun["run_scope 重跑"]
源码在 packages/core-macro/src/component.rs(#[component])、packages/core-macro/src/props/mod.rs(Props derive,约 1.8k 行,从 typed-builder 改来)、packages/core/src/properties.rs 和 events.rs(Callback)。
#[component] 生成了什么
下面这个组件,cargo expand --offline --release 展开(只留结构体、两个 impl 和函数本身,builder 后面讲):
#[component]
fn Row(label: String, count: i32) -> Element {
rsx! { "{label}: {count}" }
}
///Properties for the [`Row`] component.
#[allow(missing_docs)]
#[allow(non_camel_case_types)]
struct RowProps {
label: String,
count: i32,
}
// ... RowProps::builder()、RowPropsBuilder、impl Properties(见下文)
impl ::core::clone::Clone for RowProps {
#[inline]
fn clone(&self) -> Self {
Self {
label: ::core::clone::Clone::clone(&self.label),
count: ::core::clone::Clone::clone(&self.count),
}
}
}
impl ::core::cmp::PartialEq for RowProps {
#[inline]
fn eq(&self, other: &Self) -> bool {
self.label == other.label && self.count == other.count && true
}
}
#[allow(non_snake_case)]
fn Row(RowProps { mut label, mut count }: RowProps) -> Element {
{
{
dioxus_core::Element::Ok({
// ... rsx! 的展开
})
}
}
}
// ... mod Row_completions { pub enum Component { Row {} } },只为编辑器补全
ComponentBody 的 ToTokens 做三件事:
- 参数收成结构体。
props_struct生成{函数名}Props,每个参数一个字段,加#[derive(Props)],再手写Clone和PartialEq两个 impl。 - 函数改成只收一个参数。
comp_fn把参数列表换成结构体解构模式RowProps { mut label, mut count }: RowProps,函数体原样放进去。所以组件函数在类型上就是fn(RowProps) -> Element,满足ComponentFunction。 - 三种情况不生成结构体。 没有参数时什么也不生成,函数保持
fn() -> Element;第一个参数名叫props,或者参数本身就是结构体模式(fn Nav(NavProps { title }: NavProps))时,认为用户自己写了 props 类型,只给函数加一个#[allow(non_snake_case)]。
validate_component_fn 还拒绝几种函数:没写返回类型(「Must return a <dioxus_core::Element>」)、带生命周期参数、async fn、const fn、带 self。props 不能借用,因为它要被存进 scope、跨多次渲染活着,见下文。
PartialEq 是逐字段 == 连起来,所以字段类型必须实现 PartialEq。少了它,rustc 的报错会指在 #[component] 那一行(实验 crate rsx-fail,Config 只 derive 了 Clone):
#[derive(Clone)]
struct Config {
depth: u32,
}
#[component]
fn Panel(config: Config) -> Element {
rsx! { "{config.depth}" }
}
error[E0369]: binary operation `==` cannot be applied to type `Config`
--> src/main.rs:8:1
|
8 | #[component]
| ^^^^^^^^^^^^
|
note: an implementation of `PartialEq` might be missing for `Config`
--> src/main.rs:4:1
|
4 | struct Config {
| ^^^^^^^^^^^^^ must implement `PartialEq`
= note: this error originates in the attribute macro `component` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Config` with `#[derive(PartialEq)]`
|
4 + #[derive(PartialEq)]
5 | struct Config {
|
反过来只 derive PartialEq 不 derive Clone,报的是 error[E0277]: the trait bound Config: Clone is not satisfied,同样指在 #[component]。
0.6 的 #[component] 给 props 结构体写的是 #[derive(Props, Clone, PartialEq)]。标准库的 derive 会给每个泛型参数加上 T: PartialEq、T: Clone 约束,不管字段用没用到 T。#3963 报告了这个问题:「When have generics in Props, it would be required to be Clone and PartialEq even not needed」,例子是 fn C2<T: 'static>(a: Box<T>),Box<T> 自己对任意 T 实现了 PartialEq,却还是报「binary operation == cannot be applied to type &mut C2Props<T>」。#3968「fix: more accurate derive Clone, PartialEq for generic components」改成上面那样逐字段手写(提交说明「convert to manual clone implements」),约束只来自字段本身。首次发布于 0.7.0-alpha.0,正式版是 0.7.0。这也是现在缺 PartialEq 时报错是 E0369「binary operation ==」的原因:报错来自手写的那个 self.config == other.config。
Props derive:类型状态的 builder
rsx! 里写 Row { label: "a", count: 1 },展开成(rsx 的 Component::create_props):
dioxus_core::DynamicNode::Component({
use dioxus_core::Properties;
let __comp = ({
dioxus_core::fc_to_builder(Row)
.label({ { "a" } })
.count({ { 1 } })
.build()
})
.into_vcomponent(Row);
__comp
}),
fc_to_builder 不调用组件,只借组件函数的类型推出 P,返回 P::builder()。Props derive 生成的 builder 用一个元组类型参数记录「哪些字段已经设过」:
fn builder() -> RowPropsBuilder<((), ())> {
RowPropsBuilder {
fields: ((), ()),
_phantom: ::core::default::Default::default(),
}
}
// ...
impl<__count> RowPropsBuilder<((), __count)> {
#[allow(clippy::type_complexity)]
pub fn label(
self,
label: impl ::core::fmt::Display,
) -> RowPropsBuilder<((String,), __count)> {
let label = (label.to_string(),);
let (_, count) = self.fields;
RowPropsBuilder {
fields: (label, count),
_phantom: self._phantom,
}
}
}
// ...
impl RowPropsBuilder<((String,), (i32,))> {
pub fn build(self) -> RowProps {
let (label, count) = self.fields;
let label = label.0;
let count = count.0;
RowProps { label, count }
}
}
() 表示没设,(T,) 表示设过。label 只在第一个槽是 () 时有这个方法,build 只在两个槽都是 (T,) 时可用。少设一个必填字段时,能匹配上的是另一个 build 重载:它被标成 #[deprecated(note = "Missing required field count")],还要求一个无法构造的枚举参数,于是先有一条警告,再有一条错误(实验 rsx-fail 的 missing_field):
warning: use of deprecated method `RowPropsBuilder::<((std::string::String,), ())>::build`: Missing required field count
--> src/bin/missing_field.rs:9:14
|
9 | rsx! { Row { label: "a" } }
| ^^^^^^^^^^^^^^
|
= note: `#[warn(deprecated)]` on by default
error[E0061]: this method takes 1 argument but 0 arguments were supplied
--> src/bin/missing_field.rs:9:14
|
9 | rsx! { Row { label: "a" } }
| ^^^^^^^^^^^^^^ argument #1 of type `RowPropsBuilder_Error_Missing_required_field_count` is missing
...
缺字段的名字藏在枚举名 RowPropsBuilder_Error_Missing_required_field_count 和 deprecated 说明里。重复设同一个字段用的是同样的办法(RowPropsBuilder_Error_Repeated_field_label)。这些检查全在类型层面,运行期的 builder 只是一个元组。
几种字段有默认行为,都在 FieldInfo::new 一带:
| 字段 | 默认行为 |
|---|---|
String | setter 收 impl Display,内部 to_string(),所以 label: 42 能编译 |
Option<T> | 可选,默认 None;setter 收 impl SuperInto<Option<T>>,可以直接写 T |
名叫 children | 默认 VNode::empty()(字段是 Option 时默认 None) |
ReadSignal<T> / EventHandler / Callback | setter 收 impl SuperInto<..>,并在 props 自己的 Owner 下转换,见后文 |
#[props(...)] 能写的项在 apply_meta 里:
| 写法 | 作用 |
|---|---|
default / default = 表达式 / default_code = "..." | 可以不传,用 Default::default() 或给定的表达式 |
optional | 等于 default 加 strip_option;!optional 关掉 Option<T> 的自动可选 |
into | setter 收 impl SuperInto<T>,任何 From 都行;对 String 字段改成 to_string |
strip_option | setter 收 T,存成 Some(T) |
displayable | setter 收 impl Display |
skip | builder 上没有这个 setter,必须同时给 default |
extends = GlobalAttributes(或 extend) | 字段类型是 Vec<Attribute>,rsx! 里写的元素属性收进来,可以再用 ..attributes 展开到元素上 |
实验 props_attrs 把这几种放在一起,用 dioxus_ssr::render 看结果:
#[component]
fn Badge(
// String fields take `impl Display`
label: String,
// Option<T> fields are optional and accept a bare T
title: Option<String>,
#[props(default = 3)] size: u8,
#[props(into)] width: f64,
#[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
rsx! {
span { title, "data-size": "{size}", "data-width": "{width}", ..attributes,
"{label}"
{children}
}
}
}
fn app() -> Element {
rsx! {
Badge { label: 42, width: 2u8 }
Badge { label: "new", title: "hi", size: 1, width: 1.5f32, id: "b2", class: "x", em { "!" } }
}
}
<span data-size="3" data-width="2">42</span><span title="hi" data-size="1" data-width="1.5" id="b2" class="x">new<em>!</em></span>
第一个 Badge 没传 title,None 变成 AttributeValue::None,属性不出现;size 取默认值 3;width: 2u8 经 into 变成 2.0;label: 42 经 Display。第二个的 id、class 不是 Badge 的字段,被 extends = GlobalAttributes 收进 attributes,再由 ..attributes 放到 span 上;em { "!" } 成了 children。
Properties 与 memoize
builder 的产物要实现 Properties:
pub trait Properties: Clone + Sized + 'static {
/// The type of the builder for this component.
/// Used to create "in-progress" versions of the props.
type Builder;
/// Create a builder for this component.
fn builder() -> Self::Builder;
/// Make the old props equal to the new props. Return if the props were equal and should be memoized.
fn memoize(&mut self, other: &Self) -> bool;
/// Create a component from the props.
fn into_vcomponent<M: 'static>(self, render_fn: impl ComponentFunction<Self, M>) -> VComponent {
let type_name = std::any::type_name_of_val(&render_fn);
VComponent::new(render_fn, self, type_name)
}
}
into_vcomponent 把渲染函数、props 和 P::memoize 这个函数指针一起装进 VProps,再类型擦除成 Box<dyn AnyProps> 放进 VComponent(Template 与 VNode 里的 DynamicNode::Component)。三个约束各有来处:
'static:props 存在 scope 里,跨很多次渲染活着,不能借用任何东西。这也是#[component]拒绝生命周期参数的原因。Clone:VProps::render每次渲染都是self.render_fn.rebuild(self.props.clone()),scope 手里留着一份,组件函数按值拿走一份。所以 props 里放大块数据时,Clone的代价每次渲染都要付一遍;放Rc、signal 句柄这种廉价克隆的东西更合适。PartialEq:给memoize用。
diff 遇到新旧两个组件节点时(diff_vcomponent):渲染函数地址不同就整个替换;相同就调 old_props.memoize(new_props),true 就直接返回,false 才 run_and_diff_scope 重跑子组件。memoize 拿的是 &mut self:scope 里存的是旧 props,新 props 在 diff 完就丢掉,所以 memoize 要负责在不相等时把新值搬进旧 props,子组件重跑时读到的才是新值。Row 生成的版本:
fn memoize(&mut self, new: &Self) -> bool {
let equal = self == new;
if !equal {
let new_clone = new.clone();
self.label = new_clone.label;
self.count = new_clone.count;
}
equal
}
没有参数的组件用的是 impl Properties for (),memoize 恒为 true:父组件怎么重跑都不会带着它重跑,它只会因为自己读的 signal 变了而重跑。
实验:子组件什么时候重跑
实验 memo_props:一个父组件下面挂七个子组件,每个子组件每跑一次就给自己的计数加一。父组件读一个线程局部的 TICK;用 dom.mark_dirty(ScopeId::APP) 让父组件重跑三轮:第一次构建、TICK 不变重跑、TICK 从 0 变成 1 再重跑。
#[component]
fn Fixed(n: i32) -> Element {
ran("Fixed");
rsx! { "{n}" }
}
#[component]
fn Varying(n: i32) -> Element {
ran("Varying");
rsx! { "{n}" }
}
#[component]
fn WithHandler(onclick: EventHandler<()>) -> Element {
ran("WithHandler");
rsx! { button { onclick: move |_| onclick.call(()), "x" } }
}
#[component]
fn WithChildren(children: Element) -> Element {
ran("WithChildren");
rsx! { div { {children} } }
}
#[component]
fn SigRead(n: ReadSignal<i32>) -> Element {
ran("SigRead");
rsx! { "{n}" }
}
#[component]
fn SigNoRead(n: ReadSignal<i32>) -> Element {
ran("SigNoRead");
rsx! { "static" }
}
#[component]
fn NoProps() -> Element {
ran("NoProps");
rsx! { "none" }
}
fn app() -> Element {
ran("app");
let tick = TICK.with(|t| t.get());
rsx! {
Fixed { n: 1 }
Varying { n: tick }
WithHandler { onclick: move |_| {} }
WithChildren { span { "hi" } }
SigRead { n: tick }
SigNoRead { n: tick }
NoProps {}
}
}
async fn settle(dom: &mut VirtualDom) {
dom.render_immediate_to_vec();
report(" after 1st render_immediate");
// pick up scopes that were marked dirty by signals during the diff
while tokio::time::timeout(Duration::from_millis(20), dom.wait_for_work()).await.is_ok() {
dom.render_immediate_to_vec();
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut dom = VirtualDom::new(app);
dom.rebuild_to_vec();
report("rebuild");
dom.mark_dirty(ScopeId::APP);
settle(&mut dom).await;
report("app rerun, tick unchanged");
TICK.with(|t| t.set(1));
dom.mark_dirty(ScopeId::APP);
settle(&mut dom).await;
report("app rerun, tick 0 -> 1");
}
(ran 给一个 BTreeMap 计数,report 按名字顺序打印累计次数,代码略。)debug 构建输出:
rebuild Fixed=1 NoProps=1 SigNoRead=1 SigRead=1 Varying=1 WithChildren=1 WithHandler=1 app=1
after 1st render_immediate Fixed=1 NoProps=1 SigNoRead=1 SigRead=1 Varying=1 WithChildren=2 WithHandler=2 app=2
app rerun, tick unchanged Fixed=1 NoProps=1 SigNoRead=1 SigRead=1 Varying=1 WithChildren=2 WithHandler=2 app=2
after 1st render_immediate Fixed=1 NoProps=1 SigNoRead=1 SigRead=1 Varying=2 WithChildren=3 WithHandler=3 app=3
app rerun, tick 0 -> 1 Fixed=1 NoProps=1 SigNoRead=1 SigRead=2 Varying=2 WithChildren=3 WithHandler=3 app=3
父组件跑了 3 次,各子组件:
| 子组件 | 次数 | 原因 |
|---|---|---|
Fixed { n: 1 } | 1 | 1 == 1,memoize 一直是 true |
NoProps | 1 | () 的 memoize 恒为 true |
Varying { n: tick } | 2 | 第三轮 0 != 1,重跑一次 |
WithHandler | 3 | 每轮都重跑,值没变也一样 |
WithChildren | 3 | 每轮都重跑 |
SigRead | 2 | 第三轮重跑,但不是 diff 让它跑的,见下文 |
SigNoRead | 1 | 值变了也没跑 |
Fixed、Varying、NoProps 是预期中的样子。剩下四个要分开讲。
EventHandler 与 Callback:闭包每次都是新的
EventHandler<T> 就是 Callback<T>。Callback 是两个字段:创建它的 scope origin,和一个 GenerationalBox 里的闭包,所以它是 Copy 的,可以随便移进别的闭包和 future。它的相等是比指针:
impl<Args, Ret> PartialEq for Callback<Args, Ret> {
fn eq(&self, other: &Self) -> bool {
self.callback.ptr_eq(&other.callback) && self.origin == other.origin
}
}
rsx! 里写 onclick: move |_| {},setter 每次都 Callback::new 一个新的 GenerationalBox,指针每次都不同,于是 props 的 == 每次都是 false,子组件跟着父组件每次重跑。这就是 WithHandler 跑了 3 次的原因。
宏为回调字段生成的 memoize 还多做一件事:先算 let equal = self == new;,再对每个回调字段调 self.onclick.__point_to(&new.onclick),让旧的 Callback 句柄指向新闭包。这样子组件即使被跳过,它之前移进 future 或子闭包里的那个旧句柄,调用时跑的也是最新的闭包。它保证的是「不用旧闭包」,不是「不重跑」。
问: Callback 的 callback 字段上有一段注释,说「During diffing components with EventHandler, we move the EventHandler over in place instead of rerunning the child component」,例子里还写着「Diffing Child will not rerun this component」。为什么实验里它每次都重跑?
答: 那段注释写的是旧行为。0.5 的 EventHandler 的 PartialEq 是 fn eq(&self, _: &Self) -> bool { true },回调字段永远「相等」,所以那时确实只换闭包、不重跑。0.6.0 起(#3271 的改动里)Callback 的相等改成了上面的指针比较,注释没跟着改。目前上游还开着一个 PR #5685,打算让 Element props 按值比较,但对闭包仍然保持现状:「event listeners and EventHandler/Callback props pointing at fresh closures compare unequal, so those subtrees re-render exactly as before」。所以在 0.7.10 里,传闭包的子组件就当它每次都重跑。
想让它被跳过,就每次传同一个 Callback。use_callback 在 use_hook 里只 Callback::new 一次,之后每次渲染只把里面的闭包换掉(inner.replace(..)),句柄不变。实验 use_callback_memo:
#[component]
fn WithHandler(onclick: EventHandler<()>) -> Element {
RUNS.with(|r| r.set(r.get() + 1));
rsx! { button { onclick: move |_| onclick.call(()), "x" } }
}
fn app() -> Element {
let cb = use_callback(move |_: ()| {});
rsx! { WithHandler { onclick: cb } }
}
fn main() {
let mut dom = VirtualDom::new(app);
dom.rebuild_to_vec();
for _ in 0..3 {
dom.mark_dirty(ScopeId::APP);
dom.render_immediate_to_vec();
}
println!("app rendered 4 times, WithHandler ran {} times", RUNS.with(|r| r.get()));
}
app rendered 4 times, WithHandler ran 1 times
children: Element 也是每次都变
children 字段的类型是 Element,rsx! 里写在组件花括号里的子节点展开成 .children(dioxus_core::Element::Ok({ ... })),这是一个新的 VNode。Element 的 == 落到 VNode 的 ==,即 Rc::ptr_eq(Template 与 VNode),父组件每跑一次就是一个新的 Rc,于是 WithChildren 也每轮都跑,哪怕 span { "hi" } 一个动态部分都没有。
这是一个已知的问题,#1929「Implement memoization by value of Elements passed as props」2024 年 2 月开出来,到现在还开着,上面那个 #5685 的说明里写得很直接:「VNode's PartialEq compared by Rc pointer, so any component receiving an Element prop (most commonly children) re-rendered every time its parent re-rendered, even when the passed nodes were identical」。子组件重跑并不意味着 DOM 被改:它返回的 VNode 和上一次模板相同、动态部分相同,diff 不发任何指令。代价是组件函数本身和它的 hooks 多跑一遍。
ReadSignal props:跳过子组件,但通知订阅者
ReadSignal<T>(旧名 ReadOnlySignal,现在是它的类型别名)字段走另一条路。looks_like_signal_type 只看类型路径最后一段叫不叫 ReadSignal / ReadOnlySignal;Option<ReadSignal<T>> 不算。
传值时,setter 在 props 自己的 Owner 下做 SuperInto(setter 的生成代码里是 dioxus_core::with_owner(self.owner.clone(), move || dioxus_core::SuperInto::super_into(..))),而 SuperFrom<T> for ReadSignal<O> 就是 ReadSignal::new(Signal::new(O::super_from(input)))。所以 SigRead { n: tick } 每次都新建一个 signal,它属于这次的 props;props 里有 signal 或回调字段时,宏额外生成一个 {名字}PropsWithOwner 包装(这里是 SigReadPropsWithOwner),把 Owner 和 props 绑在一起,build() 返回的是这个包装。
关键在 memoize_impl 里 signal 字段那一支(这是宏里 quote! 的模板,#signal_fields 会对每个 signal 字段展开一遍):
Ok(quote! {
// First check if the fields are equal. This will compare the signal fields by pointer
let exactly_equal = self == new;
if exactly_equal {
// If they are return early, they can be memoized without any changes
return true;
}
// If they are not, move the signal fields into self and check if they are equal now that the signal fields are equal
#(
let mut #signal_fields = self.#signal_fields;
self.#signal_fields = new.#signal_fields;
)*
// Then check if the fields are equal now that we know the signal fields are equal
// NOTE: we don't compare other fields individually because we want to let users opt-out of memoization for certain fields by implementing PartialEq themselves
let non_signal_fields_equal = self == new;
// If they are not equal, we need to move over all the fields that are not event handlers or signals to self
if !non_signal_fields_equal {
let new_clone = new.clone();
#(
self.#regular_fields = new_clone.#regular_fields;
)*
}
// Move any signal and event fields into their old container.
// We update signals and event handlers in place so that they are always up to date even if they were moved into a future in a previous render
#move_signal_fields
#move_event_handlers
non_signal_fields_equal
})
#move_signal_fields 对每个 signal 字段做的是:用 peek() 比较新旧两个 signal 里的值(不订阅),然后 point_to 让旧 signal 指向新 signal 的存储,值不相等时再 mark_dirty() 通知旧 signal 的订阅者。返回值只看非 signal 字段是否相等。
于是:
SigNoRead只有一个 signal 字段,非 signal 字段「全相等」,memoize返回true,diff 不重跑它。它的函数体没读n,没有订阅,mark_dirty通知不到它,所以一直是 1 次。SigRead同样被 diff 跳过,但它在渲染时读了n("{n}"),是n的订阅者。第三轮值从 0 变成 1,mark_dirty把它标脏,它因为自己的订阅而重跑。输出里after 1st render_immediate那一行它还是 1,等wait_for_work返回、再跑一次render_immediate才变成 2:标脏发的是调度消息,不在当前这次 diff 里处理,见 调度。- 第二轮值没变(都是 0),
peek比较相等,不mark_dirty,谁都不跑。
这正是 ReadSignal props 的用途:子组件里的 use_memo、use_effect 读这个 signal 就能跟着 props 变,不需要整个子组件重跑;普通值 props 做不到这一点,因为 hook 里的闭包只在第一次渲染时捕获了旧值,见 Memo 与 effect。
memoize 在值不相等时把新 signal 里的值取出来塞进旧 signal((#signal_fields).__set(new.#signal_fields.__take())),回调字段也一样(self.#name.__set(new.#name.__take()))。#2331「Diffing cloned Components with readonlysignal panics on double drop」报告:「Any props with the T -> ReadOnlySignal<T> logic fail to diff if you try to render it multiple times」,例子是把同一段 rsx! 克隆一份、两处都渲染,第一处 diff 已经把值搬走,第二处再 diff 就 panic。#2839「Fix cloned rsx double drop」先给 generational-box 加了引用计数的指针(提交说明「Implement Rc generational pointers」),然后「Move over the pointer instead of the data in ReadOnlySignal props」「use rc generational boxes for event handlers as well」,即现在的 point_to 加 mark_dirty 和回调的 __point_to。首次出现在 0.6.0-alpha.3,正式版 0.6.0。小结
#[component]把参数收成{名字}Props,派生Props、逐字段手写Clone和PartialEq,函数改成解构这个结构体;没参数、参数叫props或是结构体模式时不生成。Propsderive 生成类型状态 builder:缺必填字段、重复设字段都是编译错误,错误信息藏在..._Error_Missing_required_field_x这样的类型名里。String收Display,Option<T>自动可选,children默认空,#[props(default, into, optional, extends = GlobalAttributes, ...)]调整这些行为。Properties::memoize决定子组件重不重跑:相等返回true跳过;不等时把新值搬进 scope 里的旧 props 再返回false。'static因为 props 存在 scope 里,Clone因为每次渲染都克隆一份交给组件函数。- 闭包(
EventHandler/Callback)和children: Element按指针比,父组件每次重跑它们都判「变了」,子组件跟着重跑;Callback字段上的注释说不会重跑,那是 0.5 的行为。 ReadSignal字段不参与这个判断:memoize让旧 signal 指向新存储,值变了就mark_dirty,子组件只有读过它才会重跑,而且是在下一轮调度里。