任务管理应用是前端学习中的经典练手项目,它覆盖了列表渲染、表单输入、事件处理、条件显示和本地存储等高频知识点。本文使用 Vue 3 的 <script setup> 语法,从项目创建开始,逐步实现任务新增、编辑、删除、完成状态切换、筛选统计以及浏览器本地持久化。你不需要引入后端服务,数据完全保存在浏览器中,适合快速上手和巩固 Vue 基础。

一、项目初始化与任务数据结构
先用 Vite 创建项目。执行 npm create vite@latest task-manager -- --template vue 后进入目录安装依赖,再运行 npm run dev 即可看到默认页面。Vite 生成的结构中,我们主要修改 src/App.vue。清理默认样式后,可以在组件里声明一个响应式任务数组。任务对象建议至少包含 id、title、done 三个字段,如果后续需要排序或展示创建时间,可以再补充 createdAt。
id 的生成方式直接影响删除和编辑是否稳定。小项目里可以使用自增变量,每次添加任务时让 nextId 加一。如果数据需要跨刷新保存,id 不能依赖数组长度,因为删除中间项后长度会变化。也可以使用 crypto.randomUUID 生成更可靠的唯一值。下面先给出最基础的渲染结构,后续再逐步加入交互。
<script setup>
import { ref } from 'vue'
const tasks = ref([
{ id: 1, title: '学习Vue组件', done: false, createdAt: '2025-01-01 10:00:00' },
{ id: 2, title: '完成周报', done: true, createdAt: '2025-01-01 09:00:00' }
])
</script>
<template>
<div class="task-app">
<h1>任务管理</h1>
<ul>
<li v-for="task in tasks" :key="task.id">
<input type="checkbox" :checked="task.done" disabled />
<span>{{ task.title }}</span>
</li>
</ul>
</div>
</template>
<style scoped>
.done {
text-decoration: line-through;
color: #999;
}
</style>
二、实现任务的增删改查与状态切换
新增任务的核心是 v-model 绑定输入框和 newTask 变量。提交动作可以绑在按钮的 click 事件上,也可以监听输入框的 keyup.enter,让用户按回车直接添加。提交前需要用 trim 去掉首尾空格,避免保存空任务。
编辑功能的思路是引入两个辅助变量:editingId 表示正在编辑的任务 id,draftTitle 保存编辑中的标题。双击任务文本时进入编辑态,输入框显示草稿内容,按回车保存,按 Esc 取消。保存时如果发现标题为空,可以直接删除该任务,这样交互更自然。
删除和完成状态相对简单。删除时用 filter 返回新数组,替换原 tasks,而不是直接修改下标,这样 Vue 的响应式更新更可靠。完成状态可以用 checkbox 直接 v-model="task.done",因为任务对象保存在响应式数组中,深度响应可以监听到 done 的变化。以下代码整合了这三个功能。
<script setup>
import { ref } from 'vue'
const tasks = ref([])
const newTask = ref('')
const editingId = ref(null)
const draftTitle = ref('')
let nextId = 1
function addTask() {
const title = newTask.value.trim()
if (!title) return
tasks.value.push({
id: nextId++,
title,
done: false,
createdAt: new Date().toLocaleString()
})
newTask.value = ''
}
function startEdit(task) {
editingId.value = task.id
draftTitle.value = task.title
}
function saveEdit(id) {
const title = draftTitle.value.trim()
if (!title) {
removeTask(id)
} else {
const task = tasks.value.find(item => item.id === id)
if (task) task.title = title
}
editingId.value = null
draftTitle.value = ''
}
function cancelEdit() {
editingId.value = null
draftTitle.value = ''
}
function removeTask(id) {
tasks.value = tasks.value.filter(task => task.id !== id)
}
</script>
<template>
<div class="task-app">
<h2>任务管理</h2>
<div class="task-form">
<input v-model="newTask" @keyup.enter="addTask" placeholder="输入任务后回车" />
<button @click="addTask">添加</button>
</div>
<ul>
<li v-for="task in tasks" :key="task.id">
<input type="checkbox" v-model="task.done" />
<template v-if="editingId === task.id">
<input v-model="draftTitle" @keyup.enter="saveEdit(task.id)" @keyup.esc="cancelEdit" />
<button @click="saveEdit(task.id)">保存</button>
<button @click="cancelEdit">取消</button>
</template>
<template v-else>
<span :class="{ done: task.done }" @dblclick="startEdit(task)">{{ task.title }}</span>
<button @click="removeTask(task.id)">删除</button>
</template>
</li>
</ul>
</div>
</template>
三、本地持久化与筛选统计
刷新页面后,仅存在内存里的 tasks 会丢失。解决办法是使用 watch 深度监听 tasks 数组,数据变化时把数组序列化后写入 localStorage。初始化时从 localStorage 读取,并用 try/catch 包住 JSON.parse,避免数据损坏导致应用无法启动。
筛选功能不建议在模板里直接使用方法处理数组,因为每次渲染都会重新执行。更合适的做法是使用计算属性 computed,根据当前筛选条件返回全新数组。同时可以用另一个计算属性统计未完成任务数量。
给筛选按钮绑定 filter 值,再让列表遍历计算属性返回的 filteredTasks,就能实现全部、进行中、已完成三种视图。代码如下。
import { ref, watch, computed } from 'vue'
const STORAGE_KEY = 'task-manager-data'
const tasks = ref(loadTasks())
const filter = ref('all')
function loadTasks() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : []
} catch (error) {
return []
}
}
watch(tasks, (value) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value))
}, { deep: true })
const filteredTasks = computed(() => {
if (filter.value === 'active') {
return tasks.value.filter(task => !task.done)
}
if (filter.value === 'done') {
return tasks.value.filter(task => task.done)
}
return tasks.value
})
const remaining = computed(() => tasks.value.filter(task => !task.done).length)
<template>
<div class="filters">
<button @click="filter = 'all'">全部</button>
<button @click="filter = 'active'">进行中</button>
<button @click="filter = 'done'">已完成</button>
</div>
<p>剩余 {{ remaining }} 项任务</p>
<ul>
<li v-for="task in filteredTasks" :key="task.id">
<input type="checkbox" v-model="task.done" />
<span :class="{ done: task.done }" @dblclick="startEdit(task)">{{ task.title }}</span>
<button @click="removeTask(task.id)">删除</button>
</li>
</ul>
</template>
四、常见问题与注意事项
这个项目虽然结构简单,但有几个容易忽视的细节。首先是 id 冲突,如果使用 Date.now 生成 id,快速连续添加两个任务时可能出现相同值,导致 key 重复和状态错乱。建议使用自增变量或 crypto.randomUUID。其次是 localStorage 在某些隐私模式下可能被禁用,写入前要判断是否可用并捕获异常。
还要注意空状态提示。任务列表为空时应该显示占位内容,否则用户会误以为渲染失败。日期字段如果要用于排序,建议存储时间戳而不是格式化字符串,因为字符串比较在日期格式不一致时会出错。最后,使用 v-for 时不要用 index 作为 key,删除或筛选后 index 变化会导致输入状态和 DOM 复用异常。
完成这些基础功能后,可以继续扩展任务分类、截止日期提醒、拖拽排序等功能。若状态逻辑继续增加,再考虑引入 Pinia 做集中管理。先把当前单页版本跑通,对理解 Vue 的响应式数据和组件化开发很有帮助。