以观书法
108.85M · 2026-02-05
本次实战搭建一个功能完整的 Vue3 待办清单,包含核心功能 + 拓展功能,适配 PC / 移动端,最终效果如下:
node -v/npm -v验证)# 1. 全局安装Vite(首次安装)
npm install -g create-vite
# 2. 验证Vite安装
vite -v
提前梳理本次实战用到的 Vue3 核心知识点,避免开发中混淆,后续步骤会逐一对应使用:
v-model(双向绑定)、v-for(循环渲染)、v-bind(属性绑定)、v-on(事件绑定,简写 @)、v-show(条件显示)<TransitionGroup>实现待办项的添加 / 删除动画,提升交互全程命令行执行,选择 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
启动成功后,浏览器打开,看到 Vite+Vue3 默认页面,说明项目搭建成功。
搭建完成后,删除 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配置(无需修改)
修改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')
创建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
})
修改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;
}
}
创建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>
修改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>
npm run dev),在浏览器中测试所有功能,确保无 bug(添加 / 删除 / 编辑 / 筛选 / 清空 / 持久化),刷新页面后数据不丢失。# 打包项目,生成dist目录
npm run build
打包完成后,项目根目录会生成dist文件夹,里面是可直接部署的静态文件(HTML/CSS/JS)。
focus伪类,修改边框颜色,提升交互反馈。disabled状态,无已完成待办时置灰,禁止点击。active类,修改背景色,明确当前筛选状态。confirm确认框,避免误删。坑 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
this.todoStore,控制台报错 “this is undefined”。this,直接引入并初始化 Pinia 仓库,直接使用变量 / 方法即可。坑 5:Vite 启动项目后,浏览器无法访问
npm run dev后,浏览器打开localhost:5173无法访问。vite.config.js中的端口;② 用访问,而非localhost;③ 重新安装依赖(删除node_modules和package-lock.json,重新执行npm install)。坑 6:计算属性筛选待办列表不更新
ref/reactive,Vue3 中的ref/reactive),本次实战中todoStore.todoList是ref定义的响应式数组,满足要求。本次实战实现了基础的待办清单,新手可在此基础上拓展功能,提升 Vue3 开发能力:
dayjs处理时间,添加截止时间,显示过期提醒。dist目录部署到 GitHub Pages、Netlify、Vercel 等静态托管平台,实现线上访问。animate.css添加更多动画效果,提升页面交互。本次实战的代码简洁、注释详细,新手可跟随步骤一步步实现,完成后能快速掌握 Vue3 的基础开发流程和核心知识点,是 Vue3 入门的最佳实战案例之一。