跳转到内容

声明式语法

Ratatui Kit 的日常写法由几组宏支撑:

  • element!:声明本帧 UI 树。
  • #[component]:把函数变成持久 Component
  • #[derive(Props)]:让 props 能进入运行时类型擦除系统。
  • #[with_layout_style]:给组件 props 注入布局字段。
  • routes!:声明 Router 路由表。

这页是语法参考。想先看它在运行时如何保留状态,读 组件模型;想看 if / for / match 的真实例子,读 控制流语法

最常见的形式是:

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

圆括号里是 props,花括号里是 children。没有 props 时可以省略圆括号;没有 children 时可以省略花括号。

字段值会统一走 .into(),所以很多 props 能接收比字段类型更轻的写法:

Border(top_title: Line::from("title")) // Option<Line> 字段接收裸 Line
Text(text: "ready")                    // TextParagraph 字段接收 &str

element! 会用 Default::default() 补齐未传字段。这意味着用字段语法构造的自定义 props 需要实现 Default

如果 props 无法提供合理默认值,可以先在普通 Rust 代码里构造完整 props,再用 ..props 整体传入:

let queue_props = DeployQueueProps { state: list_state };

element!(
    DeployQueue(..queue_props)
)

..rest 必须放在 props 列表最后。使用 rest props 时,宏不会再追加 ..Default::default()

children 里可以直接写 ifif letformatch

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

        for item in items.iter() {
            Text(key: item.id, text: item.title.clone())
        }

        match mode {
            Mode::Compact => { Text(text: "compact") }
            Mode::Detail => { Border { Text(text: "detail") } }
        }
    }
)

不同分支可以返回不同组件类型,不需要手写 .into_any()。这是声明层控制流;不要把 hook 调用放进这些分支里。Hook 调用顺序属于组件实例,必须每帧稳定。

复杂动态表达式仍然可以用 { expr } 嵌入:

element!(
    View {
        { maybe_banner.map(|text| element!(Text(text: text))) }
        { rows.into_iter().map(|row| element!(Text(key: row.id, text: row.title))) }
    }
)

优先使用一等控制流;只有当表达式已经天然返回 OptionVec、iterator 或 element 时,再用 { expr }

key: 是保留字段,不会进入 props。它只影响 element 身份:

for task in tasks.iter() {
    TodoRow(key: task.id, task: task.clone())
}

运行时按 ElementKey + TypeId 复用上一帧组件实例。列表会插入、删除或重排时,用业务 id;只有静态数组或顺序永远不变的小列表,才适合用 index。

routes! 里不能给路由组件写 key:。路由身份由 path 决定,宏会明确拒绝这种写法。

函数组件用 #[component]

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

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

宏只识别这些参数名:

参数名作用
hooks / _hooks接收 Hooks,可以按值或引用
props / _props接收 props 引用,类型必须是引用

其它参数名会报错。这不是风格限制,而是宏靠参数名决定要把什么传给生成的 implementation

函数组件是透明布局组件:它不占独立布局节点,而是把返回的根 element 交给运行时。布局属性应该写在返回的根元素上:

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

如果你希望父级写 Panel(width: ...) 并让它自己占位,就不要把它做成无 props 的透明包装;改成带 props 并把布局字段显式传给内部根元素,或者手写 Component

自定义 props 需要实现 Props

#[derive(Default, Props)]
struct PanelProps {
    title: String,
}

不能 derive(Default) 时,手写一个语义中性的默认值:

#[derive(Props)]
struct PanelProps {
    title: String,
    level: usize,
}

impl Default for PanelProps {
    fn default() -> Self {
        Self {
            title: String::new(),
            level: 1,
        }
    }
}

对于没有合理默认值的字段,例如 State<ListState>,优先在调用点构造完整 props,然后用 Component(..props) 传入。不要为了满足 Default 伪造无效状态句柄。

可选字段直接用 Option<T>。因为字段值会 .into(),下面三种写法都成立:

Border(top_title: Line::from("title"))
Border(top_title: Some(Line::from("title")))
Border(top_title: None)

ratatui 0.30 的 Block<'static> 可以直接作为 props 字段,例如 block: Option<Block<'static>>。不要再引入 Send/Sync 包装;当前运行时是单线程渲染,框架层也不再强制 props 必须 Send + Sync

无 props 的手写组件用 NoProps

struct Spinner;

impl Component for Spinner {
    type Props<'a> = NoProps;

    fn new(_props: &Self::Props<'_>) -> Self {
        Self
    }
}

函数组件通常不需要直接写 NoProps#[component] 会生成对应的 props 类型。只有手写 Component、adapter 或测试 helper 时,才需要显式选择 NoProps

运行时内部会把不同组件的 props 装进 AnyProps。这是类型擦除容器,正确性依赖协调阶段已经确认组件类型和 props 类型匹配。应用代码不应该直接构造或 downcast AnyProps;写组件时只暴露具体的 Props 类型,让 element! 和运行时处理擦除。

内置组件的 on_changeon_selecton_close 这类回调用 Handler<'a, T, V = ()> 表达。调用方通常不需要手写 Handler::from,因为 element! 会对字段值调用 .into()

let mut selected = hooks.use_state(String::new);

element!(
    Select(
        items: actions,
        on_select: move |action| selected.set(action),
    )
)

自定义组件如果要暴露回调,也优先用同一个类型:

#[derive(Props)]
pub struct CommandProps {
    pub on_submit: Handler<'static, String, bool>,
}

impl Default for CommandProps {
    fn default() -> Self {
        Self {
            on_submit: Handler::default(),
        }
    }
}

Handler::default() 是空实现,返回值类型 V 需要实现 Default。组件内部如果要把回调移进事件闭包,先用 take() 取出当前 handler;需要区分用户有没有传回调时,用 is_default()

组件想支持 widthheightmarginoffsetgapflex_directionjustify_content,给 props 加 #[with_layout_style]

#[with_layout_style]
#[derive(Default, Props)]
pub struct ViewProps<'a> {
    pub children: Vec<AnyElement<'a>>,
}

也可以只注入部分布局字段:

#[with_layout_style(margin, offset, width, height)]
#[derive(Props)]
pub struct VirtualListProps<W> {
    // ...
}

#[with_layout_style] 只能用于具名字段结构体。它会生成 layout_style() 方法,手写组件需要在 update 里调用:

fn update(&mut self, props: &mut Self::Props<'_>, _hooks: Hooks, updater: &mut ComponentUpdater) {
    updater.set_layout_style(props.layout_style());
}

element! 可以直接桥接 ratatui widget:

element!(
    Border {
        widget(paragraph)
        stateful(list, list_state)
    }
)

widget(expr) 只接收一个表达式。stateful(widget, state) 只接收两个参数:widget 和 state。旧的 $ / #(...) adapter 写法已经移除。

适配器适合简单桥接:

  • 原生 ParagraphLineBlock 等无状态 widget。
  • 原生 ListTable 等需要 ratatui state 的 widget。

如果你需要在组件内部构造复杂 widget、持有 props、接事件或重写布局,就手写 Component。完整示例见 原生 Widget 桥接

routes! 复用 element! 的 props 头部语法:

let routes = routes! {
    "/" => AppShell(title: "Workspace") {
        "/" => HomePage,
        "/projects/:slug" => ProjectPage,
    },
};

圆括号 () 是路由组件 props;花括号 {} 是子路由。经 routes! 传入的 props 会进入 'static 路由表,不能借用栈上临时值。

动态数据不要塞进路由 props:

  • 路径里的动态段用 use_params()
  • 导航时的轻量上下文用 push_with_state / try_use_route_state::<T>()
  • 跨页面业务状态用 Atom 或外层 Provider。

更多路由边界见 路由