2.6 完整示例:Todo 应用
> **本节目标**:用一个完整的 Todo 应用串起本章所有语法点——标签映射、属性系统、指令系统、插值表达式。
2.6.1 应用需求
实现一个待办清单应用,功能包括:
- 输入新任务并添加 - 显示任务列表,支持勾选完成 - 显示统计信息(总计、已完成、待办) - 删除任务 - 空状态提示 - 回车键快速添加
2.6.2 完整的 `.rml` 文件
<!-- todo.rml —— 完整的待办清单应用 -->
<div class="todo-app">
<!-- 应用标题 -->
<h1 class="app-title">📋 待办清单</h1>
<!-- 输入区域 -->
<div class="input-area">
<input
class="todo-input"
type="text"
placeholder="输入新任务..."
value={new_todo_text}
onkeydown="on_enter_key"
/>
<button class="btn-add" on-click={add_todo}>添加</button>
</div>
<!-- 统计信息 -->
<div class="stats">
<span>总计: {todos.len()}</span>
<span>已完成: {completed_count}</span>
<span>待办: {pending_count}</span>
</div>
<!-- 任务列表 -->
<ul class="todo-list">
<li each={todo in todos} key={todo.id} class="todo-item">
<input
type="checkbox"
checked={todo.done}
onchange={toggle_todo, {todo.id}}
/>
<span class={todo.done ? "done" : ""}>
{todo.text}
</span>
<button class="btn-delete" on-click={delete_todo, {todo.id}}>
✕
</button>
</li>
</ul>
<!-- 空状态 -->
<div if={todos.is_empty()}>
<p class="empty-hint">🎉 暂无任务,添加一条吧!</p>
</div>
</div>
2.6.3 语法点逐行解析
标签映射
<h1> <!-- → div().text_size(28.0).child(Label::new(...)) -->
<div> <!-- → div() -->
<input> <!-- → gpui_component::Input -->
<button> <!-- → gpui_component::Button -->
<ul> <!-- → div().flex().flex_col() -->
<li> <!-- → div() -->
<span> <!-- → div().inline() -->
<p> <!-- → div().child(Label::new(...)) -->
标准属性
class="todo-app" <!-- 样式类名 -->
type="text" <!-- 输入类型 -->
placeholder="输入新任务..." <!-- 占位符 -->
type="checkbox" <!-- 复选框类型 -->
数据绑定属性
value={new_todo_text} <!-- 双向绑定到 new_todo_text 字段 -->
checked={todo.done} <!-- 单向绑定到 todo.done -->
class={todo.done ? "done" : ""} <!-- 动态 class -->
事件绑定属性
on-click={add_todo} <!-- 无参数命令 -->
on-click={toggle_todo, {todo.id}} <!-- 带参数命令 -->
on-click={delete_todo, {todo.id}} <!-- 带参数命令 -->
onkeydown="on_enter_key" <!-- 方法名绑定 -->
onchange={toggle_todo, {todo.id}} <!-- change 事件 -->
指令属性
if={todos.is_empty()} <!-- 条件渲染:空状态 -->
each={todo in todos} <!-- 列表渲染:遍历 todos -->
key={todo.id} <!-- 列表唯一标识 -->
插值表达式
{todos.len()} <!-- 字段方法调用 -->
{completed_count} <!-- 计算属性 -->
{pending_count} <!-- 计算属性 -->
{todo.text} <!-- 列表项字段 -->
{todo.id} <!-- 列表项字段(用于事件参数) -->
2.6.4 对应的 `.rml.rs` 文件
// todo.rml.rs
use rml::prelude::*;
#[derive(IModel)]
pub struct TodoItem {
pub id: u64,
pub text: SharedString,
pub done: bool,
}
#[derive(IModel)]
#[component]
pub struct TodoViewModel {
pub new_todo_text: SharedString,
pub todos: Vec<TodoItem>,
next_id: u64,
}
impl TodoViewModel {
pub fn new() -> Self {
Self {
new_todo_text: SharedString::default(),
todos: Vec::new(),
next_id: 1,
}
}
// 计算属性:自动追踪依赖,UI 自动更新
#[computed]
pub fn completed_count(&self) -> usize {
self.todos.iter().filter(|t| t.done).count()
}
#[computed]
pub fn pending_count(&self) -> usize {
self.todos.len() - self.completed_count()
}
// 命令方法:UI 可直接调用
#[command]
pub fn add_todo(&mut self, _: &ClickEvent, cx: &mut ViewContext<Self>) {
if self.new_todo_text.is_empty() {
return;
}
self.todos.push(TodoItem {
id: self.next_id,
text: self.new_todo_text.clone(),
done: false,
});
self.next_id += 1;
self.new_todo_text = SharedString::default();
cx.notify();
}
#[command]
pub fn toggle_todo(&mut self, id: u64, _: &ChangeEvent, cx: &mut ViewContext<Self>) {
if let Some(todo) = self.todos.iter_mut().find(|t| t.id == id) {
todo.done = !todo.done;
cx.notify();
}
}
#[command]
pub fn delete_todo(&mut self, id: u64, _: &ClickEvent, cx: &mut ViewContext<Self>) {
self.todos.retain(|t| t.id != id);
cx.notify();
}
// 键盘事件处理方法
pub fn on_enter_key(&mut self, ev: &KeyDownEvent, cx: &mut ViewContext<Self>) {
if ev.key == Key::Enter {
self.add_todo(&ClickEvent::default(), cx);
}
}
}
2.6.5 数据流分析
以"添加任务"为例,追踪完整的数据流:
1. 用户在输入框输入"买牛奶"
↓
2. value={new_todo_text} 双向绑定
↓
3. ViewModel.new_todo_text = "买牛奶"
↓
4. 用户按回车键
↓
5. onkeydown="on_enter_key" 触发
↓
6. on_enter_key 方法检查 Enter 键,调用 add_todo
↓
7. add_todo 命令:
- 检查 new_todo_text 非空
- 创建 TodoItem,加入 todos
- 清空 new_todo_text
- 调用 cx.notify()
↓
8. UI 重新渲染:
- {todos.len()} 更新为 1
- {completed_count} 更新为 0
- {pending_count} 更新为 1
- each={todo in todos} 渲染新列表项
- 输入框清空(model 双向绑定)
- if={todos.is_empty()} 不再渲染空状态
2.6.6 关键设计点
1. `next_id` 字段不需要 `pub`
pub struct TodoViewModel {
pub new_todo_text: SharedString, // pub:UI 需要绑定
pub todos: Vec<TodoItem>, // pub:UI 需要遍历
next_id: u64, // private:仅内部使用
}
不需要在 UI 中访问的字段可以保持 private,遵循最小暴露原则。
2. 计算属性自动缓存
#[computed]
pub fn completed_count(&self) -> usize {
self.todos.iter().filter(|t| t.done).count()
}
`#[computed]` 会自动追踪 `self.todos` 的变化,只有 `todos` 变化时才重新计算。UI 中的 `{completed_count}` 和 `{pending_count}` 共享同一个计算结果。
3. 事件参数的传递
<button on-click={delete_todo, {todo.id}}>
`{todo.id}` 在编译期被解析为表达式,运行时作为参数传递给 `delete_todo` 命令。
4. 双向绑定与命令的协作
<input value={new_todo_text} onkeydown="on_enter_key" />
`value={field}` 自动双向绑定处理输入值的同步,`onkeydown` 处理特殊按键(回车提交)。两者协作完成"输入 + 回车提交"的交互模式。
2.6.7 扩展练习
尝试为这个 Todo 应用添加以下功能,巩固本章所学:
1. **优先级**:为每个任务添加 `priority` 字段(high/medium/low),用不同颜色显示 2. **过滤**:添加三个按钮(全部/待办/已完成),切换显示哪些任务 3. **编辑**:双击任务进入编辑模式,修改任务文本 4. **批量操作**:添加"全部完成"、"清除已完成"按钮 5. **持久化**:在 `#[on_loaded]` 中加载本地存储,在 `#[on_unloaded]` 中保存
每个扩展练习都会用到本章的多个语法点,是巩固学习的好方法。
2.6.8 小结
这个 Todo 应用展示了 RML 的全部核心语法:
- **标签映射**:8 种 HTML 标签的使用 - **标准属性**:`class`、`type`、`placeholder` - **数据绑定**:`value={field}` 双向绑定、`checked`、动态 `class` - **事件绑定**:无参命令、带参命令、方法名绑定 - **指令**:`if`、`each`、`key` - **插值**:字段访问、方法调用、计算属性、三元表达式
掌握这个示例,你就掌握了 `.rml` 文件的全部表达能力。后续章节会在此基础上深入数据绑定、事件系统、组件封装等高级主题。
下一章 → [第 3 章 · 数据绑定系统](../03-binding/INDEX.md)