Skip to content

TodoList 项目实战

在本章中,我们将使用 Vue3 构建一个完整的 TodoList 应用,通过这个项目来巩固我们所学的 Vue3 知识。

需求分析

我们需要构建一个具有以下功能的 TodoList 应用:

  1. 添加新的待办事项
  2. 标记待办事项为已完成
  3. 删除待办事项
  4. 编辑待办事项
  5. 过滤待办事项(全部、已完成、未完成)
  6. 清空所有已完成的待办事项
  7. 本地存储持久化

项目结构

TodoList/
├── src/
│   ├── components/
│   │   ├── TodoHeader.vue        # 头部组件
│   │   ├── TodoInput.vue         # 输入组件
│   │   ├── TodoList.vue          # 列表组件
│   │   ├── TodoItem.vue          # 列表项组件
│   │   └── TodoFooter.vue        # 底部组件
│   ├── composables/
│   │   └── useTodo.js            # 业务逻辑
│   ├── App.vue                   # 根组件
│   └── main.js                   # 入口文件
├── index.html
├── package.json
└── vite.config.js

实现步骤

1. 初始化项目

bash
npm create vite@latest todo-list -- --template vue
cd todo-list
npm install

2. 创建 Todo 业务逻辑

javascript
// composables/useTodo.js
import { ref, computed, watch } from 'vue'

export function useTodo() {
  // 从本地存储获取初始数据
  const loadTodos = () => {
    const todos = localStorage.getItem('todos')
    return todos ? JSON.parse(todos) : [
      { id: 1, text: '学习 Vue3', completed: false },
      { id: 2, text: '完成作业', completed: true },
      { id: 3, text: '运动', completed: false }
    ]
  }

  // 状态
  const todos = ref(loadTodos())
  const newTodo = ref('')
  const filter = ref('all') // all, completed, active
  const editingTodo = ref(null)
  const editText = ref('')

  // 计算属性
  const filteredTodos = computed(() => {
    switch (filter.value) {
      case 'completed':
        return todos.value.filter(todo => todo.completed)
      case 'active':
        return todos.value.filter(todo => !todo.completed)
      default:
        return todos.value
    }
  })

  const activeCount = computed(() => {
    return todos.value.filter(todo => !todo.completed).length
  })

  const completedCount = computed(() => {
    return todos.value.filter(todo => todo.completed).length
  })

  // 方法
  function addTodo() {
    if (newTodo.value.trim()) {
      todos.value.push({
        id: Date.now(),
        text: newTodo.value.trim(),
        completed: false
      })
      newTodo.value = ''
    }
  }

  function toggleTodo(id) {
    const todo = todos.value.find(t => t.id === id)
    if (todo) {
      todo.completed = !todo.completed
    }
  }

  function deleteTodo(id) {
    todos.value = todos.value.filter(todo => todo.id !== id)
  }

  function startEdit(todo) {
    editingTodo.value = todo
    editText.value = todo.text
  }

  function saveEdit(todo) {
    if (editingTodo.value) {
      editingTodo.value.text = editText.value.trim()
      editingTodo.value = null
      editText.value = ''
    }
  }

  function cancelEdit() {
    editingTodo.value = null
    editText.value = ''
  }

  function clearCompleted() {
    todos.value = todos.value.filter(todo => !todo.completed)
  }

  function changeFilter(newFilter) {
    filter.value = newFilter
  }

  // 监听 todos 变化,保存到本地存储
  watch(todos, (newTodos) => {
    localStorage.setItem('todos', JSON.stringify(newTodos))
  }, { deep: true })

  return {
    todos,
    newTodo,
    filter,
    editingTodo,
    editText,
    filteredTodos,
    activeCount,
    completedCount,
    addTodo,
    toggleTodo,
    deleteTodo,
    startEdit,
    saveEdit,
    cancelEdit,
    clearCompleted,
    changeFilter
  }
}

3. 创建组件

TodoHeader.vue

vue
<template>
  <header class="header">
    <h1>todos</h1>
  </header>
</template>

<style scoped>
.header {
  text-align: center;
  margin-bottom: 20px;
}

h1 {
  font-size: 60px;
  font-weight: 100;
  color: #e74c3c;
  margin: 0;
}
</style>

TodoInput.vue

vue
<template>
  <div class="input-container">
    <input
      v-model="newTodo"
      @keyup.enter="addTodo"
      placeholder="What needs to be done?"
      class="todo-input"
      autofocus
    />
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  newTodo: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['update:newTodo', 'addTodo'])

function addTodo() {
  if (props.newTodo.trim()) {
    emit('addTodo')
    emit('update:newTodo', '')
  }
}
</script>

<style scoped>
.input-container {
  margin-bottom: 20px;
}

.todo-input {
  width: 100%;
  padding: 16px;
  font-size: 24px;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
}

.todo-input:focus {
  outline: none;
  border-color: #e74c3c;
  box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.2);
}
</style>

TodoItem.vue

vue
<template>
  <li 
    class="todo-item" 
    :class="{ completed, editing }"
  >
    <div class="view">
      <input
        type="checkbox"
        class="toggle"
        :checked="todo.completed"
        @change="toggleTodo(todo.id)"
      />
      <label @dblclick="startEdit(todo)">{{ todo.text }}</label>
      <button class="destroy" @click="deleteTodo(todo.id)"></button>
    </div>
    <input
      v-if="editing"
      v-model="editText"
      class="edit"
      @blur="saveEdit(todo)"
      @keyup.enter="saveEdit(todo)"
      @keyup.esc="cancelEdit"
      ref="editInput"
    />
  </li>
</template>

<script setup>
import { ref, watch, onMounted } from 'vue'
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  todo: {
    type: Object,
    required: true
  },
  editing: {
    type: Boolean,
    default: false
  },
  editText: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['toggleTodo', 'deleteTodo', 'startEdit', 'saveEdit', 'cancelEdit'])

const editInput = ref(null)

onMounted(() => {
  if (props.editing && editInput.value) {
    editInput.value.focus()
  }
})

function toggleTodo(id) {
  emit('toggleTodo', id)
}

function deleteTodo(id) {
  emit('deleteTodo', id)
}

function startEdit(todo) {
  emit('startEdit', todo)
}

function saveEdit(todo) {
  emit('saveEdit', todo)
}

function cancelEdit() {
  emit('cancelEdit')
}
</script>

<style scoped>
.todo-item {
  padding: 16px;
  border-bottom: 1px solid #eee;
  display: flex;
  align-items: center;
  position: relative;
}

.todo-item.completed label {
  text-decoration: line-through;
  color: #999;
}

.todo-item.editing {
  border-bottom: none;
  padding: 0;
}

.view {
  display: flex;
  align-items: center;
  width: 100%;
}

.toggle {
  margin-right: 16px;
  width: 20px;
  height: 20px;
}

label {
  flex: 1;
  font-size: 18px;
  cursor: pointer;
}

.destroy {
  background: none;
  border: none;
  font-size: 20px;
  color: #ccc;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.3s;
}

.todo-item:hover .destroy {
  opacity: 1;
}

.edit {
  width: 100%;
  padding: 16px;
  font-size: 18px;
  border: 1px solid #ccc;
  border-radius: 4px;
  box-sizing: border-box;
}

.edit:focus {
  outline: none;
  border-color: #e74c3c;
  box-shadow: 0 0 0 2px rgba(231, 76, 60, 0.2);
}
</style>

TodoList.vue

vue
<template>
  <ul class="todo-list">
    <TodoItem
      v-for="todo in filteredTodos"
      :key="todo.id"
      :todo="todo"
      :editing="editingTodo && editingTodo.id === todo.id"
      :edit-text="editText"
      @toggle-todo="toggleTodo"
      @delete-todo="deleteTodo"
      @start-edit="startEdit"
      @save-edit="saveEdit"
      @cancel-edit="cancelEdit"
    />
  </ul>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'
import TodoItem from './TodoItem.vue'

const props = defineProps({
  filteredTodos: {
    type: Array,
    required: true
  },
  editingTodo: {
    type: Object,
    default: null
  },
  editText: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['toggleTodo', 'deleteTodo', 'startEdit', 'saveEdit', 'cancelEdit'])

function toggleTodo(id) {
  emit('toggleTodo', id)
}

function deleteTodo(id) {
  emit('deleteTodo', id)
}

function startEdit(todo) {
  emit('startEdit', todo)
}

function saveEdit(todo) {
  emit('saveEdit', todo)
}

function cancelEdit() {
  emit('cancelEdit')
}
</script>

<style scoped>
.todo-list {
  list-style: none;
  padding: 0;
  margin: 0;
  background-color: white;
  border: 1px solid #ccc;
  border-radius: 4px;
}
</style>

TodoFooter.vue

vue
<template>
  <footer class="footer" v-if="todos.length > 0">
    <span class="todo-count">
      <strong>{{ activeCount }}</strong> item{{ activeCount !== 1 ? 's' : '' }} left
    </span>
    <ul class="filters">
      <li>
        <a 
          href="#" 
          :class="{ active: filter === 'all' }"
          @click.prevent="changeFilter('all')"
        >
          All
        </a>
      </li>
      <li>
        <a 
          href="#" 
          :class="{ active: filter === 'active' }"
          @click.prevent="changeFilter('active')"
        >
          Active
        </a>
      </li>
      <li>
        <a 
          href="#" 
          :class="{ active: filter === 'completed' }"
          @click.prevent="changeFilter('completed')"
        >
          Completed
        </a>
      </li>
    </ul>
    <button 
      class="clear-completed" 
      v-if="completedCount > 0"
      @click="clearCompleted"
    >
      Clear completed
    </button>
  </footer>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  todos: {
    type: Array,
    required: true
  },
  activeCount: {
    type: Number,
    required: true
  },
  completedCount: {
    type: Number,
    required: true
  },
  filter: {
    type: String,
    default: 'all'
  }
})

const emit = defineEmits(['changeFilter', 'clearCompleted'])

function changeFilter(filter) {
  emit('changeFilter', filter)
}

function clearCompleted() {
  emit('clearCompleted')
}
</script>

<style scoped>
.footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 10px 16px;
  background-color: #f9f9f9;
  border: 1px solid #ccc;
  border-top: none;
  border-radius: 0 0 4px 4px;
  font-size: 14px;
}

.todo-count {
  color: #666;
}

.filters {
  display: flex;
  list-style: none;
  padding: 0;
  margin: 0;
  gap: 10px;
}

.filters a {
  color: #666;
  text-decoration: none;
  padding: 3px 7px;
  border-radius: 3px;
}

.filters a:hover {
  background-color: rgba(0, 0, 0, 0.05);
}

.filters a.active {
  background-color: rgba(231, 76, 60, 0.2);
  color: #e74c3c;
}

.clear-completed {
  background: none;
  border: none;
  color: #666;
  cursor: pointer;
  padding: 3px 7px;
  border-radius: 3px;
}

.clear-completed:hover {
  text-decoration: underline;
}
</style>

4. 组装应用

vue
<!-- App.vue -->
<template>
  <div class="todo-app">
    <TodoHeader />
    <TodoInput 
      v-model:newTodo="newTodo" 
      @add-todo="addTodo"
    />
    <TodoList 
      :filteredTodos="filteredTodos"
      :editingTodo="editingTodo"
      :editText="editText"
      @toggle-todo="toggleTodo"
      @delete-todo="deleteTodo"
      @start-edit="startEdit"
      @save-edit="saveEdit"
      @cancel-edit="cancelEdit"
    />
    <TodoFooter 
      :todos="todos"
      :activeCount="activeCount"
      :completedCount="completedCount"
      :filter="filter"
      @change-filter="changeFilter"
      @clear-completed="clearCompleted"
    />
  </div>
</template>

<script setup>
import { useTodo } from './composables/useTodo'
import TodoHeader from './components/TodoHeader.vue'
import TodoInput from './components/TodoInput.vue'
import TodoList from './components/TodoList.vue'
import TodoFooter from './components/TodoFooter.vue'

const {
  todos,
  newTodo,
  filter,
  editingTodo,
  editText,
  filteredTodos,
  activeCount,
  completedCount,
  addTodo,
  toggleTodo,
  deleteTodo,
  startEdit,
  saveEdit,
  cancelEdit,
  clearCompleted,
  changeFilter
} = useTodo()
</script>

<style>
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  font-size: 16px;
  line-height: 1.5;
  color: #333;
  background-color: #f5f5f5;
}

.todo-app {
  max-width: 600px;
  margin: 40px auto;
  padding: 20px;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
</style>

5. 运行项目

bash
npm run dev

项目功能说明

  1. 添加待办事项:在输入框中输入内容,按回车键添加新的待办事项。

  2. 标记完成状态:点击复选框可以将待办事项标记为已完成或未完成。

  3. 删除待办事项:将鼠标悬停在待办事项上,会显示删除按钮,点击可以删除该待办事项。

  4. 编辑待办事项:双击待办事项的文本可以进入编辑模式,修改完成后按回车键保存,按 Esc 键取消编辑。

  5. 过滤待办事项:点击底部的 All、Active、Completed 按钮可以过滤显示不同状态的待办事项。

  6. 清空已完成:点击 Clear completed 按钮可以清空所有已完成的待办事项。

  7. 本地存储:待办事项会自动保存到本地存储,刷新页面后数据不会丢失。

技术要点

  1. 组合式 API:使用 useTodo 自定义 hook 封装业务逻辑,实现逻辑复用。

  2. 响应式数据:使用 refcomputed 管理响应式状态。

  3. 组件通信:使用 props 和 emit 实现组件之间的通信。

  4. 本地存储:使用 localStorage 实现数据持久化。

  5. 事件处理:处理键盘事件、鼠标事件等用户交互。

  6. 条件渲染:根据状态条件渲染不同的 UI 元素。

  7. 列表渲染:使用 v-for 渲染待办事项列表。

通过这个 TodoList 项目,我们巩固了 Vue3 的核心概念和使用方法,包括组合式 API、响应式数据、组件通信、事件处理等。这是一个经典的前端项目,可以帮助我们更好地理解 Vue3 的应用开发流程。

© 2026 编程马·菜鸟教程 版权所有