一、项目效果演示
本次实战搭建一个功能完整的 Vue3 待办清单,包含核心功能 + 拓展功能,适配 PC / 移动端,最终效果如下:
- 核心功能:添加待办、删除待办、标记完成 / 未完成、筛选待办(全部 / 已完成 / 未完成)
- 拓展功能:待办内容编辑、清空所有已完成待办、待办数量统计、本地存储持久化
- 交互体验:添加 / 删除 / 完成待办的过渡动画、输入框防抖、空内容提交拦截、适配移动端的样式布局
- 技术亮点:全程使用 Vue3组合式 API(setup 语法糖) 开发,通过Pinia管理待办状态,替代 Vue2 的 Vuex,代码更简洁易维护。
二、前置准备
1. 环境要求(已安装可跳过,附快速安装步骤)
- Node.js + npm:版本要求 Node.js ≥16.0.0,npm ≥8.0.0(可通过
node -v/npm -v验证) - Vue CLI 或 Vite:本次用Vite搭建项目(更快、更轻量,Vue3 官方推荐)
- VS Code:搭配插件(Volar、Vue Language Features、Prettier),提升开发效率
- 基础储备:了解 HTML/CSS 基础、JavaScript ES6 + 语法(箭头函数、解构、数组方法),零基础可跟随步骤操作,代码附带详细注释。
2. 快速安装依赖(命令行直接执行)
# 1. 全局安装Vite(首次安装)
npm install -g create-vite
# 2. 验证Vite安装
vite -v
三、核心知识点梳理(Vue3 新手必看)
提前梳理本次实战用到的 Vue3 核心知识点,避免开发中混淆,后续步骤会逐一对应使用:
- Vue3 组合式 API(setup 语法糖) :无需写 export default,直接定义变量 / 方法,代码更聚合,替代 Vue2 的选项式 API(data/methods/computed)
- Pinia:Vue3 官方状态管理库,替代 Vuex,无需配置模块,创建 /store/ 使用三步到位,支持持久化
- Vue3 内置指令:
v-model(双向绑定)、v-for(循环渲染)、v-bind(属性绑定)、v-on(事件绑定,简写 @)、v-show(条件显示) - 计算属性(computed) :处理待办数量统计、筛选待办列表,缓存计算结果,提升性能
- 监听器(watch) :监听待办状态变化,实现本地存储持久化
- Vue3 过渡动画:
<TransitionGroup>实现待办项的添加 / 删除动画,提升交互 - Props/Emits:组件间通信(子组件向父组件传递事件,如编辑 / 删除待办)
四、分步实现(从项目搭建到功能开发,附完整代码块)
步骤 1:用 Vite 快速搭建 Vue3 项目
全程命令行执行,选择 Vue+JavaScript(新手友好,避免 TS 的复杂度),步骤如下:
# 1. 创建项目(项目名:vue3-todo-pinia,可自定义)
create-vite vue3-todo-pinia
# 2. 进入项目目录
cd vue3-todo-pinia
# 3. 安装项目依赖
npm install
# 4. 安装Pinia(状态管理)和pinia-plugin-persistedstate(Pinia持久化)
npm install pinia pinia-plugin-persistedstate
# 5. 启动开发服务器(默认端口:5173)
npm run dev
启动成功后,浏览器打开http://127.0.0.1:5173/,看到 Vite+Vue3 默认页面,说明项目搭建成功。
步骤 2:项目目录结构梳理(精简版,删除无用文件)
搭建完成后,删除 Vite 默认的无用文件(如 HelloWorld.vue、favicon.ico 等),整理后的目录结构更清晰,新手严格按照此结构创建:
vue3-todo-pinia/
├── node_modules/ # 项目依赖
├── public/ # 公共资源(空)
├── src/
│ ├── components/ # 组件目录(存放TodoItem子组件)
│ │ └── TodoItem.vue # 待办项子组件(编辑/删除/标记完成)
│ ├── store/ # Pinia状态管理目录
│ │ └── todoStore.js # 待办状态管理(核心)
│ ├── App.vue # 根组件(主页面,包含所有功能)
│ ├── main.js # 项目入口(挂载App、注册Pinia)
│ ├── style.css # 全局样式(重置样式、通用样式)
│ └── vite-env.d.ts # Vite环境声明(无需修改)
├── .gitignore # git忽略文件(无需修改)
├── index.html # 入口HTML(无需修改)
├── package.json # 项目配置(无需修改)
└── vite.config.js # Vite配置(无需修改)
步骤 3:配置项目入口(main.js),注册 Pinia 并开启持久化
修改src/main.js,替换 Vite 默认代码,注册 Pinia并引入持久化插件,让待办数据刷新页面不丢失:
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia' // 引入Pinia
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // Pinia持久化插件
import './style.css'
import App from './App.vue'
// 创建Pinia实例
const pinia = createPinia()
// 注册持久化插件
pinia.use(piniaPluginPersistedstate)
// 创建Vue实例并挂载
createApp(App).use(pinia).mount('#app')
步骤 4:编写 Pinia 状态管理(todoStore.js),统一管理待办数据
创建src/store/todoStore.js,这是项目的核心数据层,所有待办的增删改查、状态存储都在这里,替代 Vue2 的 data 和 Vuex:
// src/store/todoStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 定义并导出Pinia仓库,id为唯一标识(必须)
export const useTodoStore = defineStore('todo', () => {
// 1. 状态(相当于Vue2的data):待办列表,数组类型,每个项包含id/title/complete/editable
const todoList = ref([])
// 2. 计算属性(相当于Vue2的computed):缓存计算结果,不重复执行
// 统计未完成待办数量
const unCompletedCount = computed(() => {
return todoList.value.filter(todo => !todo.complete).length
})
// 统计已完成待办数量
const completedCount = computed(() => {
return todoList.value.filter(todo => todo.complete).length
})
// 统计总待办数量
const totalCount = computed(() => {
return todoList.value.length
})
// 3. 方法(相当于Vue2的methods):处理待办的增删改查
// 添加待办
const addTodo = (title) => {
if (!title.trim()) return // 空内容拦截
todoList.value.unshift({
id: Date.now(), // 用时间戳作为唯一id,简单高效
title: title.trim(),
complete: false, // 默认为未完成
editable: false // 默认为非编辑状态
})
}
// 删除待办
const deleteTodo = (id) => {
todoList.value = todoList.value.filter(todo => todo.id !== id)
}
// 标记待办完成/未完成
const toggleComplete = (id) => {
const todo = todoList.value.find(todo => todo.id === id)
if (todo) todo.complete = !todo.complete
}
// 切换编辑状态
const toggleEditable = (id) => {
const todo = todoList.value.find(todo => todo.id === id)
if (todo) todo.editable = !todo.editable
}
// 保存编辑后的待办内容
const saveEdit = (id, newTitle) => {
const todo = todoList.value.find(todo => todo.id === id)
if (todo) {
todo.title = newTitle.trim()
todo.editable = false // 编辑完成后关闭编辑状态
}
}
// 清空所有已完成待办
const clearCompleted = () => {
todoList.value = todoList.value.filter(todo => !todo.complete)
}
// 导出状态、计算属性、方法,供组件使用
return {
todoList,
unCompletedCount,
completedCount,
totalCount,
addTodo,
deleteTodo,
toggleComplete,
toggleEditable,
saveEdit,
clearCompleted
}
}, {
persist: true // 开启Pinia持久化,默认存储在localStorage
})
步骤 5:编写全局样式(style.css),重置样式 + 通用样式,适配多端
修改src/style.css,清除浏览器默认样式,设置通用样式(字体、颜色、布局),适配 PC / 移动端,避免每个组件单独写重置样式:
/* src/style.css */
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: "Microsoft YaHei", sans-serif;
}
body {
background-color: #f5f5f5;
color: #333;
min-height: 100vh;
padding: 20px;
}
#app {
max-width: 800px;
margin: 0 auto;
background-color: #fff;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}
/* 通用按钮样式 */
.btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
.btn-primary {
background-color: #42b983; /* Vue官方绿色 */
color: #fff;
}
.btn-primary:hover {
background-color: #359469;
}
.btn-danger {
background-color: #e53935;
color: #fff;
}
.btn-danger:hover {
background-color: #c62828;
}
.btn-default {
background-color: #e0e0e0;
color: #333;
}
.btn-default:hover {
background-color: #bdbdbd;
}
/* 适配移动端 */
@media (max-width: 768px) {
#app {
padding: 20px;
}
body {
padding: 10px;
}
}
步骤 6:编写待办项子组件(TodoItem.vue),实现单个待办的功能
创建src/components/TodoItem.vue,这是子组件,负责单个待办项的渲染、编辑、删除、标记完成,通过Props接收父组件传递的待办数据,通过Emits向父组件传递事件:
<!-- src/components/TodoItem.vue -->
<template>
<!-- Transition实现单个待办项的过渡动画 -->
<Transition name="todo-item">
<div class="todo-item" :class="{ completed: todo.complete }">
<!-- 标记完成/未完成 -->
<input
type="checkbox"
v-model="todo.complete"
@change="handleToggle"
class="todo-check"
/>
<!-- 编辑状态/普通状态切换 -->
<input
v-if="todo.editable"
type="text"
v-model="editTitle"
@blur="handleSave"
@keyup.enter="handleSave"
ref="editInput"
class="todo-input-edit"
/>
<span v-else @dblclick="handleEdit" class="todo-title">{{ todo.title }}</span>
<!-- 操作按钮:编辑、删除 -->
<div class="todo-actions">
<button @click="handleEdit" class="btn btn-default btn-sm">编辑</button>
<button @click="handleDelete" class="btn btn-danger btn-sm ml-2">删除</button>
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
// 1. 接收父组件传递的待办数据(Props)
const props = defineProps({
todo: {
type: Object,
required: true,
properties: {
id: { type: Number, required: true },
title: { type: String, required: true },
complete: { type: Boolean, required: true },
editable: { type: Boolean, required: true }
}
}
})
// 2. 定义向父组件传递的事件(Emits)
const emit = defineEmits(['toggle', 'delete', 'edit', 'save'])
// 3. 编辑状态的临时标题,避免直接修改props
const editTitle = ref(props.todo.title)
// 4. 监听待办数据变化,同步编辑框内容
watch(() => props.todo.title, (newVal) => {
editTitle.value = newVal
})
// 5. 生命周期:编辑框显示时自动获取焦点
onMounted(() => {
if (props.todo.editable) {
editInput.value.focus()
}
})
const editInput = ref(null)
// 6. 事件处理方法
// 标记完成/未完成
const handleToggle = () => {
emit('toggle', props.todo.id)
}
// 删除待办
const handleDelete = () => {
if (confirm('确定要删除这个待办吗?')) {
emit('delete', props.todo.id)
}
}
// 切换编辑状态
const handleEdit = () => {
emit('edit', props.todo.id)
editTitle.value = props.todo.title
}
// 保存编辑内容
const handleSave = () => {
if (!editTitle.value.trim()) {
alert('待办内容不能为空!')
editInput.value.focus()
return
}
emit('save', props.todo.id, editTitle.value)
}
</script>
<style scoped>
/* 子组件样式,scoped表示样式仅作用于当前组件 */
.todo-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #eee;
transition: all 0.3s ease;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #999;
}
.todo-check {
width: 18px;
height: 18px;
margin-right: 15px;
cursor: pointer;
}
.todo-title {
flex: 1;
font-size: 16px;
cursor: pointer;
}
.todo-input-edit {
flex: 1;
padding: 6px 10px;
border: 1px solid #42b983;
border-radius: 4px;
font-size: 16px;
outline: none;
}
.todo-actions {
display: flex;
gap: 8px;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.ml-2 {
margin-left: 8px;
}
/* 过渡动画样式 */
.todo-item-enter-from {
opacity: 0;
transform: translateY(10px);
}
.todo-item-enter-to {
opacity: 1;
transform: translateY(0);
}
.todo-item-leave-from {
opacity: 1;
transform: translateY(0);
}
.todo-item-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.todo-item-leave-active {
position: absolute;
width: calc(100% - 60px);
}
</style>
步骤 7:编写根组件(App.vue),整合所有功能,实现筛选和动画
修改src/App.vue,这是父组件,负责待办的添加、筛选、统计、清空已完成,引入子组件TodoItem并传递数据,通过<TransitionGroup>实现待办列表的批量过渡动画:
<!-- src/App.vue -->
<template>
<div class="todo-app">
<!-- 页面标题 -->
<h1 class="todo-title">Vue3 待办清单(Pinia版)</h1>
<!-- 添加待办表单 -->
<div class="todo-add">
<input
type="text"
v-model="inputTitle"
@keyup.enter="handleAdd"
placeholder="请输入待办内容,按回车添加..."
class="todo-input"
/>
<button @click="handleAdd" class="btn btn-primary todo-add-btn">添加待办</button>
</div>
<!-- 待办列表 -->
<div class="todo-list" v-if="todoStore.totalCount > 0">
<!-- TransitionGroup实现列表过渡动画,必须设置key -->
<TransitionGroup name="todo-list" tag="div">
<TodoItem
v-for="todo in filterTodoList"
:key="todo.id"
:todo="todo"
@toggle="todoStore.toggleComplete"
@delete="todoStore.deleteTodo"
@edit="todoStore.toggleEditable"
@save="todoStore.saveEdit"
/>
</TransitionGroup>
<!-- 待办筛选+统计+清空 -->
<div class="todo-footer">
<div class="todo-count">
总待办:{{ todoStore.totalCount }} | 未完成:{{ todoStore.unCompletedCount }} | 已完成:{{ todoStore.completedCount }}
</div>
<div class="todo-filter">
<button
@click="activeFilter = 'all'"
:class="{ active: activeFilter === 'all' }"
class="btn btn-default btn-sm"
>
全部
</button>
<button
@click="activeFilter = 'uncompleted'"
:class="{ active: activeFilter === 'uncompleted' }"
class="btn btn-default btn-sm ml-2"
>
未完成
</button>
<button
@click="activeFilter = 'completed'"
:class="{ active: activeFilter === 'completed' }"
class="btn btn-default btn-sm ml-2"
>
已完成
</button>
</div>
<button
@click="todoStore.clearCompleted"
class="btn btn-danger btn-sm todo-clear"
:disabled="todoStore.completedCount === 0"
>
清空已完成
</button>
</div>
</div>
<!-- 空列表提示 -->
<div class="todo-empty" v-else>
暂无待办,快来添加你的第一个待办吧!😊
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
// 引入Pinia仓库
import { useTodoStore } from './store/todoStore'
// 引入待办项子组件
import TodoItem from './components/TodoItem.vue'
// 初始化Pinia仓库
const todoStore = useTodoStore()
// 输入框绑定的值
const inputTitle = ref('')
// 筛选状态:all(全部)、uncompleted(未完成)、completed(已完成)
const activeFilter = ref('all')
// 计算属性:根据筛选状态过滤待办列表
const filterTodoList = computed(() => {
switch (activeFilter.value) {
case 'uncompleted':
return todoStore.todoList.filter(todo => !todo.complete)
case 'completed':
return todoStore.todoList.filter(todo => todo.complete)
default:
return todoStore.todoList
}
})
// 添加待办方法
const handleAdd = () => {
todoStore.addTodo(inputTitle.value)
inputTitle.value = '' // 添加后清空输入框
}
</script>
<style scoped>
.todo-title {
text-align: center;
color: #42b983;
margin-bottom: 30px;
font-size: 28px;
}
.todo-add {
display: flex;
gap: 10px;
margin-bottom: 30px;
}
.todo-input {
flex: 1;
padding: 10px 15px;
border: 1px solid #eee;
border-radius: 4px;
font-size: 16px;
outline: none;
transition: border-color 0.3s ease;
}
.todo-input:focus {
border-color: #42b983;
}
.todo-add-btn {
white-space: nowrap;
}
.todo-list {
margin-top: 20px;
position: relative;
}
.todo-empty {
text-align: center;
padding: 50px 0;
color: #999;
font-size: 18px;
}
.todo-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 0;
margin-top: 10px;
font-size: 14px;
}
.todo-count {
color: #666;
}
.todo-filter .active {
background-color: #42b983;
color: #fff;
}
.todo-clear {
white-space: nowrap;
}
.todo-clear:disabled {
background-color: #ccc;
cursor: not-allowed;
}
/* 列表过渡动画 */
.todo-list-enter-from {
opacity: 0;
transform: translateY(10px);
}
.todo-list-enter-to {
opacity: 1;
transform: translateY(0);
}
.todo-list-leave-from {
opacity: 1;
transform: translateY(0);
}
.todo-list-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.todo-list-move {
transition: transform 0.3s ease;
}
/* 适配移动端 */
@media (max-width: 768px) {
.todo-title {
font-size: 24px;
}
.todo-add {
flex-direction: column;
}
.todo-footer {
flex-direction: column;
gap: 15px;
align-items: flex-start;
}
}
</style>
步骤 8:项目测试与打包(上线前必备)
- 本地测试:开发服务器已启动(
npm run dev),在浏览器中测试所有功能,确保无 bug(添加 / 删除 / 编辑 / 筛选 / 清空 / 持久化),刷新页面后数据不丢失。 - 项目打包:测试通过后,执行打包命令,生成生产环境的静态文件(可直接部署到服务器 / 静态托管平台,如 GitHub Pages、Netlify):
# 打包项目,生成dist目录
npm run build
打包完成后,项目根目录会生成dist文件夹,里面是可直接部署的静态文件(HTML/CSS/JS)。
五、样式优化与交互提升(新手易上手,提升项目质感)
- 输入框聚焦样式:给输入框添加
focus伪类,修改边框颜色,提升交互反馈。 - 按钮状态优化:给 “清空已完成” 按钮添加
disabled状态,无已完成待办时置灰,禁止点击。 - 筛选按钮高亮:给当前选中的筛选按钮添加
active类,修改背景色,明确当前筛选状态。 - 过渡动画:给待办项添加单个动画,给列表添加批量移动 / 增删动画,避免页面生硬变化。
- 移动端适配:通过媒体查询,将 PC 端的横向布局改为移动端的纵向布局,避免内容挤压。
- 空内容拦截:添加待办、编辑待办时,拦截空内容,给出提示,提升用户体验。
- 删除确认:删除待办时添加
confirm确认框,避免误删。
六、避坑点总结(Vue3 新手常踩的坑,附解决方案)
-
坑 1:Pinia 仓库数据刷新后丢失
- 问题表现:添加待办后,刷新页面数据消失。
- 解决方案:安装并注册
pinia-plugin-persistedstate插件,在仓库中添加persist: true,开启持久化(默认存储在 localStorage)。
-
坑 2:子组件直接修改 Props 报错
- 问题表现:子组件中直接修改
props.todo.title,控制台报错 “Avoid mutating a prop directly”。 - 解决方案:子组件中定义临时变量(如
editTitle),接收 Props 的值,修改临时变量,通过 Emits 将数据传递给父组件,由父组件调用 Pinia 方法修改数据。
- 问题表现:子组件中直接修改
-
坑 3:Vue3 过渡动画
<TransitionGroup>不生效- 问题表现:添加 / 删除待办项时,无动画效果。
- 解决方案:① 给
<TransitionGroup>设置tag属性(如tag="div");② 给循环的子组件设置唯一的key;③ 编写对应的过渡样式(enter-from/enter-to/leave-from/leave-to)。
-
坑 4:setup 语法糖中无法使用
this- 问题表现:在 setup 中写
this.todoStore,控制台报错 “this is undefined”。 - 解决方案:Vue3 组合式 API(setup 语法糖)中无
this,直接引入并初始化 Pinia 仓库,直接使用变量 / 方法即可。
- 问题表现:在 setup 中写
-
坑 5:Vite 启动项目后,浏览器无法访问
- 问题表现:执行
npm run dev后,浏览器打开localhost:5173无法访问。 - 解决方案:① 检查端口是否被占用,可修改
vite.config.js中的端口;② 用http://127.0.0.1:5173/访问,而非localhost;③ 重新安装依赖(删除node_modules和package-lock.json,重新执行npm install)。
- 问题表现:执行
-
坑 6:计算属性筛选待办列表不更新
- 问题表现:修改待办完成状态后,筛选列表不刷新。
- 解决方案:确保计算属性依赖的是响应式数据(Pinia 中的
ref/reactive,Vue3 中的ref/reactive),本次实战中todoStore.todoList是ref定义的响应式数组,满足要求。
七、拓展延伸(Vue3 新手进阶方向)
本次实战实现了基础的待办清单,新手可在此基础上拓展功能,提升 Vue3 开发能力:
- 添加待办分类:给待办添加分类(工作 / 生活 / 学习),实现分类筛选、分类统计。
- 添加待办截止时间:使用
dayjs处理时间,添加截止时间,显示过期提醒。 - 添加待办优先级:给待办设置优先级(高 / 中 / 低),按优先级排序。
- 部署项目:将打包后的
dist目录部署到 GitHub Pages、Netlify、Vercel 等静态托管平台,实现线上访问。 - 改为 TypeScript 版本:将项目中的 JavaScript 改为 TypeScript,添加类型注解,提升代码可维护性。
- 使用 Vue Router:添加路由,实现待办列表页、待办详情页的跳转。
- 添加动画效果:使用
animate.css添加更多动画效果,提升页面交互。
八、总结
- 本次实战用Vite快速搭建 Vue3 项目,全程使用组合式 API(setup 语法糖) 开发,替代 Vue2 的选项式 API,代码更聚合、更易维护。
- 通过Pinia实现状态管理,替代 Vuex,配置简单,支持持久化,完美适配 Vue3。
- 掌握了 Vue3 的核心知识点:Props/Emits 组件通信、computed 计算属性、watch 监听器、过渡动画、响应式数据(ref/reactive)。
- 实现了功能完整的待办清单,包含增删改查、筛选、统计、持久化、动画等,代码可直接复刻到自己的项目中。
- 解决了 Vue3 新手常踩的坑,如 Pinia 持久化、子组件修改 Props、过渡动画不生效等,为后续 Vue3 项目开发打下基础。
本次实战的代码简洁、注释详细,新手可跟随步骤一步步实现,完成后能快速掌握 Vue3 的基础开发流程和核心知识点,是 Vue3 入门的最佳实战案例之一。