跳转到内容

组件模型

Ratatui Kit 的组件模型分成两层:Element 是每帧重新创建的声明,Component 是运行时复用的持久实例。写应用时你大多在 element! 里声明 UI;框架在 update 阶段把这些声明协调成组件树,并保留 hooks、子树、布局和生命周期。

如果你来自 React,可以把它理解成类似 JSX element 和 component instance 的关系。不同的是这里的输出不是 DOM,而是 ratatui buffer。

Element 每帧重建;Component 实例靠协调复用,状态真正挂在实例和 hooks 上。

一个函数组件每帧都会重新执行:

#[component]
fn Counter(mut hooks: Hooks) -> impl Into<AnyElement<'static>> {
    let count = hooks.use_state(|| 0);

    element!(
        Text(text: format!("count: {}", count.get()))
    )
}

这里的 element!(Text(...)) 是声明。真正保存 count 的不是这棵临时声明树,而是运行时已经实例化的 Counter 节点和它的 hook 列表。

Element<T> 可以理解成:

Element<T> {
    key,
    props,
}

它很轻,每帧都可以重新创建。element! 宏负责把声明式语法变成 element 树:

element!(
    Border(top_title: Line::from("dashboard").centered()) {
        Text(text: "ready")
        Counter
    }
)

AnyElement 是类型擦除版本,用来把不同组件类型放进同一个 children 列表。完整宏语法见 声明式语法

Component 是持久对象。一个实例持有:

  • 组件自身状态。
  • hook 列表。
  • 子组件树。
  • 布局样式。
  • 生命周期回调。

函数组件由 #[component] 宏生成 Component 实现。你写的是函数,运行时看到的是一个透明包装组件:每帧 update 时执行函数体,拿到返回的根 element,再协调它的子树。

手写组件直接实现 Component,通常用于桥接原生 ratatui widget 或自定义布局:

impl Component for DeployQueue {
    type Props<'a> = DeployQueueProps;

    fn update(
        &mut self,
        props: &mut Self::Props<'_>,
        _hooks: Hooks,
        _updater: &mut ComponentUpdater,
    ) {
        self.state = props.state;
    }

    fn draw(&mut self, drawer: &mut ComponentDrawer<'_, '_>) {
        drawer.render_stateful_widget(
            list,
            drawer.area,
            &mut self.state.write_no_update(),
        );
    }
}

完整示例见 原生 Widget 桥接

运行时协调子节点时,使用 ElementKey + TypeId 匹配上一帧节点:

本帧声明上一帧实例结果
同 key、同组件类型命中复用实例,保留 hooks 和子树
同 key、不同组件类型类型变化重建实例
key 变化身份变化重建实例
旧 key 消失无声明卸载实例,运行 on_drop

这就是列表渲染要给稳定 key 的原因:

for item in items {
    Row(key: item.id) {
        Text(text: item.title)
    }
}

不要用会增删、重排的数组下标当 key。插入一行后,后面所有下标都会变,旧状态就可能被复用到错误的行上。

静态小数组可以用下标;例如 控制流语法 里的 ROWS 是固定数组,所以 key: index 是稳定的。

element! 支持直接写 ifif letformatch

element!(
    View {
        if loading.get() {
            Text(text: "loading")
        } else {
            Border {
                Text(text: "ready")
            }
        }
    }
)

这些控制流只改变本帧产生的 element 声明。运行时仍然用 key 和类型决定哪些实例能复用。

这也解释了一个重要边界:你可以在 element! 里放条件渲染,但不要把 hook 调用放进条件分支。Hook 顺序属于组件实例,必须每帧稳定;渲染分支属于声明层,可以每帧变化。更多规则见 Hooks

Props 是传给组件声明的数据。element! 会根据 props 字段构造结构体,并用 Default::default() 补齐未传字段:

element!(
    SearchInput(
        value: query.read().clone(),
        placeholder: "search".to_string(),
        on_change: move |next| query.set(next),
    )
)

自定义 props 用 #[derive(Props)]

#[derive(Props)]
struct DeployQueueProps {
    state: State<ListState>,
}

如果 props 里有不能自动 Default 的字段,就手写一个语义中性的 impl Default。这不是装饰性要求:element! 依赖默认值补齐没传的字段。

可选字段直接用 Option<T>。宏会对字段值调用 .into(),所以 top_title: Line::from("title")top_title: Some(line)top_title: None 都能工作。

Ratatui Kit 的布局字段来自 LayoutStyle

  • width
  • height
  • flex_direction
  • justify_content
  • gap
  • margin
  • offset

内置组件会在 props 上暴露这些字段;自定义组件如果也想支持布局属性,需要给 props 加 #[with_layout_style]

函数组件有一个特殊点:#[component] 生成的是透明布局组件。它不占独立布局节点,而是继承返回的第一个子节点的布局样式。

所以布局属性要写在函数组件返回的根元素上:

#[component]
fn Panel(_hooks: Hooks) -> impl Into<AnyElement<'static>> {
    element!(
        Border(width: Constraint::Length(40)) {
            Text(text: "content")
        }
    )
}

不要指望在父级直接给 Panel(width: ...) 生效,除非你把 Panel 写成带 props 的手写组件或显式把布局字段传给内部根元素。

默认组件布局是 flex 切分子区域。需要非 flex 行为时,组件可以重写 calc_children_areas

  • ScrollView 要把内容区域和滚动 offset 结合起来。
  • Modal 要把弹层定位在屏幕中央,并把 input layer 注入子树。
  • 自定义画布、表格、分栏工作台也可能需要自己的区域计算。

calc_children_areas 必须返回和 children 数量一致的区域列表。少一个区域会导致子节点不被绘制;多一个区域没有对应子节点。调试构建会尽量暴露这类契约问题。