最近带几个学前端的朋友做小项目,我发现大多数人卡住的地方往往不是单个语法点,而是不知道怎么把组合式 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 是目前性价比最高的选择。
| 对比维度 | Vuex(Options 风格) | Pinia(setup store) |
|---|---|---|
| 状态修改 | state + mutations + actions | state + actions |
| TypeScript 支持 | 需要额外类型体操 | 天然友好 |
| 代码组织 | 按 state / getters / mutations 分块 | 按逻辑函数组织 |
| DevTools | 支持 | 支持且体验更好 |
| 学习成本 | 概念多 | 概念少,接近"自己写 composable" |
1.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 生态当前的标配构建工具。相比 Webpack,Vite 基于原生 ESModule,开发服务器启动速度快,热更新也是毫秒级,尤其在依赖多的大项目里优势更明显。
在命令行执行以下命令:
npm create vite@latest 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可以少写很多.value
3.2 用 computed 处理派生状态,避免手动维护
待办列表的"筛选结果"和"未完成数量"都属于派生状态:它们不应该是独立存储的数据,而应该基于todos和filter实时计算出来。
比如未完成数量,如果不使用 computed,写完toggleTodo你就得想着"手动更新一下剩余数量",既要存储 todos 又要存储 count,很容易出现数据不一致。computed 的设计思路就是"你给我一个结果,对这个结果的一切更新都基于原始数据自动推导",这天然避免了状态不同步的问题。
在 Pinia 的 setup store 里,computed 充当的角色就是 Vuex 里的 getters:
export 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 状态管理:从零开始写一个 store
4.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 type="checkbox" :checked="todo.completed" @change="handleToggle" /> <input v-if="editing" :id="`edit-${todo.id}`" v-model="editText" @keyup.enter="saveEdit" @keyup.esc="cancelEdit" @blur="saveEdit" /> <span v-else @dblclick="startEdit" > {{ todo.title }} </span> <button @click="handleRemove">删除</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。
解决方案就是nextTick:
const 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-for="todo in filteredTodos" :key="todo.id" :todo="todo" @toggle="handleToggle" @remove="handleRemove" @update="handleUpdate" /> </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 换成了 props,TodoItem 也完全不用改。
筛选栏组件就更简单了,它只是把当前筛选条件展示成按钮,点击时触发 store 里的setFilter:
<script 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 class="filter-bar"> <button v-for="item in filters" :key="item.value" :class="{ active: filter === item.value }" @click="store.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-for="todo in filteredTodos" :key="todo.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包裹 focus |
| localStorage 写入不生效 | 手机隐私模式可能禁用 | try...catch 包裹存储操作 |
$subscribe回调死循环 | 回调内修改了 state | 使用防抖或增加修改条件判断 |
6.7 排查响应式问题的调试技巧
遇到状态异常,不要只靠眼睛看页面。我常用的调试手段有三个:
第一,在浏览器控制台打印 store 实例,展开它的$state属性,看实际存储的数据状态。$state是 Pinia 暴露出的可响应式状态,这里的数据是"真正的数据源"。
第二,打开 Vue DevTools 的 Pinia 面板,可以看到每个 store 的 state、getters、actions 以及状态变更的时间线。这一步能快速判断"问题出在计算结果还是渲染层"。
第三,如果界面显示的结果不对,先确认你组合的 computed 是否正确。可以在 store 里临时写一个debugcomputed,把中间结果直接渲染到模板里查看。
最后分享一个我个人的体会:学 Vue3 组合式 API + Pinia,最忌讳的就是只看文档不动手。这个待办清单项目你把每一步代码敲完、每个坑踩过一遍之后,回头再看中后台系统里的模块拆分和状态设计,会轻松很多。后续可以在这个项目基础上继续加功能,比如给待办增加优先级分类、按创建时间排序、把状态存储换成一个模拟接口请求——每一次扩展都会让你对"状态从哪来、到哪里去"有更深的体感。