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)