一、项目效果演示

本次实战搭建一个功能完整的 Vue3 待办清单,包含核心功能 + 拓展功能,适配 PC / 移动端,最终效果如下:

  1. 核心功能:添加待办、删除待办、标记完成 / 未完成、筛选待办(全部 / 已完成 / 未完成)
  2. 拓展功能:待办内容编辑、清空所有已完成待办、待办数量统计、本地存储持久化
  3. 交互体验:添加 / 删除 / 完成待办的过渡动画、输入框防抖、空内容提交拦截、适配移动端的样式布局
  4. 技术亮点:全程使用 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 核心知识点,避免开发中混淆,后续步骤会逐一对应使用:

  1. Vue3 组合式 API(setup 语法糖) :无需写 export default,直接定义变量 / 方法,代码更聚合,替代 Vue2 的选项式 API(data/methods/computed)
  2. Pinia:Vue3 官方状态管理库,替代 Vuex,无需配置模块,创建 /store/ 使用三步到位,支持持久化
  3. Vue3 内置指令v-model(双向绑定)、v-for(循环渲染)、v-bind(属性绑定)、v-on(事件绑定,简写 @)、v-show(条件显示)
  4. 计算属性(computed) :处理待办数量统计、筛选待办列表,缓存计算结果,提升性能
  5. 器(watch) :待办状态变化,实现本地存储持久化
  6. Vue3 过渡动画<TransitionGroup>实现待办项的添加 / 删除动画,提升交互
  7. 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

启动成功后,浏览器打开,看到 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:项目测试与打包(上线前必备)

  1. 本地测试:开发服务器已启动(npm run dev),在浏览器中测试所有功能,确保无 bug(添加 / 删除 / 编辑 / 筛选 / 清空 / 持久化),刷新页面后数据不丢失。
  2. 项目打包:测试通过后,执行打包命令,生成生产环境的静态文件(可直接部署到服务器 / 静态托管平台,如 GitHub Pages、Netlify):
# 打包项目,生成dist目录
npm run build

打包完成后,项目根目录会生成dist文件夹,里面是可直接部署的静态文件(HTML/CSS/JS)。

五、样式优化与交互提升(新手易上手,提升项目质感)

  1. 输入框聚焦样式:给输入框添加focus伪类,修改边框颜色,提升交互反馈。
  2. 按钮状态优化:给 “清空已完成” 按钮添加disabled状态,无已完成待办时置灰,禁止点击。
  3. 筛选按钮高亮:给当前选中的筛选按钮添加active类,修改背景色,明确当前筛选状态。
  4. 过渡动画:给待办项添加单个动画,给列表添加批量移动 / 增删动画,避免页面生硬变化。
  5. 移动端适配:通过媒体查询,将 PC 端的横向布局改为移动端的纵向布局,避免内容挤压。
  6. 空内容拦截:添加待办、编辑待办时,拦截空内容,给出提示,提升用户体验。
  7. 删除确认:删除待办时添加confirm确认框,避免误删。

六、避坑点总结(Vue3 新手常踩的坑,附解决方案)

  1. 坑 1:Pinia 仓库数据刷新后丢失

    • 问题表现:添加待办后,刷新页面数据消失。
    • 解决方案:安装并注册pinia-plugin-persistedstate插件,在仓库中添加persist: true,开启持久化(默认存储在 localStorage)。
  2. 坑 2:子组件直接修改 Props 报错

    • 问题表现:子组件中直接修改props.todo.title,控制台报错 “Avoid mutating a prop directly”。
    • 解决方案:子组件中定义临时变量(如editTitle),接收 Props 的值,修改临时变量,通过 Emits 将数据传递给父组件,由父组件调用 Pinia 方法修改数据。
  3. 坑 3:Vue3 过渡动画<TransitionGroup>不生效

    • 问题表现:添加 / 删除待办项时,无动画效果。
    • 解决方案:① 给<TransitionGroup>设置tag属性(如tag="div");② 给循环的子组件设置唯一的key;③ 编写对应的过渡样式(enter-from/enter-to/leave-from/leave-to)。
  4. 坑 4:setup 语法糖中无法使用this

    • 问题表现:在 setup 中写this.todoStore,控制台报错 “this is undefined”。
    • 解决方案:Vue3 组合式 API(setup 语法糖)中this,直接引入并初始化 Pinia 仓库,直接使用变量 / 方法即可。
  5. 坑 5:Vite 启动项目后,浏览器无法访问

    • 问题表现:执行npm run dev后,浏览器打开localhost:5173无法访问。
    • 解决方案:① 检查端口是否被占用,可修改vite.config.js中的端口;② 用访问,而非localhost;③ 重新安装依赖(删除node_modulespackage-lock.json,重新执行npm install)。
  6. 坑 6:计算属性筛选待办列表不更新

    • 问题表现:修改待办完成状态后,筛选列表不刷新。
    • 解决方案:确保计算属性依赖的是响应式数据(Pinia 中的ref/reactive,Vue3 中的ref/reactive),本次实战中todoStore.todoListref定义的响应式数组,满足要求。

七、拓展延伸(Vue3 新手进阶方向)

本次实战实现了基础的待办清单,新手可在此基础上拓展功能,提升 Vue3 开发能力:

  1. 添加待办分类:给待办添加分类(工作 / 生活 / 学习),实现分类筛选、分类统计。
  2. 添加待办截止时间:使用dayjs处理时间,添加截止时间,显示过期提醒。
  3. 添加待办优先级:给待办设置优先级(高 / 中 / 低),按优先级排序。
  4. 部署项目:将打包后的dist目录部署到 GitHub Pages、Netlify、Vercel 等静态托管平台,实现线上访问。
  5. 改为 TypeScript 版本:将项目中的 JavaScript 改为 TypeScript,添加类型注解,提升代码可维护性。
  6. 使用 Vue Router:添加路由,实现待办列表页、待办详情页的跳转。
  7. 添加动画效果:使用animate.css添加更多动画效果,提升页面交互。

八、总结

  1. 本次实战用Vite快速搭建 Vue3 项目,全程使用组合式 API(setup 语法糖) 开发,替代 Vue2 的选项式 API,代码更聚合、更易维护。
  2. 通过Pinia实现状态管理,替代 Vuex,配置简单,支持持久化,完美适配 Vue3。
  3. 掌握了 Vue3 的核心知识点:Props/Emits 组件通信、computed 计算属性、watch 器、过渡动画、响应式数据(ref/reactive)。
  4. 实现了功能完整的待办清单,包含增删改查、筛选、统计、持久化、动画等,代码可直接复刻到自己的项目中。
  5. 解决了 Vue3 新手常踩的坑,如 Pinia 持久化、子组件修改 Props、过渡动画不生效等,为后续 Vue3 项目开发打下基础。

本次实战的代码简洁、注释详细,新手可跟随步骤一步步实现,完成后能快速掌握 Vue3 的基础开发流程和核心知识点,是 Vue3 入门的最佳实战案例之一。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com