Skip to content

L05 · 条件渲染与事件:完成 / 删除 Todo ​

🎯 本节目标:实现 Todo 的完成切换和删除功能,掌握条件渲染和事件系统
📦 本节产出:可完成、可删除的 Todo 应用 + 删除动画
🔗 前置钩子:L04 的 v-for 列表渲染
🔗 后续钩子:L06 将用 v-model 实现内联编辑

1. 条件渲染:v-if vs v-show ​

1.1 v-if ​

vue
<template>
  <!-- v-if:条件为 false 时,元素完全不存在于 DOM -->
  <p v-if="todos.length === 0" class="empty">还没有任务,添加一条试试吧</p>

  <!-- v-else-if / v-else 必须紧跟 v-if -->
  <div v-if="status === 'loading'">加载中...</div>
  <div v-else-if="status === 'error'">出错了</div>
  <div v-else>数据加载完成</div>
</template>

1.2 v-show ​

vue
<template>
  <!-- v-show:条件为 false 时,元素仍然存在于 DOM,只是 display: none -->
  <p v-show="showHint" class="hint">提示:按 Enter 快速添加</p>
</template>

1.3 如何选择 ​

v-ifv-show
条件为 false 时DOM 中不存在DOM 中存在,display: none
切换开销高(创建/销毁组件)低(只改 CSS)
初始渲染条件为 false 时不渲染始终渲染
支持 <template>✅❌
支持 v-else✅❌

2. 事件绑定:@click ​

2.1 基本语法 ​

vue
<template>
  <!-- 完整写法 -->
  <button v-on:click="handleClick">点击</button>

  <!-- 简写(推荐) -->
  <button @click="handleClick">点击</button>

  <!-- 内联表达式 -->
  <button @click="count++">+1</button>

  <!-- 传参 -->
  <button @click="remove(todo.id)">删除</button>

  <!-- 访问原生事件对象 -->
  <button @click="handleClick($event)">点击</button>
</template>

2.2 事件修饰符 ​

vue
<template>
  <!-- .prevent:阻止默认行为(等同 event.preventDefault()) -->
  <form @submit.prevent="onSubmit">...</form>

  <!-- .stop:阻止冒泡(等同 event.stopPropagation()) -->
  <div @click.stop="handleClick">...</div>

  <!-- .once:只触发一次 -->
  <button @click.once="initApp">初始化</button>

  <!-- 按键修饰符 -->
  <input @keyup.enter="submit" />
  <input @keyup.esc="cancel" />
</template>

3. 子组件通信:defineEmits ​

Props 是父→子传数据,那子组件想通知父组件怎么办?用 emit。

3.1 原理 ​

3.2 在 TodoItem 中定义事件 ​

vue
<!-- src/components/TodoItem.vue -->
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    id: number
    text: string
    done?: boolean
    priority?: 'low' | 'medium' | 'high'
    createdAt?: string
  }>(),
  { done: false, priority: 'medium' }
)

// 定义组件可以发出的事件
const emit = defineEmits<{
  toggle: [id: number]       // 切换完成状态,传递 id
  delete: [id: number]       // 删除 Todo,传递 id
}>()

function handleToggle() {
  emit('toggle', props.id)   // 发出 toggle 事件,携带 id
}

function handleDelete() {
  emit('delete', props.id)   // 发出 delete 事件,携带 id
}
</script>

<template>
  <div class="todo-item" :class="{ 'is-done': done, [`priority-${priority}`]: true }">
    <button type="button" class="toggle-btn" :aria-label="done ? '标记为未完成' : '标记为已完成'" :aria-pressed="done" @click="handleToggle">
      {{ done ? '✅' : '⬜' }}
    </button>

    <div class="todo-content">
      <span class="todo-text">{{ text }}</span>
      <span v-if="createdAt" class="todo-date">{{ createdAt }}</span>
    </div>

    <span class="priority-badge">{{ priority }}</span>

    <button type="button" class="delete-btn" aria-label="删除任务" @click="handleDelete">🗑️</button>
  </div>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  background: #fff;
  border-radius: 8px;
  border: 1px solid #e8e8e8;
  margin-bottom: 8px;
  transition: all 0.3s ease;
}

.todo-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.todo-item.is-done {
  opacity: 0.55;
  background: #fafafa;
}

.todo-item.is-done .todo-text {
  text-decoration: line-through;
  color: #999;
}

.toggle-btn,
.delete-btn {
  background: none;
  border: none;
  font-size: 1.2rem;
  cursor: pointer;
  padding: 4px;
  border-radius: 4px;
  transition: background 0.2s;
}

.toggle-btn:hover,
.delete-btn:hover {
  background: #f0f0f0;
}

.delete-btn {
  opacity: 0.6;
  transition: opacity 0.2s;
}

.todo-item:hover .delete-btn,
.todo-item:focus-within .delete-btn {
  opacity: 1;
}

.todo-content {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.todo-text {
  font-size: 1rem;
  color: #2c3e50;
}

.todo-date {
  font-size: 0.75rem;
  color: #aaa;
  margin-top: 2px;
}

.priority-badge {
  font-size: 0.7rem;
  padding: 2px 8px;
  border-radius: 10px;
  text-transform: uppercase;
  font-weight: 600;
  letter-spacing: 0.5px;
}

.priority-low .priority-badge {
  background: #e8f5e9;
  color: #4caf50;
}

.priority-medium .priority-badge {
  background: #fff3e0;
  color: #ff9800;
}

.priority-high .priority-badge {
  background: #ffebee;
  color: #f44336;
}
</style>

3.3 在 App.vue 中监听事件 ​

替换脚本和模板,继续保留 L03 的应用样式。本节新增必传的 id prop,所有 TodoItem 调用都要传入 :id="todo.id"。

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TodoItem from './components/TodoItem.vue'
import type { Todo } from './types/todo'

const todos = ref<Todo[]>([
  { id: 1, text: '搭建项目脚手架', done: true, priority: 'low', createdAt: '2024-01-01' },
  { id: 2, text: '理解组件和 Props', done: true, priority: 'medium', createdAt: '2024-01-02' },
  { id: 3, text: '学习响应式系统', done: false, priority: 'high', createdAt: '2024-01-03' },
])

let nextTodoId = 4
const newTodoText = ref('')

function addTodo() {
  const text = newTodoText.value.trim()
  if (!text) return
  todos.value.push({
    id: nextTodoId++,
    text,
    done: false,
    priority: 'medium',
    createdAt: new Date().toISOString().slice(0, 10), // UTC 日期
  })
  newTodoText.value = ''
}

// 切换完成状态
function toggleTodo(id: number) {
  const todo = todos.value.find(t => t.id === id)
  if (todo) {
    todo.done = !todo.done  // ✅ 在父组件修改数据,保持单向数据流
  }
}

// 删除 Todo
function deleteTodo(id: number) {
  todos.value = todos.value.filter(t => t.id !== id)
}
</script>

<template>
  <div class="app">
    <header class="app-header">
      <h1>📝 Vue Todo</h1>
      <p class="subtitle">{{ todos.length }} 个任务</p>
    </header>

    <div class="add-todo">
      <input
        :value="newTodoText"
        @input="newTodoText = ($event.target as HTMLInputElement).value"
        @keydown.enter="!$event.isComposing && addTodo()"
        placeholder="添加新任务..."
        class="todo-input"
      />
      <button @click="addTodo" class="add-btn">添加</button>
    </div>

    <main class="app-main">
      <p v-if="todos.length === 0" class="empty">还没有任务,添加一条试试吧</p>

      <TodoItem
        v-for="todo in todos"
        :key="todo.id"
        :id="todo.id"
        :text="todo.text"
        :done="todo.done"
        :priority="todo.priority"
        :created-at="todo.createdAt"
        @toggle="toggleTodo"
        @delete="deleteTodo"
      />
      <!-- ⬆ @toggle="toggleTodo" 监听子组件的 toggle 事件 -->
      <!-- ⬆ 子组件 emit('toggle', id) → 执行 toggleTodo(id) -->
    </main>
  </div>
</template>

3.4 完整数据流 ​


4. Transition 动画 ​

用下面的 <TransitionGroup> 替换现有的 TodoItem 列表,不要再保留原列表,否则每项会显示两次:

vue
<!-- 在 App.vue 的 template 中 -->
<TransitionGroup name="list" tag="div" class="todo-list">
  <TodoItem
    v-for="todo in todos"
    :key="todo.id"
    :id="todo.id"
    :text="todo.text"
    :done="todo.done"
    :priority="todo.priority"
    :created-at="todo.createdAt"
    @toggle="toggleTodo"
    @delete="deleteTodo"
  />
</TransitionGroup>
css
/* 追加到 App.vue 保留的 style scoped 中 */
.todo-list {
  position: relative;
}

/* 进入动画 */
.list-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}

.list-enter-active {
  transition: all 0.3s ease;
}

/* 离开动画 */
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  width: 100%;
}

/* 移动动画(列表重排时) */
.list-move {
  transition: transform 0.3s ease;
}

<Transition> vs <TransitionGroup>:

  • <Transition>:单个元素/组件的进出动画
  • <TransitionGroup>:列表的进出 + 移动动画

5. 完整的空状态处理 ​

vue
<template>
  <main class="app-main">
    <!-- 空状态 -->
    <div v-if="todos.length === 0" class="empty-state">
      <span class="empty-icon">🎉</span>
      <p class="empty-text">没有任务了</p>
      <p class="empty-hint">在上方添加你的第一个任务吧</p>
    </div>

    <!-- 保留容器,让最后一项也能完成离开动画 -->
    <TransitionGroup name="list" tag="div" class="todo-list">
      <TodoItem
        v-for="todo in todos"
        :key="todo.id"
        v-bind="todo"
        @toggle="toggleTodo"
        @delete="deleteTodo"
      />
      <!-- ⬆ v-bind="todo" 是展开语法 -->
      <!-- 等价于 :id="todo.id" :text="todo.text" :done="todo.done" ... -->
    </TransitionGroup>
  </main>
</template>

v-bind="todo" 展开技巧: 当 Props 名和数据对象属性名一致时,可以用 v-bind="obj" 展开所有属性,省去逐个绑定。


组件事件和列表动画规则参见 Vue 组件事件 与 TransitionGroup。

6. 本节总结 ​

知识图谱 ​

检查清单 ​

  • [ ] 能区分 v-if 和 v-show 的区别与适用场景
  • [ ] 能用 @click 绑定事件并传参
  • [ ] 知道常用事件修饰符(.prevent、.stop、.enter)
  • [ ] 能用 defineEmits 声明和触发自定义事件
  • [ ] 能解释"子组件 emit 事件 → 父组件监听处理"的数据流
  • [ ] 能用 <TransitionGroup> 实现列表动画

🐞 防坑指南 ​

坑说明正确做法
以为组件事件会冒泡孙组件 emit 后,祖父组件不会自动收到由直接父组件监听,必要时再转发
v-show 用在 <template> 上<template> 不是真实 DOM → v-show 无效改用 v-if 或给真实元素加 v-show
TransitionGroup 无 key列表项必须有 key 才能触发动画每个子元素加 :key
内联事件写复杂逻辑@click="a++; b--; doSomething()" 难维护抽为方法 @click="handleClick"

📐 最佳实践 ​

  1. emit 类型声明:始终用 defineEmits<{ event: [payload] }>() 泛型,享受类型检查
  2. v-if vs v-show:按切换成本和状态保留需求选择。二者都只控制前端显示,接口权限必须由服务端校验
  3. 事件命名:自定义事件用行为动词(toggle、delete、update),不用 onClick 等
  4. 动画渐进:先做功能、后加动画,列表的 <TransitionGroup> 可以最后一步加上去

Git 提交 ​

bash
git add .
git commit -m "L05: 条件渲染 + 事件绑定 + emit + 动画"

🔗 钩子连接 ​

→ 下一节:L06 · 表单与 v-model:双向绑定 ​

现在 Todo 能添加、完成、删除了,但不能编辑。L06 将引入 v-model 实现双向绑定,让 Todo 支持内联编辑。同时会深入讲解 v-model 的语法糖本质。