看板核心:状态与交互
实战:任务管理应用 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
实现看板的核心交互:新建任务、编辑标题、切换状态(在三列间移动)、删除、按优先级排序。这一节的验收标准是「不用刷新,所有操作即时生效且可撤销」。
问题场景
骨架搭好了,开始加交互。第一个坑:任务卡片上的「移到进行中」按钮要改 store,卡片是纯展示组件还是自己拿 store?第二个坑:删除要二次确认,确认弹窗放哪一层?第三个坑:快速连点「移列」,状态跳错列——因为读的是渲染时的旧快照。
核心解释
卡片保持纯展示:TaskCard 只接收 task prop,发出意图事件(move、edit、remove),改数据统一由 BoardView(或 store action)处理。这是 V04 的单向数据流原则在项目里的落地。
<!-- components/TaskCard.vue -->
<script setup>
const props = defineProps({ task: { type: Object, required: true } });
const emit = defineEmits(["move", "remove", "edit"]);
const nextStatus = {
todo: "doing",
doing: "done",
done: "todo",
};
</script>
<template>
<article class="card">
<header>
<h4>{{ props.task.title }}</h4>
<span class="priority" :data-level="props.task.priority">
{{ props.task.priority }}
</span>
</header>
<footer>
<button @click="emit('move', props.task.id, nextStatus[props.task.status])">
移到{{ nextStatus[props.task.status] === "doing" ? "进行中" : nextStatus[props.task.status] === "done" ? "已完成" : "待办" }}
</button>
<button @click="emit('edit', props.task.id)">编辑</button>
<button @click="emit('remove', props.task.id)">删除</button>
</footer>
</article>
</template>
store 的 action 负责改数据,组件只调用:
// stores/tasks.js(在 V18 第一节基础上追加)
function move(id, status) {
const task = tasks.value.find((t) => t.id === id);
if (task) task.status = status;
}
function update(id, patch) {
const task = tasks.value.find((t) => t.id === id);
if (task) Object.assign(task, patch);
}
function remove(id) {
tasks.value = tasks.value.filter((t) => t.id !== id);
}
function reorderByPriority() {
tasks.value = [...tasks.value].sort((a, b) => {
const rank = { high: 0, mid: 1, low: 2 };
return rank[a.priority] - rank[b.priority];
});
}
为什么读旧快照会出错:如果在卡片里写 const task = props.task 并缓存 nextStatus[task.status],快速连点时每次事件都用渲染时的 props.task——Vue 的 props 是响应式的,下一次渲染会更新,但同一帧内的多次点击读到的可能都是旧值。正确做法是「事件发出 id,由 store 用最新状态计算」,或事件发出「目标状态」由 store 幂等处理。上面的实现把 nextStatus 的计算放在模板里(每次渲染重算),并让 store 的 move 幂等——连点两次最多移到目标列,不会跳错。
删除确认:用 confirm() 最简单;要自定义弹窗就放 BoardView 层,用 pendingDelete ref 记录待删 id,确认后调 store。卡片不持有弹窗状态。
示例拆解
BoardView 接住所有事件,负责跨组件的交互(弹窗、提示):
<script setup>
import { ref } from "vue";
import { useTaskStore } from "../stores/tasks";
import BoardColumn from "../components/BoardColumn.vue";
import TaskDialog from "../components/TaskDialog.vue";
const store = useTaskStore();
const dialogOpen = ref(false);
const editingId = ref(null);
function openCreate() {
editingId.value = null;
dialogOpen.value = true;
}
function openEdit(id) {
editingId.value = id;
dialogOpen.value = true;
}
function saveTask(payload) {
if (editingId.value) store.update(editingId.value, payload);
else store.add(payload.title);
dialogOpen.value = false;
}
function removeTask(id) {
if (confirm("删除这条任务?")) store.remove(id);
}
</script>
<template>
<main class="board">
<div class="toolbar">
<button @click="openCreate">新建任务</button>
<button @click="store.reorderByPriority()">按优先级排序</button>
</div>
<BoardColumn
title="待办"
status="todo"
:tasks="store.byStatus.todo"
@move="store.move"
@edit="openEdit"
@remove="removeTask"
/>
<!-- doing / done 两列同理 -->
<TaskDialog
v-model="dialogOpen"
:task="editingId ? store.tasks.find((t) => t.id === editingId) : null"
@save="saveTask"
/>
</main>
</template>
注意 @move="store.move":事件参数 (id, status) 直接透传给 action,不需要中间函数。Vue 的事件绑定天然支持这种透传,比 React 少一层闭包。
动手任务
- 实现卡片的「移列 / 编辑 / 删除」三个交互,验收:操作即时生效、刷新前数据正确。
- 给任务加优先级设置(新建时可选),实现按优先级排序按钮。
- 实现「编辑」弹窗:复用同一个 TaskDialog 处理新建与编辑(
editingId为空是新建)。
常见错误
- 卡片自己拿 store 改数据:纯展示组件被绑死,无法复用、难以测试。事件上抛。
- 用 index 标识任务:增删后错位(V07)。用 uuid。
- 同一帧连点读旧值:把「目标状态」算在事件里而不是渲染缓存里,或让 store 幂等。
- 弹窗状态散落在卡片里:每条卡片一个弹窗实例,内存和层级都乱。弹窗提到页面层。
检查与复盘
- 能说清卡片为什么保持纯展示
- 三个交互全部生效且无错位
- 能解释「读旧快照」问题的成因与两种解法
参考资料
- Vue 官方文档「组件事件」:事件参数透传
- Pinia 官方文档:action 的组织方式
读完这一课了?
标记完成后,工作台会安排接下来的学习。