拆分实战:TodoItem 组件
组件基础:props、emits 与事件 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
把 props 和 emits 合起来用一遍:从单文件 Todo 应用出发,把列表项拆成独立组件,完成「展示、勾选、删除、编辑」四条交互链路,并保证父组件状态是唯一数据源。这一节也是本站练习 C62 的完整解法思路。
问题场景
单文件待办列表能跑了,但文件越写越长:列表项模板、编辑态、删除确认全挤在一起。你想拆出 TodoItem,拆的过程中发现三个新问题:编辑态放子组件还是父组件?删除确认弹窗谁控制?勾选后父组件怎么知道?这一节把这些问题逐个落地。
核心解释
先定组件边界。TodoItem 只负责「渲染一条待办 + 发出用户意图」,编辑态属于交互细节,放在子组件内部;「改数据」全部由父组件完成。于是子组件需要:
- props:
todo(当前项) - emits:
toggle(切换完成)、remove(删除)、update(保存新标题)
<!-- components/TodoItem.vue -->
<script setup>
import { nextTick, ref } from "vue";
const props = defineProps({
todo: { type: Object, required: true },
});
const emit = defineEmits(["toggle", "remove", "update"]);
const editing = ref(false);
const draft = ref("");
const inputRef = ref(null);
async function startEdit() {
editing.value = true;
draft.value = props.todo.title;
await nextTick(); // 等输入框渲染出来再聚焦
inputRef.value?.focus();
}
function save() {
const title = draft.value.trim();
if (title && title !== props.todo.title) {
emit("update", { id: props.todo.id, title });
}
editing.value = false;
}
function cancel() {
editing.value = false;
}
</script>
<template>
<li class="todo-item" :class="{ done: props.todo.done }">
<input
type="checkbox"
:checked="props.todo.done"
@change="emit('toggle', props.todo.id)"
/>
<template v-if="editing">
<input
ref="inputRef"
v-model="draft"
@keyup.enter="save"
@keyup.esc="cancel"
@blur="save"
/>
</template>
<span v-else class="title" @dblclick="startEdit">{{ props.todo.title }}</span>
<button @click="emit('remove', props.todo.id)">删除</button>
</li>
</template>
父组件持有唯一数据源,三个事件分别对应三种修改:
<script setup>
import { computed, ref } from "vue";
import TodoItem from "./components/TodoItem.vue";
const todos = ref([
{ id: 1, title: "读 V04", done: true },
{ id: 2, title: "写 TodoItem 练习", done: false },
]);
const remaining = computed(() => todos.value.filter((t) => !t.done).length);
function toggle(id) {
const t = todos.value.find((x) => x.id === id);
if (t) t.done = !t.done;
}
function remove(id) {
todos.value = todos.value.filter((t) => t.id !== id);
}
function update({ id, title }) {
const t = todos.value.find((x) => x.id === id);
if (t) t.title = title;
}
</script>
<template>
<section>
<p>还剩 {{ remaining }} 项</p>
<ul>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="toggle"
@remove="remove"
@update="update"
/>
</ul>
</section>
</template>
几个值得注意的点:
nextTick是 Vue 的「等 DOM 更新完成」工具。改editing后输入框还没渲染,直接focus()会拿到 null;await nextTick()之后才拿得到。- 编辑态
editing是子组件内部状态——它不影响业务数据,只影响交互呈现,放子组件合理;如果父组件需要「同时只允许编辑一条」,那它就该提升为父组件的状态。 @blur="save"和@keyup.enter="save"可能重复触发,但save里有title !== props.todo.title判断,第二次 emit 被跳过,天然幂等。v-model的 checkbox 写法这里用:checked+@change而不是v-model,因为勾选状态来自 props,不能直接双向写。
示例拆解
把「同时只允许编辑一条」做出来,体会状态提升:
<!-- 父组件新增 -->
<script setup>
import { ref } from "vue";
const editingId = ref(null);
</script>
<template>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
:editing="editingId === todo.id"
@start-edit="editingId = todo.id"
@end-edit="editingId = null"
@toggle="toggle"
@remove="remove"
@update="update"
/>
</template>
子组件删掉本地 editing,改为接收 editing prop,startEdit 改成 emit("start-edit", props.todo.id)。改动不大,但状态的归属变了:从「每条自己管自己」变成「父组件统一调度」。这就是 Vue 里状态提升的典型动作。
动手任务
- 完成上面的拆分,跑通勾选、删除、双击编辑、回车保存、Esc 取消五条链路。
- 加「全部完成 / 清除已完成」两个批量按钮,用 computed 和数组方法实现。
- 给删除加二次确认(子组件内
confirm()或父组件统一弹窗,二选一),说明你的选择理由。
常见错误
- 编辑态放错层级:把
draft提升到父组件,导致每敲一个字父组件重渲染整个列表。交互态留在子组件。 @blur和回车同时触发导致重复 emit:加幂等判断,或用一个标志位防重入。- 删除时用 index 做参数:列表变化后 index 失效,误删其他项。永远传 id。
- 忘记
nextTick就 focus:拿到 null,控制台报「Cannot read properties of null」。改完状态等一拍再操作 DOM。
检查与复盘
- 能独立拆出 TodoItem 并跑通四条交互
- 能判断一个状态该留在子组件还是提升到父组件
- 能解释 nextTick 在「改状态后操作 DOM」场景中的作用
参考资料
- Vue 官方文档「组件基础」:拆分与组合的完整示例
- Vue 官方文档「组件事件」:事件命名与载荷设计
- Vue 官方文档「nextTick」:DOM 更新时机
读完这一课了?
标记完成后,工作台会安排接下来的学习。