Appearance
TodoList 项目实战
在本章中,我们将使用 Vue3 构建一个完整的 TodoList 应用,通过这个项目来巩固我们所学的 Vue3 知识。
需求分析
我们需要构建一个具有以下功能的 TodoList 应用:
- 添加新的待办事项
- 标记待办事项为已完成
- 删除待办事项
- 编辑待办事项
- 过滤待办事项(全部、已完成、未完成)
- 清空所有已完成的待办事项
- 本地存储持久化
项目结构
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 install2. 创建 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项目功能说明
添加待办事项:在输入框中输入内容,按回车键添加新的待办事项。
标记完成状态:点击复选框可以将待办事项标记为已完成或未完成。
删除待办事项:将鼠标悬停在待办事项上,会显示删除按钮,点击可以删除该待办事项。
编辑待办事项:双击待办事项的文本可以进入编辑模式,修改完成后按回车键保存,按 Esc 键取消编辑。
过滤待办事项:点击底部的 All、Active、Completed 按钮可以过滤显示不同状态的待办事项。
清空已完成:点击 Clear completed 按钮可以清空所有已完成的待办事项。
本地存储:待办事项会自动保存到本地存储,刷新页面后数据不会丢失。
技术要点
组合式 API:使用
useTodo自定义 hook 封装业务逻辑,实现逻辑复用。响应式数据:使用
ref和computed管理响应式状态。组件通信:使用 props 和 emit 实现组件之间的通信。
本地存储:使用
localStorage实现数据持久化。事件处理:处理键盘事件、鼠标事件等用户交互。
条件渲染:根据状态条件渲染不同的 UI 元素。
列表渲染:使用
v-for渲染待办事项列表。
通过这个 TodoList 项目,我们巩固了 Vue3 的核心概念和使用方法,包括组合式 API、响应式数据、组件通信、事件处理等。这是一个经典的前端项目,可以帮助我们更好地理解 Vue3 的应用开发流程。
