Dioxus Props 派生宏深度指南:用 #[derive(Props)] 精确控制组件参数
【免费下载链接】dioxusFullstack app framework for web, desktop, and mobile.项目地址: https://gitcode.com/GitHub_Trending/di/dioxus
Props(属性)是 Dioxus 组件之间通信的契约。在 Dioxus 中,任何一个组件要么不接收参数,要么接收一个实现了Propertiestrait 的单一参数,而#[derive(Props)]派生宏正是把"字段声明"翻译成"组件可用属性"的核心机制。本文以 packages/core-macro/docs/props.md 为骨架,结合 derive 宏的底层实现 与 core 中Propertiestrait 的定义,完整讲解如何声明 props、四种#[props(...)]修饰符、Option<T>/String/ReadSignal<T>/children等特殊字段类型,以及组件属性在底层是如何完成比较与记忆化(memoize)的。读完你不仅能写出健壮的组件签名,还能理解 prop 变化时组件重跑背后的原理。
关于使用建议:
#[derive(Props)]与显式 props 结构体适合需要精细控制Propertiestrait 实现的场景;而日常开发通常优先使用#[component]宏,它会自动生成 props 结构体,并额外校验组件是否合法。两套语法的字段级行为完全一致,可相互对照学习 component 宏文档。
声明 Props 的最小示例
下面的ButtonProps就是一个最基础的 props 结构体:用#[derive(Props, PartialEq, Clone)]派生,每个字段都会被自动转换成组件在使用处的属性,字段上的///文档注释会原样出现在 IDE 补全提示中。
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { /// The text of the button text: String, /// The color of the button color: String, } fn Button(props: ButtonProps) -> Element { rsx! { button { color: props.color, "{props.text}" } } } rsx! { // 结构体上定义的每个字段都会成为该组件的属性 Button { text: "Click me!", color: "red", } };需要注意派生宏的硬性约束。从 props/mod.rs 的入口实现 可以看到:Props只支持具名字段的结构体,元组结构体(tuple struct)、单元结构体(unit struct)、枚举(enum)和联合体(union)都会直接编译报错。另外,字段名为key会被明确拒绝,因为它会与 Dioxus 内建的 key 属性冲突——在列表渲染中为每个条目设置稳定 key 需要改用 RSX 层面提供的机制。
四大 Prop 修饰符一览
通过#[props(...)]属性可以改变单个字段在组件使用处的行为。下表是文档给出的全部公开修饰符:
| 修饰符 | 作用 |
|---|---|
#[props(default)] | 让字段在使用组件时可省略,省略时取字段类型的Default实现 |
#[props(!optional)] | 让Option<T>类型的字段变为必填 |
#[props(into)] | 使用Intotrait 把传入值自动转换成字段类型 |
#[props(extends = GlobalAttributes)] | 用某元素(或全局元素属性集合)的全部属性扩展该 props |
除此之外,还有几种特殊字段类型会自动触发不同的行为,无需显式写修饰符:
Option<T>—— 自动变成可选字段,缺省值为None;ReadSignal<T>—— 传参时自动把T转换成ReadSignal<T>;String—— 可以接受格式化字符串(format string);- 名为
children的字段 —— 接受组件标签体里的子元素。
这些规则在源码的 FieldBuilderAttr::with / apply_meta 解析逻辑 中有非常直观的对应:default、default = 表达式、default_code、into、optional、strip_option、displayable、skip、extends等均在语法层面被逐一识别与校验。
默认值:#[props(default)]与显式默认表达式
default修饰符的作用是:当组件使用处没有设置该属性时,用一个默认值兜底,让字段变为"可选但类型仍非Option"。
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { // default 让字段在使用组件时可省略,省略时使用类型 Default 实现 #[props(default)] text: String, // 也可以显式给出默认表达式,替代 Default 实现 #[props(default = "red".to_string())] color: String, } fn Button(props: ButtonProps) -> Element { rsx! { button { color: props.color, "{props.text}" } } } rsx! { // 带默认值的属性都可以跳过不写 Button {} };从源码看,这个修饰符有两档语义:
- 裸写
#[props(default)]等价于把默认值设为::core::default::Default::default(); - 写成
#[props(default = <表达式>)]则把表达式直接作为默认值。解析位于 apply_meta 对Expr::Assign的分支,代码中还额外支持#[props(default_code = "...")](把字符串形式的 token 再解析为表达式)这一内部变体。
一个值得注意的底层细节:拥有默认值的字段在生成的 builder 中不再参与"必填校验",详见 required_field_impl;而未提供默认值的字段,编译器会通过一个隐藏类型 + 过期方法(#[deprecated(note = "Missing required field ...")])在你漏填时给出清晰错误提示。也就是说,#[derive(Props)]在编译期就把"必填 / 可选"信息编码进了类型系统。
可选属性:Option<T>与!optional
很多时候我们想要"可省略且缺省为无"的属性,又懒得写显式默认值。此时只要把字段声明成Option<T>,props 宏就会自动把它变成可选字段,缺省值为None:
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { // text 字段是 Option<String>,所以使用时可以不设置 text: Option<String>, } fn Button(props: ButtonProps) -> Element { rsx! { button { {props.text.unwrap_or("button".to_string())} } } } rsx! { Button {} };若反过来想让某个Option<T>字段强制必填,使用!optional即可:
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { // 用 !optional 让 Option<T> 字段变成必填 #[props(!optional)] text: Option<String>, } fn Button(props: ButtonProps) -> Element { rsx! { button { {props.text.unwrap_or("button".to_string())} } } } rsx! { Button { text: None } };这里text: None必须显式出现,因为字段已经被标记为必填。自动可选的识别逻辑在源码中对应 type_from_inside_option 对Option<T>内层类型的剥离——它甚至能穿透ReadSignal<Option<T>>这类嵌套形式,把最内层类型识别出来。而!optional(即对optional取反)在 apply_meta 的Expr::Unary分支中会同时关闭strip_option并置位ignore_option,从而跳过自动的Option剥离与默认值生成。
自动类型转换:#[props(into)]
使用into修饰符可以让字段在接收参数时自动执行Intotrait 转换,从而在调用方写出更自然的字面量:
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { // into 会把传入值用 Into trait 转换成 u64 #[props(into)] number: u64, } fn Button(props: ButtonProps) -> Element { rsx! { button { "{props.number}" } } } rsx! { Button { // 有了 into,可以传入任何实现了 Into<u64> 的类型 number: 10u8 } };之所以10u8能赋给u64字段,是因为标准库为数值类型提供了大量From/Into实现。宏在生成 builder setter 时会把参数类型做成impl dioxus_core::SuperInto<#arg_type, __Marker>的泛型形式,并在赋值处调用super_into,这部分代码位于 field_impl 的参数类型改写。另外源码中还体现了一些自动转换的隐藏规则,例如:字段类型是WriteSignal/Store等时会自动启用into(looks_like_write_type / looks_like_store_type 检查);String字段若同时标记into,则会退化为使用ToString(props/mod.rs 中对 String 的自动处理),以保证给出更有用的错误信息。
格式化字符串属性:String字段直接支持内插
RSX 元素属性里可以直接书写"Hello {name}"这样的格式化字符串,String类型的 props 字段同样继承了这个能力:
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { text: String, } fn Button(props: ButtonProps) -> Element { rsx! { button { "{props.text}" } } } let name = "Bob"; rsx! { Button { // 和元素属性一样,String 类型的 props 也接受格式化字符串 text: "Hello {name}!" } };实现层面,宏在发现字段类型是String时会把 builder setter 的参数收敛为impl ::core::fmt::Display,并在赋值处生成#field_name.to_string()(对应 props/mod.rs 中from_displayable的分支)。这意味着你可以传任何实现了Display的值,而不只是字面量。
children 子元素:把组件变成容器
如果你希望组件像 HTML 元素一样承载内容,而不是把内容塞进普通属性,那么声明一个名为children、类型为Element的字段即可。这是 props 宏内置的"魔法"字段:
use dioxus::prelude::*; #[derive(PartialEq, Clone, Props)] struct ClickableProps { href: String, children: Element, } fn Clickable(props: ClickableProps) -> Element { rsx! { a { href: "{props.href}", class: "fancy-button", {props.children} } } }使用时,把子内容直接写进组件标签的花括号内即可:
use dioxus::prelude::*; #[derive(PartialEq, Clone, Props)] struct ClickableProps { href: String, children: Element, } fn Clickable(props: ClickableProps) -> Element { rsx! { a { href: "{props.href}", class: "fancy-button", {props.children} } } } rsx! { Clickable { href: "#", "How to " i { "not" } " be seen" } };这里"How to "、i { "not" }、" be seen"三块内容都会被收集进children,随后在a标签内原样渲染。这一特性在示例仓库中有大量应用,例如 01-app-demos 中的 CalculatorKey 与 02-building-ui/children.rs 里的 Card / Section 都通过children: Element实现内容注入。
底层还有一个容易忽略的细节:字段名为children且未标记可选项时,宏会自动为它填充dioxus_core::VNode::empty()作为默认值(见 props/mod.rs 中 children 字段的默认值处理)。所以即使调用方没有传子内容,组件内部也能安全地渲染空节点,不会出现缺省字段的编译错误。
响应式 props:属性变化与 memoize 的取舍
在 Dioxus 中,父组件传入的 prop 一旦改变,子组件就会用新值重跑一遍以更新 UI。例如下面的Counter,当count从 0 变到 1 时,组件会重跑并渲染出 "Count: 1":
use dioxus::prelude::*; #[component] fn Counter(count: i32) -> Element { rsx! { div { "Count: {count}" } } }大多数情况下,让组件重跑就够了。但如果你把 prop 用在了use_memo、use_resource这类响应式钩子内部,问题就出现了:这些钩子的闭包只在首次运行时创建,而普通i32参数本身不具备响应式追踪能力,因此 memo 不会在count改变时自动重算:
use dioxus::prelude::*; #[component] fn Counter(count: i32) -> Element { // 这个 memo 只在组件首次运行时创建,count 不是响应式的,所以它永远不会随 count 更新 let doubled_count = use_memo(move || count * 2); rsx! { div { "Count: {count}" "Doubled Count: {doubled_count}" } } }文档给出了两种修复方案:
方案一:用ReadSignal<T>让 prop 变成响应式(推荐)
ReadSignal是Copy的响应式值。props 宏会自动把传给ReadSignal<T>字段的T值包装成信号——它由子组件持有(child-owned),所以信号的所有者管理也是安全的:
use dioxus::prelude::*; #[component] fn Counter(count: ReadSignal<i32>) -> Element { // 因为 count 现在是响应式的,memo 会在 count 变化时自动重跑 let doubled_count = use_memo(move || count() * 2); rsx! { div { "Count: {count}" "Doubled Count: {doubled_count}" } } }方案二:用use_reactive!显式声明依赖
如果不想改变字段类型,可以保持普通i32参数,改用use_reactive!宏把count显式声明为闭包依赖。use_reactive!接收一个闭包,其参数列表即为依赖列表:
use dioxus::prelude::*; #[component] fn Counter(count: i32) -> Element { // 用 use_reactive! 把 count 作为显式依赖传给所有使用它的响应式钩子 let doubled_count = use_memo(use_reactive!(|count| count * 2)); rsx! { div { "Count: {count}" "Doubled Count: {doubled_count}" } } }从源码看 props 的 memoize 是怎么发生的
为什么ReadSignal方案是"推荐"的?可以结合 core 与宏的实现来理解。
Dioxus 在 diff 组件时会先比较新旧 props 是否相等,相等则跳过重跑。这在 core 的Propertiestrait 中被明确为memoize(&mut self, other: &Self) -> bool:把旧 props "原地改写"成新 props,并返回"是否应被记忆化"。trait 注释同时要求 props 满足'static、Clone、PartialEq——这也是派生宏强制要求你在 derive 列表里带上Clone, PartialEq的原因。
而 props 宏生成的memoize实现(见 props/mod.rs 的 memoize_impl)远比"整体替换"精细:
- 如果存在
ReadSignal/ReadOnlySignal之类的信号字段,且新旧相等,直接返回true完成记忆化; - 信号与事件处理器(
EventHandler/Callback)字段会被就地(in-place)更新:信号通过point_to指向新值并mark_dirty唤醒订阅者,事件处理器则通过__point_to原地重定向——这样即使旧信号/处理器此前已被移入某个 future 或钩子,也能在每次重渲染后保持指向最新值; - 仅当普通非信号字段变化时,才把对应的常规字段从新 props 中拷贝过来。
仓库中的两个测试可以佐证这一点:
- core-macro/tests/event_handler.rs 中的
TakesSignal/TakesEventHandler组件把首次渲染的 props 存进use_hook,随后在多次渲染中校验信号与事件处理器"从未被 drop、始终可用",即就地记忆化的回归测试; - values_memoize_in_place.rs 的 spreads_memorize_in_place 测试 直接对
#[props(extends = GlobalAttributes)]的 props 调用memoize,验证属性集合确实会按值就地更新。
正是这套就地更新机制,使得"把count: i32换成count: ReadSignal<i32>"后,use_memo能够感知变化——信号被更新时其订阅者(memo)会被标记为 dirty 并自动重算,而普通i32只参与PartialEq比较、不具备这种追踪能力。
扩展元素属性:extends与属性透传
自己封装的组件往往需要支持标准 HTML 属性(如width、height、color、class……)。如果逐一在 props 结构体里声明,既繁琐又容易漏。extends修饰符就是为了解决这个问题:把某个元素(或全局元素属性集合)的全部属性"扩进来",使用方就能像操作原生元素一样设置它们。
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct CardProps { // 在 Vec<Attribute> 字段上声明 extends,即可扩展某元素或全局元素属性的全部属性 #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>, } #[component] fn Card(props: CardProps) -> Element { rsx! { // 无需逐一手写每个属性,直接把 props.attributes 展开进元素即可 div { ..props.attributes, "card" } } } rsx! { // 扩展了全局属性后,元素上能用的属性这里都能用 Card { width: "10px", height: "10px", color: "red", } };如果需要同时获得全局属性 + 某个具体元素的专属属性,可以多次使用extends:
use dioxus::prelude::*; #[derive(Props, PartialEq, Clone)] struct ButtonProps { #[props(extends = GlobalAttributes, extends = button)] attributes: Vec<Attribute>, } #[component] fn Button(props: ButtonProps) -> Element { rsx! { button { ..props.attributes, "button" } } } rsx! { Button { // 一个全局属性 width: "10px", // 一个 button 专属属性 disabled: true, } };使用注意:从多个元素扩展时,要求这些元素之间不存在冲突的属性——如果两个扩展源定义了同名但不同类型的属性,宏生成的 builder 方法会发生歧义。
这一特性在示例仓库中同样有真实落地:09-reference/spread.rs 用extends = GlobalAttributes让SpreadableComponent支持任意全局属性(包括"data-custom-attribute"这类自定义 data 属性),文档注释还点明其典型价值——让封装了<a>的Link组件能透传下层链接元素的所有属性;09-reference/custom_element.rs 则对自定义 Web Component 同时扩展了GlobalAttributes与analyticsPanel。
实现上,extends会为扩展字段自动填入Default::default()(即空Vec)作为默认值(props/mod.rs 中 extends 字段的默认值处理),并在生成的 builder 上实现HasAttributestrait 与一个名为XxxSpreadTarget的"标记超 trait"(extends_impl 实现),从而把扩展元素的每个属性方法都暴露给 builder。底层 RSX 在收集这些属性后,最终通过Vec<Attribute>与元素上的..props.attributes展开语法接合(属性类型由dioxus_core::Attribute承载,参见 properties.rs 中 push_attribute 的签名)。
小结
#[derive(Props)]看似只是"给结构体加个属性",实际做的工作远超声明本身:
- 签名即文档:每个字段 +
///注释自动构成组件的属性集与 IDE 补全内容,并把"必填/可选"固化进类型系统,漏填字段会得到编译期的deprecated提示; - 修饰符表达语义:
default兜底、!optional强制必填、into自动转换、extends透传元素属性,四个公开修饰符覆盖了绝大多数组件封装需求; - 字段类型决定自动行为:
Option<T>自动可选、String自动接受格式化字符串、ReadSignal<T>自动包装并实现响应式、children自动承载子内容; - 响应式与性能由 memoize 支撑:props 参与
PartialEq比较决定是否跳过重跑;信号与事件处理器字段被就地更新,让子组件内部的钩子与 future 始终引用最新值。
对绝大多数组件而言,直接使用#[component]宏最省心(它内部走的就是同一套 props 逻辑,参见 lib.rs 中宏的注册与文档绑定 与 component 宏文档);当需要深度定制 props 的构造、比较与属性透传行为时,再回到本文介绍的显式#[derive(Props)]方式即可。
【免费下载链接】dioxusFullstack app framework for web, desktop, and mobile.项目地址: https://gitcode.com/GitHub_Trending/di/dioxus
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考