6.2 自定义组件
> **本节目标**:掌握用 `#[component]` 宏创建自定义组件,封装可复用的 UI 单元。
6.2.1 自定义组件的价值
自定义组件是大型应用的基础:
- **复用性**:一次编写,多处使用 - **封装性**:隐藏内部实现,暴露清晰接口 - **可维护性**:组件独立,修改不影响其他部分 - **可测试性**:组件可单独测试
6.2.2 创建自定义组件
步骤一:创建 `.rml` 模板
<!-- components/counter.rml -->
<div class="counter">
<button on-click={decrement} disabled={count <= min}>-</button>
<span class="counter-value">{count}</span>
<button on-click={increment} disabled={count >= max}>+</button>
</div>
步骤二:创建 `.rml.rs` 逻辑
// components/counter.rml.rs
use rml::prelude::*;
#[derive(IModel)]
#[component]
pub struct Counter {
pub count: i32,
pub min: i32,
pub max: i32,
}
impl Counter {
pub fn new() -> Self {
Self {
count: 0,
min: 0,
max: 100,
}
}
#[command]
pub fn increment(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
if self.count < self.max {
self.count += 1;
cx.notify();
}
}
#[command]
pub fn decrement(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
if self.count > self.min {
self.count -= 1;
cx.notify();
}
}
}
步骤三:使用组件
<!-- views/my_view.rml -->
<div>
<h1>计数器示例</h1>
<Counter count={initial_count} min={0} max={10} />
</div>
// views/my_view.rml.rs
use rml::prelude::*;
use crate::components::counter::Counter;
#[derive(IModel)]
#[component]
pub struct MyView {
pub initial_count: i32,
}
impl MyView {
pub fn new() -> Self {
Self { initial_count: 5 }
}
}
6.2.3 `#[component]` 宏
`#[component]` 宏标记一个结构体为 RML 组件:
#[derive(IModel)]
#[component]
pub struct Counter {
pub count: i32,
pub min: i32,
pub max: i32,
}
与 `#[contribute]` 叠加
案例组件等需要向贡献点注册元数据时,**`#[contribute]` 写在 `#[component]` 之上**:
#[contribute(
host = "demo.shell",
id = "components.button",
name = "case.button.title",
kind = "case",
group = "components",
order = 11,
)]
#[component]
#[derive(Default)]
pub struct ButtonCase {
pub button_clicks: i32,
}
`#[contribute]` 由 `build.rs` 扫描并随 `RmlApplication` 自动注册;`#[component]` 生成 RML 模板绑定。
应用启动(`#[rml::main]` 已注入 `embed_contributions!`):
纯元数据(无 struct、如分类节点)仍用 `contributions::register_case_categories` 等程序化 API。详见 [贡献点架构](../09-architecture/contribution-system.md)。
`#[component]` 的参数
| 参数 | 类型 | 说明 | | ----------- | ------ | ------------------- | | `template` | 字符串 | 指定 `.rml` 模板文件路径 | | `tag` | 字符串 | 自定义标签名(默认为结构体名) | | `export` | 布尔 | 是否导出为全局标签 |
`template` 路径
路径相对于项目根目录的 `src` 目录:
// src/components/counter.rml
#[component]
// src/views/user/profile.rml
#[component]
6.2.4 组件的属性
组件的属性就是结构体的 `pub` 字段:
#[derive(IModel)]
#[component]
pub struct Button {
pub text: SharedString, // 输入属性
pub variant: SharedString, // 输入属性
pub disabled: bool, // 输入属性
pub size: SharedString, // 输入属性
}
在 `.rml` 中传递属性
<Button
text="提交"
variant="primary"
disabled={is_loading}
size="large"
/>
属性的类型
| 类型 | 示例 | | --------------- | ------------------------------- | | `SharedString` | `text="提交"` 或 `text={value}` | | `i32`、`f64` 等 | `count={10}` 或 `count={value}` | | `bool` | `disabled={true}` 或 `disabled` | | 枚举 | `variant="primary"` | | `Vec<T>` | `items={my_items}` | | `Option<T>` | `title={some_title}` |
布尔属性的简写
<!-- 完整写法 -->
<Button disabled={true}>提交</Button>
<!-- 简写 -->
<Button disabled>提交</Button>
6.2.5 组件的事件
组件可以通过 `Option<Arc<dyn Fn(...)>>` 字段声明事件回调:
#[derive(IModel)]
#[component]
pub struct Counter {
pub count: i32,
pub min: i32,
pub max: i32,
pub on_change: Option<Arc<dyn Fn(i32)>>, // 值变化事件
pub on_reach_min: Option<Arc<dyn Fn()>>, // 达到最小值事件
pub on_reach_max: Option<Arc<dyn Fn()>>, // 达到最大值事件
}
触发事件
impl Counter {
#[command]
pub fn increment(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
if self.count < self.max {
self.count += 1;
cx.notify();
// 触发 on_change 事件
if let Some(callback) = &self.on_change {
callback(self.count);
}
// 触发 on_reach_max 事件
if self.count == self.max {
if let Some(callback) = &self.on_reach_max {
callback();
}
}
}
}
}
监听事件
<Counter
count={initial_count}
min={0}
max={10}
on_change={handle_count_change}
on_reach_max={handle_reach_max}
/>
#[command]
pub fn handle_count_change(&mut self, new_count: i32, cx: &mut Context<Self>) {
println!("计数变化: {}", new_count);
}
#[command]
pub fn handle_reach_max(&mut self, cx: &mut Context<Self>) {
println!("达到最大值!");
}
详见 [5.4 自定义事件](../05-events/custom-events.md)。
6.2.6 组件的双向绑定
组件可以通过 `value={field}` / `checked={field}` 实现双向绑定:
#[derive(IModel)]
#[component]
pub struct Counter {
pub count: i32,
pub min: i32,
pub max: i32,
}
在父视图中双向绑定
<Counter value={my_count} min={0} max={10} />
实现双向绑定
组件需要实现 `TwoWayBinding` trait:
impl TwoWayBinding for Counter {
type Value = i32;
fn get_value(&self) -> Self::Value {
self.count
}
fn set_value(&mut self, value: Self::Value, cx: &mut Context<Self>) {
self.count = value;
cx.notify();
}
}
6.2.7 组件的插槽
组件可以通过 `<slot>` 占位符接收父视图传递的内容。使用插槽需要**两处声明**:
① 声明插槽契约
在 `#[component]` 宏参数中声明组件接受的插槽列表:
// components/card.rml.rs
#[derive(IModel)]
#[component(slots = ["header", "default", "footer"])]
pub struct Card {
pub title: SharedString,
}
- `slots` 为字符串数组字面量 - 保留名 `"default"` 对应模板内无 `name` 属性的 `<slot />` - 不写 `slots` 参数 → 组件不接受任何插槽
② 声明插槽位置
在 `.rml` 模板内用 `<slot>` 占位符声明渲染位置:
<!-- components/card.rml -->
<component>
<div class="card">
<div class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</component>
> **注意**:`<slot>` 不支持默认内容。`<slot>默认文本</slot>` 中的子节点会被忽略,未填充的插槽渲染为空。
使用插槽
<Card title="用户信息">
<template slot="header">
<h2>用户信息</h2>
</template>
<!-- 默认插槽:无 slot 属性的裸子节点(仅当声明了 "default") -->
<p>姓名: {user.name}</p>
<p>邮箱: {user.email}</p>
<template slot="footer">
<button on-click={edit_user}>编辑</button>
</template>
</Card>
- `<template slot="name">` 填充具名插槽,`name` 必须在 `slots` 声明中 - 无 `slot` 属性的裸子节点填充 `default` 插槽 - 未填充的插槽渲染为空
详见 [6.3 插槽与内容分发](./slots.md)。
6.2.8 组件的生命周期
组件支持与视图相同的生命周期回调:
#[derive(IModel)]
#[component]
pub struct DataLoader {
pub data: Vec<Item>,
pub is_loading: bool,
}
impl DataLoader {
#[on_loaded]
pub fn on_loaded(&mut self, cx: &mut Context<Self>) {
self.load_data(cx);
}
#[on_unloaded]
pub fn on_unloaded(&mut self, _cx: &mut Context<Self>) {
// 清理资源
}
fn load_data(&mut self, cx: &mut Context<Self>) {
self.is_loading = true;
cx.notify();
cx.spawn(|this, mut cx| async move {
let data = fetch_data().await;
let _ = this.update(&mut cx, |this, cx| {
this.data = data;
this.is_loading = false;
cx.notify();
});
}).detach();
}
}
详见 [第 8 章 生命周期管理](../08-lifecycle/INDEX.md)。
6.2.9 组件的嵌套
组件可以嵌套使用:
<!-- components/user_card.rml -->
<div class="user-card">
<Avatar src={user.avatar} />
<div class="user-info">
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
<Button on-click={edit_user} variant="ghost">编辑</Button>
</div>
组件树
UserCard
├── Avatar
├── div
│ ├── h3
│ └── p
└── Button
6.2.10 完整示例:搜索框组件
// components/search_box.rml.rs
use std::time::Duration;
use rml::prelude::*;
#[derive(Clone)]
pub struct SearchEvent {
pub query: SharedString,
}
#[derive(IModel)]
#[component]
pub struct SearchBox {
pub query: SharedString,
pub placeholder: SharedString,
pub is_searching: bool,
pub on_search: Option<Arc<dyn Fn(&SearchEvent)>>,
pub on_clear: Option<Arc<dyn Fn()>>,
debounce_task: Option<Task<()>>,
}
impl SearchBox {
pub fn new() -> Self {
Self {
query: SharedString::default(),
placeholder: "搜索...".into(),
is_searching: false,
on_search: None,
on_clear: None,
debounce_task: None,
}
}
#[command]
pub fn on_input(&mut self, ev: &InputEvent, cx: &mut Context<Self>) {
self.query = ev.value.clone();
cx.notify();
// 取消之前的防抖任务
if let Some(task) = self.debounce_task.take() {
task.abort();
}
if self.query.is_empty() {
return;
}
// 启动防抖搜索
let query = self.query.clone();
self.debounce_task = Some(cx.spawn(|this, mut cx| async move {
cx.background_executor()
.timer(Duration::from_millis(300))
.await;
let _ = this.update(&mut cx, |this, _cx| {
if let Some(callback) = &this.on_search {
callback(&SearchEvent { query: query.clone() });
}
});
}));
}
#[command]
pub fn on_clear_click(&mut self, _: &ClickEvent, cx: &mut Context<Self>) {
if let Some(task) = self.debounce_task.take() {
task.abort();
}
self.query = SharedString::default();
cx.notify();
if let Some(callback) = &self.on_clear {
callback();
}
}
}
<!-- components/search_box.rml -->
<div class="search-box">
<input
value={query}
oninput={on_input}
placeholder={placeholder}
class="search-input"
/>
<button
if={!query.is_empty()}
on-click={on_clear_click}
class="clear-btn"
>
✕
</button>
<div if={is_searching} class="loading">搜索中...</div>
</div>
使用搜索框
<!-- views/user_list.rml -->
<div class="user-list-view">
<h1>用户列表</h1>
<SearchBox
placeholder="搜索用户..."
on_search={handle_search}
on_clear={handle_clear}
/>
<ul>
<li each={user in filtered_users} key={user.id}>
{user.name} ({user.email})
</li>
</ul>
</div>
// views/user_list.rml.rs
use rml::prelude::*;
use crate::components::search_box::{SearchBox, SearchEvent};
#[derive(IModel)]
#[component]
pub struct UserListView {
pub users: Vec<User>,
pub search_query: SharedString,
pub filtered_users: Vec<User>,
}
impl UserListView {
pub fn new(users: Vec<User>) -> Self {
Self {
users: users.clone(),
search_query: SharedString::default(),
filtered_users: users,
}
}
#[command]
pub fn handle_search(&mut self, ev: &SearchEvent, cx: &mut Context<Self>) {
self.search_query = ev.query.clone();
self.filter_users(cx);
}
#[command]
pub fn handle_clear(&mut self, cx: &mut Context<Self>) {
self.search_query = SharedString::default();
self.filtered_users = self.users.clone();
cx.notify();
}
fn filter_users(&mut self, cx: &mut Context<Self>) {
let query = self.search_query.to_lowercase();
self.filtered_users = self
.users
.iter()
.filter(|u| {
u.name.to_lowercase().contains(&query)
|| u.email.to_lowercase().contains(&query)
})
.cloned()
.collect();
cx.notify();
}
}
6.2.11 组件设计的最佳实践
1. 单一职责
每个组件只做一件事:
// ✅ 单一职责
pub struct Avatar { ... } // 只显示头像
pub struct UserName { ... } // 只显示用户名
pub struct UserCard { ... } // 组合 Avatar 和 UserName
// ❌ 职责混乱
pub struct UserAvatarAndNameAndActions { ... }
2. 通过属性配置,而非继承
// ✅ 通过属性配置
#[component]
pub struct Button {
pub variant: SharedString, // primary, secondary, danger
pub size: SharedString, // small, medium, large
pub disabled: bool,
}
// ❌ 通过继承
pub struct PrimaryButton : Button { ... }
pub struct LargeButton : Button { ... }
3. 事件向上,属性向下
父视图 --属性--> 子组件
父视图 <--事件-- 子组件
// ✅ 属性向下,事件向上
pub struct SearchBox {
pub placeholder: SharedString, // 属性向下
pub on_search: Option<Arc<dyn Fn(&SearchEvent)>>, // 事件向上
}
// ❌ 子组件直接修改父视图状态
pub struct SearchBox {
parent: Entity<ParentView>, // 强耦合
}
4. 合理的默认值
impl Button {
pub fn new() -> Self {
Self {
text: SharedString::default(),
variant: "primary".into(), // 默认主要按钮
size: "medium".into(), // 默认中等大小
disabled: false,
}
}
}
5. 清晰的接口文档
/// 按钮组件
///
/// # 属性
/// - `text`: 按钮文本
/// - `variant`: 样式变体("primary" | "secondary" | "danger" | "ghost")
/// - `size`: 尺寸("small" | "medium" | "large")
/// - `disabled`: 是否禁用
///
/// # 事件
/// - `on_click`: 点击事件
///
/// # 示例
/// ```html
/// <Button text="提交" variant="primary" size="large" on_click={submit} />
/// ```
#[derive(IModel)]
#[component]
pub struct Button {
pub text: SharedString,
pub variant: SharedString,
pub size: SharedString,
pub disabled: bool,
pub on_click: Option<Arc<dyn Fn(&ClickEvent)>>,
}
6.2.12 小结
自定义组件是 RML 的核心复用机制:
- **创建**:`#[component]` + `.rml` 模板 - **属性**:结构体的 `pub` 字段 - **事件**:`Option<Arc<dyn Fn(...)>>` 字段 - **双向绑定**:实现 `TwoWayBinding` trait - **插槽**:`<slot>` 接收父视图内容 - **生命周期**:`#[on_loaded]`、`#[on_unloaded]`
掌握自定义组件,你就能构建可复用、可组合的 UI 库。
下一节 → [6.3 插槽与内容分发](./slots.md)