Vue3组合式API+Pinia实战:从零搭建待办清单应用
发布时间:2026/9/10 23:29:37 作者:尧图编辑部 阅读量:1,286

最近带几个学前端的朋友做小项目我发现大多数人卡住的地方往往不是单个语法点而是不知道怎么把组合式 API、Pinia 状态管理、单文件组件这些零散的东西串到一个完整项目里。所以这次我挑了待办清单这个经典到不能再经典的项目用 Vue3 组合式 API Pinia 从 0 走一遍完整流程从创建项目、设计目录、写状态管理到组件拆分、数据持久化每一个环节都给出可直接复制的代码和选型理由。很多教程只告诉你怎么写但很少告诉你为什么这么写。这篇文章我会把关键决策背后的逻辑一并讲清楚比如为什么用 Pinia 而不是 Vuex为什么某些数据放 store、某些数据放组件内部为什么storeToRefs能解决响应式丢失的问题。待办清单麻雀虽小但增删改查、筛选统计、持久化、组件通信全部覆盖把它吃透你基本就能拿下中后台管理系统 80% 的常见开发场景。1. 整体设计为什么用组合式 API Pinia 来做待办清单1.1 待办清单是最适合练手的项目没有之一很多人觉得待办清单太简单不屑于做这其实是个误区。一个功能完整的待办清单需要覆盖数据的新增、删除、修改、查询、状态切换、条件筛选、数量统计、本地存储这些恰好就是业务系统最核心的增删改查能力。我在设计这个项目时特意让功能范围控制在刚好能讲透核心概念的边界上。功能太少体现不出状态管理的价值功能太多容易让新手淹没在边缘逻辑里。最终敲定的功能清单是这样输入框添加待办支持回车和按钮两种添加方式待办列表展示点击复选框切换完成状态双击待办文字进入编辑模式支持修改内容单条删除和清空已完成全部 / 进行中 / 已完成三种筛选底部显示未完成数量数据持久化到 localStorage刷新不丢失1.2 技术选型对比为什么选 Pinia 而不是 Vuex在状态管理方案上我直接选了 Pinia。这里先跟还不熟悉生态的朋友解释一下背景Vuex 是 Vue 官方早期的状态管理库Pinia 是 Vue 官方团队推出的新一代状态管理库vue3 官方文档中 Pinia 已经是默认推荐方案。Pinia 相比 Vuex 做了一系列简化我在项目里体会最深的有四点去掉了 mutations同步修改状态的 action 直接写就行少一层概念负担对 TypeScript 的推导非常友好不用写一堆繁琐的类型声明支持 setup store 语法可以在 store 里使用组合式 API心智模型跟组件统一官方 DevTools 支持到位时间旅行调试、状态快照都好用这里补充一个实际观点选型不只是选更先进的而是要选让团队和项目写起来更舒服的。如果项目已经用 Vuex 稳定跑了两三年没必要为了迁移而迁移但新项目没历史包袱直接上 Pinia 是目前性价比最高的选择。对比维度VuexOptions 风格Piniasetup store状态修改state mutations actionsstate actionsTypeScript 支持需要额外类型体操天然友好代码组织按 state / getters / mutations 分块按逻辑函数组织DevTools支持支持且体验更好学习成本概念多概念少接近自己写 composable1.3 组合式 API 的核心价值让相关代码聚在一起选项式 API 的写法是按选项类型组织代码数据放 data、方法放 methods、计算属性放 computed组合式 API 的写法是按逻辑关注点组织代码跟某个功能相关的 state、computed、方法都放一起。举一个很典型的例子在选项式 API 里如果我要实现筛选待办这个功能需要去 data 里找 filter 变量去 computed 里找 filteredTodos去 methods 里找切换筛选的方法逻辑离得很远。组合式 API 里这些全都写在一个 store 内一目了然。更重要的是组合式 API 让逻辑复用变成了普通函数调用。后续如果你想把这个项目的待办逻辑抽出来给另一个页面用直接把 store 文件拷过去或者封装成 composable 即可这比把逻辑从选项式组件里抠出来要容易得多。2. 从 0 搭建项目环境准备与目录规划2.1 使用 Vite 初始化项目创建项目首选 Vite这是 Vue3 生态当前的标配构建工具。相比 WebpackVite 基于原生 ESModule开发服务器启动速度快热更新也是毫秒级尤其在依赖多的大项目里优势更明显。在命令行执行以下命令npm create vitelatest vue3-todo-pinia按照提示选择 Vue 框架然后选择 JavaScript 还是 TypeScript。我个人建议新手先用 JavaScript 把核心概念跑通后续再切 TypeScript 加深理解。工程创建好之后cd vue3-todo-pinia npm install npm install pinia安装完 Pinia 之后打开src/main.js把 Pinia 实例注册到应用上import { createApp } from vue import { createPinia } from pinia import App from ./App.vue const app createApp(App) app.use(createPinia()) app.mount(#app)注意app.use(createPinia())这一步必须要写在mount之前否则组件里拿不到 store 实例。2.2 目录结构划分提前为可维护性铺路项目虽小但目录结构我建议从一开始就按可扩展的标准来。这不是形式主义而是让你养成好习惯一个中后台项目少说几十个文件目录乱了的后果就是后续改一个功能要找遍全项目。src/ ├── main.js # 入口文件 ├── App.vue # 根组件 ├── stores/ # Pinia 状态 │ └── todos.js └── components/ ├── TodoInput.vue # 输入框组件 ├── TodoList.vue # 待办列表组件 ├── TodoItem.vue # 单条待办组件 └── TodoFilter.vue # 筛选栏组件你可能会问这么小的项目把代码全写进 App.vue 不是更省事吗确实是省事但代价是组件没法复用逻辑全挤在一个文件里后续加功能会越来越痛苦。拆组件的过程其实就是职责划分的练习每个组件干好自己的事对外通过 props 和事件通信这是 Vue 组件化的核心思想。我实际写完这个项目之后发现合理的拆分帮了大忙。比如双击编辑的逻辑被封装在 TodoItem 内部App.vue 完全不需要关心编辑状态是怎么管理的筛选逻辑放在 TodoFilter它只是告诉 store我要切换筛选条件了至于筛选结果怎么计算那是 store 内部的事。3. 组合式 API 实战状态与逻辑的组织方式3.1 响应式状态的核心ref 与 reactive 的取舍在组合式 API 里创建响应式数据有两个基础工具ref和reactive。ref主要用于基本类型也可以包裹对象访问时需要带.valuereactive只能用于对象类型访问时不需要.value用一个生活化类比来理解ref是一个带安全盖的盒子盒子里的东西基本类型不能直接触摸必须开盖.value才能拿到reactive则是直接暴露在外的对象直接操作属性就行。在待办项目中列表数据用ref比较合适因为我要在 action 里整体替换它比如todos.value newTodos。如果换成reactive虽然也能用但在某些场景下比如从 localStorage 读数据后整体赋值需要额外注意方法代码会绕一些。我的经验法则非常简单基本类型、需要整体替换的值用ref深层嵌套对象比如表单对象用reactive可以少写很多.value3.2 用 computed 处理派生状态避免手动维护待办列表的筛选结果和未完成数量都属于派生状态它们不应该是独立存储的数据而应该基于todos和filter实时计算出来。比如未完成数量如果不使用 computed写完toggleTodo你就得想着手动更新一下剩余数量既要存储 todos 又要存储 count很容易出现数据不一致。computed 的设计思路就是你给我一个结果对这个结果的一切更新都基于原始数据自动推导这天然避免了状态不同步的问题。在 Pinia 的 setup store 里computed 充当的角色就是 Vuex 里的 gettersexport const useTodosStore defineStore(todos, () { const todos ref([...]) const filter ref(all) const remainingCount computed(() { return todos.value.filter(todo !todo.completed).length }) const filteredTodos computed(() { if (filter.value active) { return todos.value.filter(todo !todo.completed) } if (filter.value completed) { return todos.value.filter(todo todo.completed) } return todos.value }) return { todos, filter, remainingCount, filteredTodos } })注意一点remainingCount和filteredTodos虽然是 computed但在模板或者组件里它们是作为响应式引用存在的也就是用storeToRefs取出来之后直接拿到的是计算后的值。3.3 添加与删除的逻辑为什么放在 action 里有人会问修改状态的逻辑直接放在组件里不行吗当然可以但那样状态修改就散落在各个组件后续排查问题需要一个个组件去翻。把逻辑收敛到 store 的 action 里有两个实际好处第一组件只负责调用addTodo不关心这个操作内部是怎么 push、怎么校验的第二如果多个组件都要执行同一操作比如列表页和快捷添加组件都要添加待办那么它们调用同一个 action 就能保证逻辑一致。看添加待办的实现const addTodo (title) { const text title.trim() if (!text) return todos.value.push({ id: Date.now(), title: text, completed: false }) }我使用Date.now()作为 id在小项目里够用如果到生产环境建议换成百度的 nanoid 或者后端返回的 id。trim()这步很重要输入全是空格时加上这个判断就能防止添加空待办。4. Pinia 状态管理从零开始写一个 store4.1 setup store 完整代码逻辑聚合的参考实现以下是本项目的核心完整版src/stores/todos.js。这个代码我实际跑了很多遍注释里也标了一些关键要点你可以直接抄去用import { ref, computed } from vue import { defineStore } from pinia const STORAGE_KEY vue3-todo-pinia export const useTodosStore defineStore(todos, () { // state const todos ref([]) const filter ref(all) // getters const remainingCount computed(() { return todos.value.filter(todo !todo.completed).length }) const filteredTodos computed(() { switch (filter.value) { case active: return todos.value.filter(todo !todo.completed) case completed: return todos.value.filter(todo todo.completed) default: return todos.value } }) // actions const addTodo (title) { const text title.trim() if (!text) return { success: false, reason: empty } todos.value.push({ id: Date.now(), title: text, completed: false }) return { success: true } } const toggleTodo (id) { const todo todos.value.find(item item.id id) if (todo) { todo.completed !todo.completed } } const removeTodo (id) { todos.value todos.value.filter(item item.id ! id) } const clearCompleted () { todos.value todos.value.filter(item !item.completed) } const setFilter (value) { filter.value value } // 初始化时从 localStorage 读取数据 const initFromStorage () { const saved localStorage.getItem(STORAGE_KEY) if (saved) { todo s.value JSON.parse(saved) } } initFromStorage() // 订阅状态变化自动写入 localStorage todos.$subscribe((mutation, state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)) }) return { todos, filter, remainingCount, filteredTodos, addTodo, toggleTodo, removeTodo, clearCompleted, setFilter } })这里有一个很多教程不会细讲的点$subscribe。Pinia 的 store 实例自带$subscribe方法可以监听 state 的变化并执行回调。我在持久化这步用了它这样不管是添加、删除、切换状态只要 todos 变了localStorage 就会自动更新完全不需要在每个 action 里手动调用存储方法。4.2 在组件中正确使用 store注意响应式不被破坏在组件里使用 store 有两种方式const store useTodosStore() const { todos, filteredTodos, remainingCount } storeToRefs(store) const { addTodo, toggleTodo, clearCompleted } store这里是一个高频踩坑点不要直接解构 store 的 state。// 错误写法直接解构 state 会丢失响应性 const { todos, remainingCount } store为什么Pinia 把 reactive 对象包装在 store 上直接解构拿到的是那个时刻的值的副本后续 store 内部变化不会再通知到这个副本。正确做法是用 Pinia 提供的storeToRefs它专门针对 store 的 ref 和 computed 做了解包处理解构出来的属性保持响应式。对于 action 方法则可以直接解构因为方法不涉及响应式绑定调用时this已经被 Pinia 绑定到 store 实例上。4.3 页面刷新后数据不丢localStorage 持久化的两种方案持久化我见过不少实现方式这里列出两种主流方案你可以根据团队情况选择方案一手动监听 同步初始化本项目使用这种方案的好处是零依赖、逻辑透明适合核心逻辑还不多的项目。实现分两步初始化时从 localStorage 读取数据然后用$subscribe监听变化写入数据。方案二使用pinia-plugin-persistedstate插件npm i pinia-plugin-persistedstate插件化方案的好处是配置简单只需要在创建 Pinia 时传入插件然后在 store 定义里加一个persist属性。但插件本身帮你隐藏了序列化、反序列化、存储时机等细节如果后续要针对某些数据自定义存储策略你还得去看插件文档。对新手来说手工实现一次持久化反而有助于理解整个流程。这里提醒一个兼容问题浏览器隐私模式下 localStorage 有被禁用的可能稳妥做法是读写 localStorage 时用 try...catch 包裹避免整个应用报错崩掉。5. 组件拆解与事件通信父子组件怎么配合5.1 组件划分思路按职责让每个组件只管一件事组件拆分的核心原则是单一职责。我按页面区块把功能分给了四个组件它们各有分工TodoInput.vue负责输入框收集用户输入触发添加事件TodoList.vue负责列表循环渲染不关心单条待办的内部逻辑TodoItem.vue负责展示单条待办处理勾选、删除、双击编辑TodoFilter.vue负责筛选栏渲染三个筛选按钮这种拆分让组件之间的通信路径变得清晰。数据流是单向的store 是唯一的数据源组件通过调用 action 来修改数据而不是自己在内部 copy 一份数据。拿 TodoItem 举例它接收 todo 对象作为 prop展示标题和勾选状态用户点击勾选时它不直接修改 todo 对象而是 emit 一个事件给上层由上层调用 store 的 action 去改。这种单向数据流的约束能让状态变化的来源可追踪出问题时不用猜是哪个组件改的。5.2 defineProps 与 defineEmits 的实战用法在script setup语法里defineProps和defineEmits是编译宏不需要引入直接可用。TodoItem 的完整实现如下script setup import { ref, nextTick } from vue const props defineProps({ todo: { type: Object, required: true } }) const emit defineEmits([toggle, remove, update]) const editing ref(false) const editText ref() const handleToggle () { emit(toggle, props.todo.id) } const handleRemove () { emit(remove, props.todo.id) } const startEdit () { editing.value true editText.value props.todo.title nextTick(() { // 自动聚焦输入框 document.getElementById(edit-${props.todo.id})?.focus() }) } const saveEdit () { const text editText.value.trim() if (text) { emit(update, { id: props.todo.id, title: text }) } editing.value false } const cancelEdit () { editing.value false } /script template li :class{ completed: todo.completed } input typecheckbox :checkedtodo.completed changehandleToggle / input v-ifediting :idedit-${todo.id} v-modeleditText keyup.entersaveEdit keyup.esccancelEdit blursaveEdit / span v-else dblclickstartEdit {{ todo.title }} /span button clickhandleRemove删除/button /li /template有几个实现细节值得说明。第一editing和editText是组件的内部状态它们只服务于这个组件要不要进入编辑态这个 UI 问题不需要放进 store第二编辑结束后触发update事件由列表组件的父级或 store action去真正修改数据第三blur和enter都会触发saveEdit但如果有重复执行风险可以在 saveEdit 里加个if (editing.value)判断。5.3 双击编辑的焦点管理为什么需要 nextTick双击待办标题进入编辑态的交互很常见但如果处理不好会出现一个尴尬情况编辑框被渲染出来了但光标没自动聚焦到输入框上用户体验很别扭。问题出在 DOM 更新时机上。editing.value true改变了响应式状态但 Vue 不会立刻把编辑框插入 DOM而是在下一个tick也就是下一次事件循环才做 DOM 更新。如果直接在赋值后立刻用document.getElementById()去拿元素拿到的是null。解决方案就是nextTickconst startEdit () { editing.value true editText.value props.todo.title nextTick(() { document.getElementById(edit-${props.todo.id})?.focus() }) }nextTick的回调会在 DOM 更新完成后执行这时候再去 focus 就能保证元素一定存在。这里用可选链?.做容错即使元素没找到也不会抛错。5.4 列表组件的事件中转让组件层级保持干净TodoList 在中间扮演的是中转站角色。它遍历 store 的 filteredTodos然后给每个 TodoItem 绑定事件处理函数script setup import TodoItem from ./TodoItem.vue import { useTodosStore } from ../stores/todos import { storeToRefs } from pinia const store useTodosStore() const { filteredTodos } storeToRefs(store) const handleToggle (id) store.toggleTodo(id) const handleRemove (id) store.removeTodo(id) const handleUpdate ({ id, title }) store.updateTodo(id, title) /script template ul TodoItem v-fortodo in filteredTodos :keytodo.id :todotodo togglehandleToggle removehandleRemove updatehandleUpdate / /ul /template这里 store 的 updateTodo 需要补上它对应编辑保存的逻辑const updateTodo (id, title) { const todo todos.value.find(item item.id id) if (todo) { todo.title title } }这样做的好处是 TodoItem 不用知道 store 的存在它的 props 和 emit 就是它的全部对外接口可复用性最高。假如未来待办的来源从 Pinia 换成了 propsTodoItem 也完全不用改。筛选栏组件就更简单了它只是把当前筛选条件展示成按钮点击时触发 store 里的setFilterscript setup import { storeToRefs } from pinia import { useTodosStore } from ../stores/todos const store useTodosStore() const { filter } storeToRefs(store) const filters [ { label: 全部, value: all }, { label: 进行中, value: active }, { label: 已完成, value: completed } ] /script template div classfilter-bar button v-foritem in filters :keyitem.value :class{ active: filter item.value } clickstore.setFilter(item.value) {{ item.label }} /button /div /template筛选栏自身的当前按钮高亮状态是从 store 的 filter 得来的点击时更新 store然后 store 里的 filteredTodos 自动重新计算列表跟着更新整个数据流闭环不需要额外的手动通知。6. 常见问题排查与避坑经验6.1 解构 store 后页面不更新首选排查是不是丢了响应式这个问题出现的频率极高症状就是console 里打印 store 的数据有变化但页面 UI 纹丝不动。最典型的写法是// 错误写法 const { todos, remainingCount } useTodosStore()刚才已经说过这会让 todos 和 remainingCount 变成一次性的快照。遇到页面不更新第一件事就是检查你有没有用storeToRefs。6.2todos.$subscribe不触发或者只想监听某个字段变化$subscribe默认是监听所有 state 的变化。如果 store 里有多个 state比如 todos 和 filter但你只想在 todos 变化时写 localStorage可以在回调里判断 mutation 的类型todos.$subscribe((mutation, state) { if (mutation.type direct) { // 只处理直接修改 state 的情况 } localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)) })如果担心$subscribe在某些场景被循环调用比如回调里又修改了 store 的 state可以在回调内设置一个防抖标记或者把存储操作放到setTimeout里避免写入频繁。6.3 v-for 列表渲染的 key 选择是新手最容易忽略的坑在TodoList.vue中我用了v-fortodo in filteredTodos :keytodo.id。这个 key 必须是稳定且唯一的标识。为什么这么重要Vue 的 diff 算法通过 key 来识别节点是否复用。如果使用数组下标做 key当你在中间插入或删除一条待办时后续所有条目的下标都变了Vue 可能会把错误的节点做复用导致 DOM 状态错乱。典型症状是删除中间一条待办页面显示了重复内容或者输入框值串了。用id做 key 能保证每条数据有独立身份Vue 可以准确知道哪条被删了、哪条被改了。6.4 修改深层对象属性却无法触发视图更新在 toggleTodo 的实现里我直接修改了todo.completed这在 Vue3 里没问题因为ref内部会用 reactive 处理深层对象。但在某些特殊场景比如你从接口拿到一个对象数组往某个 item 里动态新增属性可能会发现视图不更新。Vue3 的响应式是基于 Proxy 的它在访问属性时会收集依赖理论上新增属性也能被拦截。但我遇到过一种情况用Object.assign(todo, { completed: true })可以正常触发更新而直接todo.completed true却不行原因通常是这个todo对象并不是响应式的它只是从普通数组里取出来的一个普通对象。如果怀疑对象失去了响应性可以打印isReactive(todo)来验证。这是一个很实用的排查思路。6.5 CSS 布局不生效flex 子项被压缩的问题待办输入框的常见 UI 是输入框 添加按钮横排展示。我给它的容器设置了display: flex发现按钮宽度正常但输入框被压缩得很难看。原因是 flex 布局里输入框默认的 flex-shrink 是 1空间不足时会被压缩。解决方式给输入框加flex: 1; min-width: 0;。很多人只写了 flex: 1 但忘了 min-width: 0在输入内容过长时依然会溢出。这是一个很基础但很容易踩的小坑。6.6 常见问题速查表现象可能原因解决办法解构 state 后 UI 不更新直接解构导致失去响应性用storeToRefs刷新页面数据丢失没有初始化时读取 localStorage初始化时JSON.parse读入添加按钮点击无效输入内容为纯空格检查是否做了trim()和空值校验列表删除后 DOM 错乱key 用了 index换成唯一 id编辑框不自动聚焦未等 DOM 更新就操作元素用nextTick包裹 focuslocalStorage 写入不生效手机隐私模式可能禁用try...catch 包裹存储操作$subscribe回调死循环回调内修改了 state使用防抖或增加修改条件判断6.7 排查响应式问题的调试技巧遇到状态异常不要只靠眼睛看页面。我常用的调试手段有三个第一在浏览器控制台打印 store 实例展开它的$state属性看实际存储的数据状态。$state是 Pinia 暴露出的可响应式状态这里的数据是真正的数据源。第二打开 Vue DevTools 的 Pinia 面板可以看到每个 store 的 state、getters、actions 以及状态变更的时间线。这一步能快速判断问题出在计算结果还是渲染层。第三如果界面显示的结果不对先确认你组合的 computed 是否正确。可以在 store 里临时写一个debugcomputed把中间结果直接渲染到模板里查看。最后分享一个我个人的体会学 Vue3 组合式 API Pinia最忌讳的就是只看文档不动手。这个待办清单项目你把每一步代码敲完、每个坑踩过一遍之后回头再看中后台系统里的模块拆分和状态设计会轻松很多。后续可以在这个项目基础上继续加功能比如给待办增加优先级分类、按创建时间排序、把状态存储换成一个模拟接口请求——每一次扩展都会让你对状态从哪来、到哪里去有更深的体感。