news 2026/11/3 21:01:36

Vue 3任务清单实战:组合式API与组件通信入门

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue 3任务清单实战:组合式API与组件通信入门

先把结论放这儿:这个 Vue 小 demo 值得每个刚上手 Vue 3 的人亲手敲一遍。它不做什么宏大叙事,就是一个任务清单,能加、能删、能改、能筛选、能记住上一次的状态。代码量不大,但把ref、computed、watch、组件拆分、props与emit、本地持久化这几件最常打交道的事全串了一遍。这篇代码介绍不打算逐行念代码,而是按我实际写下来的顺序,讲清楚每一处为什么这么写、哪里容易翻车、跑起来之后怎么验证。适合刚学完模板语法想找个东西练手的人,也适合写了几个月业务代码、想把状态管理边界重新理一遍的人。下面所有代码都可以直接复制到一个空目录里跑起来,不需要后端,不需要账号,浏览器打开就能用。

1. 先把这个 Vue 小 demo 的边界说清楚

1.1 需求范围:一个任务清单,四个动作,三种视图

动手之前我先把需求按“必须做”和“以后再说”切成两堆。必须做的只有四件事:新增任务、勾选完成、修改标题、删除任务。围绕这四件事再补三个视图状态:全部、未完成、已完成。搜索框和优先级排序属于“顺手加上去的”,因为它们能逼着我把computed的链式依赖写清楚,但如果时间不够,砍掉也不影响 demo 成立。

这个边界很重要。新手写 demo 最常见的毛病是第一天就想做标签、截止日期、子任务、拖拽排序,结果写到一半卡在组件通信上,最后连“新增一条数据”都没跑通。我的建议是把第一版控制在两百行以内,页面只有一个列表、一个输入框、一排筛选按钮。先把数据从输入框流到列表里,再把勾选状态写回数组,最后接上localStorage。每一步都能独立验证,出错时也容易定位是哪一层断了。

页面结构我拆成三块:顶部是统计和进度条,中间是输入区与工具栏,底部是任务列表。统计信息不单独存一份数据,全部从任务数组里算出来。这个决定后面会反复提到,因为它直接决定了你会不会写出“改了任务但统计没更新”的 bug。视图状态也只有三个值:all、active、done,用字符串枚举就够了,没必要上常量文件。

1.2 技术选型:为什么用 Vue 3 的组合式 API 而不是继续写选项式

选项式 API 不是不能用,任务清单这种规模用data、methods、computed写出来也能跑。但我选组合式 API 的理由很实际:业务逻辑可以抽到一个普通的.js文件里,脱离组件单独测试。任务清单的核心逻辑其实就四五个函数,把它们塞进setup里会让组件越来越胖,抽出来之后组件只负责“把数据摆到页面上”和“把用户动作传回去”。

构建工具用 Vite,原因是启动快、配置少,默认模板就把 Vue 单文件组件、热更新、生产构建配好了。我不建议在这个阶段手写 webpack 配置,时间应该花在理解响应式上,而不是和构建工具搏斗。状态管理不上 Pinia,一个页面的状态用composable足够,等出现跨页面共享再考虑,这个判断标准在最后一节会展开讲。

还有一个选型细节:用不用 TypeScript。我的做法是第一版用 JavaScript,把逻辑跑通之后再考虑迁移。原因是新手同时面对泛型、组件类型声明和响应式类型推导,很容易把“类型报错”误当成“逻辑错误”,排查成本翻倍。demo 的目标是建立手感,不是建立工程规范。

1.3 目录拆分:三个组件加一个 composable 就够了

目录结构我最终定成这样:

vue-task-demo/ ├── index.html ├── package.json ├── vite.config.js └── src/ ├── main.js ├── App.vue ├── styles.css ├── composables/ │ └── useTasks.js └── components/ ├── TaskInput.vue └── TaskItem.vue

App.vue是容器,持有useTasks返回的全部状态,负责把事件分发给具体函数。TaskInput.vue只关心“用户输入了什么、合不合法”,校验通过后把结果emit出去。TaskItem.vue只关心“怎么把一条任务显示出来”,勾选、删除、改名都往外抛事件。useTasks.js承担数据初始化、增删改查、筛选排序、持久化。

这个拆法的好处是每个文件都有明确的“不知道的事”。输入框不知道任务存在哪里,列表项也不知道统计怎么算。改样式时只动组件,改规则时只动 composable,互不牵连。坏处是多了一层事件转发,写起来没有单文件顺手。我的判断是:只要组件里出现了“既处理用户输入又直接改数组”的代码,就该拆了,否则后面加一个“撤销删除”功能时,你会发现改动点散落在三四个地方。

2. 核心代码的拆解方式:数据、视图、交互各归各

2.1 数据模型设计:id、done、priority 三个字段撑起全部逻辑

一条任务我最终只留五个字段:

{ id: 'a1b2c3', title: '把 demo 跑起来', done: false, priority: 'normal', createdAt: 1730000000000 }

id用来做列表的key和后续的查找,必须是稳定且唯一的值。很多人图省事用Date.now(),连续快速添加两条时时间戳可能相同,列表就会出现复用错乱。我的处理是优先用crypto.randomUUID(),拿不到时降级成时间戳加随机串:

function createId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID() } return Date.now().toString(36) + Math.random().toString(36).slice(2, 8) }

done用布尔值,不要用0/1。布尔值在模板里直接参与:class判断,省掉一次隐式转换。priority用三个字符串枚举,比数字可读,调试时一眼能看出是高还是低。createdAt存时间戳而不是格式化后的字符串,排序和后续做“今天新增几条”都方便,展示时再格式化。

标题长度我限制在 50 个字符。这个数字不是拍脑袋:输入框一行大约能显示二十多个汉字,50 个字在列表里最多占两行半,再长就会把操作按钮挤出可视区。限制放在提交时做,同时给输入框加maxlength,两层兜底。注意maxlength统计的是 UTF-16 码元,生僻字和表情符号可能占两个长度,真正严谨的做法是按Array.from(text).length计算,demo 里用简单方案就够。

2.2 派生状态用 computed,不要用 watch 硬同步

统计信息是最容易写错的地方。常见写法是再建一个stats对象,每次增删改都手动加减。这种写法在 demo 阶段看着能跑,一旦加上“批量清理已完成”,四个地方要同步改,漏一个就出现数字对不上。

我的做法是只维护tasks一个源头,其他全部用computed推出来:

const stats = computed(() => { const total = tasks.value.length const done = tasks.value.filter(t => t.done).length return { total, done, active: total - done, percent: total === 0 ? 0 : Math.round((done / total) * 100) } })

computed自带缓存,依赖不变就不会重算。这里依赖的是tasks.value和每个元素的done,Vue 的响应式系统能追踪到属性级别,勾选一条任务只会触发一次重新计算。进度条的百分比用Math.round取整,宽度直接绑到style上,不需要额外维护一个width变量。

筛选列表也是同样的思路。我一开始写过“点击筛选按钮时手动过滤数组并赋值给visibleTasks”,结果新增任务后忘了重新过滤,切到“未完成”看不到刚加的那条。改成computed之后,tasks、keyword、filter任何一个变化都会自动重算,这类 bug 从根上消失。

const filteredTasks = computed(() => { const kw = keyword.value.trim().toLowerCase() return tasks.value .filter(t => filter.value === 'all' || (filter.value === 'done' ? t.done : !t.done)) .filter(t => !kw || t.title.toLowerCase().includes(kw)) .sort((a, b) => b.createdAt - a.createdAt) })

这里有一个细节:sort会原地修改数组。上面链式调用中,filter已经返回了新数组,所以对结果排序不会动到原数据。如果你直接写tasks.value.sort(),就会改到源头,虽然页面看起来正常,但watch会收到一次不必要的变更,持久化也跟着多写一次。养成“先过滤再排序”的习惯,能避开这类隐蔽的副作用。

2.3 组件通信:props 向下、emit 向上,父组件只做状态调度

TaskItem的职责非常窄:接收一条任务对象,显示标题、优先级、完成状态,用户操作时把事件抛出去。它不直接改task.done,也不持有任务数组的引用。这样做的好处是列表项可以被随便复用,测试时传一个假对象就能渲染。

<script setup> import { computed } from 'vue' const props = defineProps({ task: { type: Object, required: true } }) const emit = defineEmits(['toggle', 'remove', 'rename']) const priorityLabel = computed(() => { const map = { high: '高', normal: '中', low: '低' } return map[props.task.priority] || '中' }) function handleRename() { const next = window.prompt('修改任务内容', props.task.title) if (next === null) return const text = next.trim() if (!text) return emit('rename', { id: props.task.id, title: text.slice(0, 50) }) } </script>

事件命名全部用小写加连字符的风格,模板里写@toggle、@remove,和原生事件区分开。父组件收到事件后统一调用 composable 里的函数,不在这里做业务判断。比如“删除后要不要弹确认框”这种决策放在App.vue,列表项只管“用户点了删除”。

props是只读的,这一点必须刻在脑子里。我见过有人在子组件里写props.task.done = !props.task.done,页面当时确实变了,因为对象是引用类型,但数据流已经乱了,后面加撤销功能时根本不知道是谁改的。正确的做法是抛事件,让持有数据的父组件去改。

3. 完整实操:从空目录到能跑起来的任务清单

3.1 初始化项目与依赖安装

确保本机 Node 版本在 18 以上,然后执行:

npm create vite@latest vue-task-demo -- --template vue cd vue-task-demo npm install npm run dev

终端会给出一个本地地址,浏览器打开能看到 Vite 的默认页面。这时候先把src/components/HelloWorld.vue和src/assets里的示例文件删掉,App.vue清空到只剩一个空模板。别急着写业务,先确认热更新是通的:改一下模板里的文字,浏览器不刷新就能变,说明环境没问题。

接着建目录。src/composables/useTasks.js和src/components/TaskInput.vue、src/components/TaskItem.vue三个文件先建空文件,styles.css放在src下,在main.js里引入。我习惯先让项目处于“能跑但什么都没渲染”的状态,再一块一块往上加,这样每次报错都能锁定在刚写的那一小段。

依赖方面不需要额外装 UI 库。任务清单的交互很简单,原生input、select、button足够,引一个组件库反而要花时间处理样式覆盖和按需引入。如果后面想练手,可以自己接一个图标库,但第一版保持零额外依赖,出问题时排查范围最小。

3.2 编写 useTasks.js:把业务逻辑从模板里抽出来

这个文件是整个 demo 的核心。我按“常量、初始化、状态、派生、动作、持久化、导出”的顺序写,读起来像一条流水线。

先定义存储键和种子数据。种子数据的作用是第一次打开时有东西可看,不然面对空列表很难判断渲染逻辑对不对。

import { ref, computed, watch } from 'vue' const STORAGE_KEY = 'vue-task-demo:tasks:v1' const seedTasks = [ { id: 'seed-1', title: '把项目跑起来', done: true, priority: 'high', createdAt: Date.now() - 86400000 }, { id: 'seed-2', title: '试着添加一条任务', done: false, priority: 'normal', createdAt: Date.now() - 3600000 }, { id: 'seed-3', title: '勾选后刷新页面看是否还在', done: false, priority: 'low', createdAt: Date.now() - 600000 } ]

存储键带:v1版本号,后面字段结构变了可以换键,老数据自然失效,不用写迁移逻辑。这是个小技巧,但能省掉“用户本地存着旧结构,新代码读出来字段全是undefined”的麻烦。

读取时要假设localStorage里的东西不可信,可能被手动改过,可能是上个版本留下的,也可能根本不是 JSON。所以要包try/catch,并且对每个字段做归一化:

function normalizeTask(item, index) { const priorityList = ['low', 'normal', 'high'] return { id: item && item.id ? String(item.id) : `fixed-${index}-${Date.now()}`, title: item && typeof item.title === 'string' ? item.title.slice(0, 50) : '未命名任务', done: Boolean(item && item.done), priority: item && priorityList.includes(item.priority) ? item.priority : 'normal', createdAt: Number(item && item.createdAt) || Date.now() } } function loadTasks() { try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) return seedTasks.map(normalizeTask) const parsed = JSON.parse(raw) if (!Array.isArray(parsed)) return seedTasks.map(normalizeTask) return parsed.filter(item => item && typeof item.title === 'string').map(normalizeTask) } catch (error) { console.warn('读取本地任务失败,已回退到默认数据', error) return seedTasks.map(normalizeTask) } }

归一化函数把每个字段都转成预期类型,缺什么补什么。filter那一步把明显不是任务的项剔掉,避免一条脏数据把整个列表渲染搞崩。

状态部分只有三个ref:

export function useTasks() { const tasks = ref(loadTasks()) const keyword = ref('') const filter = ref('all')

动作函数统一返回{ ok, message },方便调用方决定怎么提示,也方便后面写单元测试时断言:

function addTask(title, priority = 'normal') { const text = String(title ?? '').trim() if (!text) return { ok: false, message: '任务内容不能为空' } if (Array.from(text).length > 50) return { ok: false, message: '任务内容不要超过 50 个字' } tasks.value.unshift({ id: createId(), title: text, done: false, priority: ['low', 'normal', 'high'].includes(priority) ? priority : 'normal', createdAt: Date.now() }) return { ok: true } } function toggleTask(id) { const target = tasks.value.find(t => t.id === id) if (target) target.done = !target.done } function renameTask(id, title) { const text = String(title ?? '').trim() if (!text) return { ok: false, message: '标题不能为空' } const target = tasks.value.find(t => t.id === id) if (!target) return { ok: false, message: '任务不存在' } target.title = Array.from(text).slice(0, 50).join('') return { ok: true } } function removeTask(id) { tasks.value = tasks.value.filter(t => t.id !== id) } function clearDone() { tasks.value = tasks.value.filter(t => !t.done) }

注意removeTask和clearDone用的是重新赋值,而不是splice。两种写法在 Vue 3 里都能触发更新,但重新赋值让“新数组”这件事更明确,配合computed的依赖追踪也更直观。toggleTask和renameTask是直接改属性,因为对象本身还在数组里,Proxy 能拦截到属性变化。

持久化用watch监听整个数组,加一个简单的防抖,避免连续输入时频繁序列化:

let saveTimer = null watch(tasks, () => { clearTimeout(saveTimer) saveTimer = setTimeout(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks.value)) } catch (error) { console.warn('保存任务失败,可能是存储空间不足', error) } }, 200) }, { deep: true })

200 毫秒这个值是我实测下来比较舒服的区间:低于 100 毫秒基本等于没防抖,高于 500 毫秒又会在快速关闭页面时丢掉最后一次写入。localStorage同步写入会阻塞主线程,任务量上百条之后序列化成本就上来了,防抖能明显减少卡顿。

最后把需要暴露的东西返回:

return { tasks, keyword, filter, filteredTasks, stats, addTask, toggleTask, renameTask, removeTask, clearDone } }

这里不要把内部辅助函数也导出去,接口越小,后面重构越自由。

3.3 三个组件的代码与关键注释

TaskInput.vue负责收集输入并做第一层校验。注意它自己保留一份error状态,父组件不需要知道错误文案怎么显示:

<script setup> import { ref } from 'vue' const emit = defineEmits(['submit']) const title = ref('') const priority = ref('normal') const error = ref('') function handleSubmit() { const text = title.value.trim() if (!text) { error.value = '先写点内容再提交' return } if (Array.from(text).length > 50) { error.value = '最多 50 个字' return } emit('submit', { title: text, priority: priority.value }) title.value = '' error.value = '' } </script> <template> <form class="task-input" @submit.prevent="handleSubmit"> <input v-model="title" type="text" placeholder="今天要完成什么?" /> <select v-model="priority"> <option value="high">高</option> <option value="normal">中</option> <option value="low">低</option> </select> <button type="submit">添加</button> <p v-if="error" class="error">{{ error }}</p> </form> </template>

@submit.prevent阻止表单默认提交行为,这点不能漏。不加的话回车会刷新页面,看起来像“数据丢了”,其实是整个应用重新加载了。输入框没加maxlength,而是在提交时用Array.from计算真实字符数,这样可以给出更准确的提示。如果你更在意即时限制,也可以加maxlength="50",两层一起用。

App.vue作为容器,负责组装和转发:

<script setup> import { useTasks } from './composables/useTasks' import TaskInput from './components/TaskInput.vue' import TaskItem from './components/TaskItem.vue' const { keyword, filter, filteredTasks, stats, addTask, toggleTask, renameTask, removeTask, clearDone } = useTasks() function handleAdd(payload) { addTask(payload.title, payload.priority) } function handleRename(payload) { renameTask(payload.id, payload.title) } </script> <template> <main class="app"> <header class="app-header"> <h1>任务清单</h1> <p>{{ stats.done }} / {{ stats.total }} 已完成,剩余 {{ stats.active }} 项</p> <div class="progress"> <span :style="{ width: stats.percent + '%' }"></span> </div> </header> <TaskInput @submit="handleAdd" /> <section class="toolbar"> <input v-model="keyword" type="search" placeholder="搜索任务" /> <select v-model="filter"> <option value="all">全部</option> <option value="active">未完成</option> <option value="done">已完成</option> </select> <button type="button" @click="clearDone">清理已完成</button> </section> <TransitionGroup name="list" tag="ul" class="task-list"> <TaskItem v-for="task in filteredTasks" :key="task.id" :task="task" @toggle="toggleTask" @remove="removeTask" @rename="handleRename" /> </TransitionGroup> <p v-if="filteredTasks.length === 0" class="empty">没有符合条件的任务</p> </main> </template>

v-for的key绑的是task.id,不是索引。这一点后面会单独讲。TransitionGroup的tag指定成ul,这样列表语义还在,动画只是附加效果。

TaskItem.vue前面已经给过脚本部分,模板补上:

<template> <li class="task-item" :class="{ done: task.done }"> <label class="task-main"> <input type="checkbox" :checked="task.done" @change="$emit('toggle', task.id)" /> <span class="title">{{ task.title }}</span> </label> <span class="priority" :data-level="task.priority">{{ priorityLabel }}</span> <button type="button" class="ghost" @click="handleRename">改名</button> <button type="button" class="danger" @click="$emit('remove', task.id)">删除</button> </li> </template>

复选框用:checked加@change,而不是v-model。原因是数据源在父组件,子组件不应该直接双向绑定props。这种“受控组件”的写法多敲几次就习惯了,后面接表单库时也会更顺。

3.4 样式与过渡:别让动画拖慢交互

样式我保持极简,重点是可读性和操作区域够大:

.app { max-width: 680px; margin: 0 auto; padding: 32px 20px 60px; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: #1f2933; } .task-list { list-style: none; padding: 0; margin: 20px 0 0; } .task-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 1px solid #e4e7eb; border-radius: 10px; margin-bottom: 10px; background: #fff; } .task-item.done .title { text-decoration: line-through; color: #9aa5b1; } .task-main { flex: 1; display: flex; align-items: center; gap: 10px; cursor: pointer; } .priority[data-level="high"] { color: #c0392b; } .priority[data-level="normal"] { color: #7b8794; } .priority[data-level="low"] { color: #2f855a; } .list-enter-active, .list-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; } .list-enter-from, .list-leave-to { opacity: 0; transform: translateY(-6px); }

过渡时长我定在 180 毫秒,接近人眼感知的“即时反馈”上限。再长会显得拖沓,再短又看不出动画。注意没给list-leave-active加position: absolute,因为那会让离场元素脱离文档流,列表在删除瞬间会跳一下。demo 里保持简单,只做透明度和位移。

移动端还有一个细节:按钮可点击区域至少 40 像素见方。上面的padding加上默认行高基本能满足,如果换成图标按钮,记得手动补min-width和min-height,否则手指点不准。

3.5 本地持久化与首次加载的兜底

持久化看起来只是JSON.stringify一下,实际有三个坑。第一,localStorage只能存字符串,存对象会变成"[object Object]",读出来解析必失败。第二,容量大约 5MB,任务量不大时够用,但如果标题很长、任务上千条,写入会抛异常,所以setItem必须包try/catch。第三,隐私模式下部分浏览器会禁用存储,读写都可能报错,不能让应用直接白屏。

我的兜底策略是“读失败给种子数据,写失败只告警”。任务清单不是关键数据,丢一次可以接受,但页面必须能继续用。如果你要做更严谨的版本,可以在写入失败时把数据放到内存里,并提示用户“当前无法保存”,而不是静默失败。

还有一个容易忽略的点:首次加载时watch还没建立,loadTasks()读到的数据不会立刻写回去。这没问题,因为数据本来就来自存储。但如果你在loadTasks里做了字段归一化,归一化后的结果要等下一次变更才会落盘。想让它在初始化时就写回,可以在useTasks里手动调用一次保存函数,或者干脆接受“下次操作时再统一写入”。demo 里我选后者,少一次同步写入,启动更快。

4. 调试与排错实录:那些让人卡半小时的小问题

4.1 响应式失效的四种典型场景

第一种,解构reactive对象。比如const state = reactive({ tasks: [] }),然后const { tasks } = state,这个tasks拿到的是普通数组,后续怎么改页面都不动。解决方式是改用ref,或者用toRefs包一层。我在 demo 里全程用ref,就是为了避开这个坑。

第二种,把props解构到普通变量。Vue 3.5 之前,const { task } = defineProps(...)会丢失响应式,父组件换数据子组件不更新。3.5 之后解构 props 默认保持响应式,但如果你把解构出来的值再传给普通函数或存进普通对象,仍然可能断链。稳妥写法是保留props.task的访问形式,或者用toRef明确声明。

第三种,替换了整个数组但忘了走.value。tasks = newArray只是改了局部变量,tasks.value = newArray才是改响应式引用。这个错误在抽函数时特别容易发生,因为参数名和外部变量重名,编辑器不会报错。

第四种,在computed里做了副作用。比如在计算筛选结果时顺手调用了localStorage.setItem,或者在computed里改tasks。这会导致依赖追踪混乱,严重时出现无限循环。记住computed只负责算,不负责改。

排查这类问题的笨办法但很有效:在watch里打日志,确认数据到底有没有变;在模板里临时插一个{{ JSON.stringify(tasks) }},看渲染层拿到的是什么。Vue Devtools 也能看组件树和ref的当前值,比console.log直观。

4.2 key 用错引发的输入错位

把:key写成索引,是列表渲染里最经典的坑。假设列表是 A、B、C,你删掉 B,索引变成 0、1,Vue 会复用原来的 DOM 节点,把 C 的内容塞进原本 B 的位置。如果列表项里有输入框或复选框,状态就会错位:你看到的是 C 的标题,勾选状态却还是 B 的。

任务清单里每个列表项都有复选框,所以这个问题一定会出现。用task.id做 key 之后,Vue 能准确知道哪个节点被删了,只移除对应的 DOM,其余节点原样保留。id必须在整个生命周期里稳定,不能每次渲染重新生成,否则等于每次都换新节点,动画和焦点都会丢。

还有一种隐蔽情况:新增任务时用unshift插到头部,如果 key 是索引,所有现有项的位置都往后挪一位,Vue 会误以为每一项都变了,整列表重渲染。任务少时看不出来,上百条时就能感到卡顿。所以 key 这件事不只是“别用索引”,还要保证 key 来自数据本身。

4.3 本地存储脏数据与并发覆盖

脏数据最常见的来源是手动改localStorage,或者旧版本代码写入了不同结构。我的normalizeTask会把缺字段补上、类型不对的转掉,但有一种情况它处理不了:数组里混进了null。所以filter那一步要判空,map里也要做空值保护。

并发覆盖发生在同时开两个标签页的时候。A 标签页删了一条,B 标签页还拿着旧数组,B 一操作就把 A 的删除覆盖回去了。demo 里我没做多标签同步,但排查时要知道这个现象:明明删了,刷新又回来了,多半是另一个标签页写回了旧数据。简单处理是监听storage事件,收到变更后重新loadTasks();更严谨的做法是给每条数据加updatedAt,写入前做合并。任务清单场景不复杂,重新加载就够用。

还有一个细节:watch的deep: true会遍历整个数组,任务对象里字段越多,开销越大。如果后面加了子任务、评论这类嵌套结构,建议改成只监听必要字段,或者在动作函数里手动触发保存,别依赖深度监听。

4.4 问题速查表

现象更可能的原因先查什么处理方式
新增后列表不更新直接改了普通变量,没走ref.value在addTask里打日志看tasks.value长度统一通过tasks.value赋值或unshift
勾选后统计不变统计用了手动维护的对象检查stats是不是computed改成从tasks派生
删除中间项后内容错位key用了索引看v-for的:key绑定换成稳定的task.id
刷新后数据消失写入被异常吞掉,或键名不一致看控制台有没有存储告警统一存储键,setItem包try/catch
回车后页面刷新表单没阻止默认行为看<form>有没有@submit.prevent补上.prevent修饰符
搜索框输入后列表闪烁每次输入都触发深度监听写存储看watch有没有防抖加 200 毫秒延迟写入

这张表是我自己踩过之后整理的,实际排查时先看现象对应哪一行,再去代码里找对应位置,比从头读一遍快得多。

5. 从 demo 走向可维护:扩展路线与个人经验

5.1 什么时候该上 Pinia,什么时候继续用 composable

判断标准不是“代码量多少行”,而是“有没有第二个页面需要同一份状态”。任务清单只有一个页面,useTasks返回的状态天然是组件内单例,够用。一旦加了路由,比如“任务列表页”和“统计页”都要读同一份任务,组件内状态就不够了,因为每个页面调用一次useTasks会创建各自独立的数据。

这时有两个选择:把状态提到根组件用provide/inject传下去,或者上 Pinia。我的建议是三步走:先在根组件调用useTasks,通过provide注入;如果共享状态超过三处,或者需要跨页面保持、需要调试工具追踪,再换 Pinia。Pinia 的好处是 Devtools 里能看到每次变更,时间旅行调试也方便,代价是多一个依赖和一套 store 写法。demo 阶段不用提前背这个包袱。

还有一个折中方案:把useTasks改成模块级单例,状态定义在函数外面,函数只返回同一份引用。这样多个组件调用拿到的是同一份数据,不引入额外依赖。缺点是测试时状态会互相污染,需要在beforeEach里手动重置。团队项目里我更倾向 Pinia,个人练手用单例足够。

5.2 给 demo 加测试:从 composable 下手最划算

组件测试要处理挂载、事件、DOM 查询,写起来慢。任务清单的核心规则都在useTasks里,先测它性价比最高。用 Vitest 加一个测试文件,跑之前需要localStorage,在 Node 环境里可以用一个简单 mock:

import { beforeEach, describe, expect, it } from 'vitest' import { useTasks } from './useTasks' beforeEach(() => { const store = new Map() globalThis.localStorage = { getItem: key => (store.has(key) ? store.get(key) : null), setItem: (key, value) => store.set(key, String(value)), removeItem: key => store.delete(key), clear: () => store.clear() } }) describe('useTasks', () => { it('拒绝空白标题', () => { const { tasks, addTask } = useTasks() const before = tasks.value.length const result = addTask(' ') expect(result.ok).toBe(false) expect(tasks.value.length).toBe(before) }) it('切换完成状态后统计跟着变', () => { const { tasks, stats, toggleTask } = useTasks() const target = tasks.value.find(t => !t.done) const beforeDone = stats.value.done toggleTask(target.id) expect(stats.value.done).toBe(beforeDone + 1) }) })

这两个用例覆盖了最容易被改坏的两条规则:输入校验和派生统计。后面加“清理已完成”“按优先级排序”时,先补测试再改代码,比手动点页面可靠。注意useTasks每次调用都会重新读存储,测试之间要清空 mock,否则用例互相影响。

5.3 三个值得动手的扩展方向

第一个是撤销删除。实现思路不复杂:删除前把当前数组深拷贝一份压进历史栈,点撤销时弹栈恢复。历史栈限制在 20 步以内,避免内存涨太快。深拷贝用structuredClone就行,注意它不能克隆函数,任务对象里只有普通字段,正好适用。这个功能能让你真正理解“状态快照”和“命令模式”的区别。

第二个是拖拽排序。原生 HTML5 拖放 API 在桌面端够用,移动端要换成指针事件。排序结果不要直接改filteredTasks,因为那是派生数据,应该给每条任务加一个order字段,或者在源数组里做位置交换。拖拽过程中要处理“拖到空白处”和“拖出列表”两种边界,建议先做上下移动按钮,跑通顺序变更逻辑再上拖拽。

第三个是接后端同步。本地先改,请求成功确认,失败回滚,这叫乐观更新。难点不在发请求,而在冲突处理:本地改了标题,服务端也被别人改了,谁赢?简单策略是“最后写入胜出”,复杂一点用版本号做乐观锁。demo 阶段可以先用json-server起一个假接口,把addTask、removeTask改成异步,观察 loading 状态和错误提示怎么加。

我个人在实际操作中的体会是,这类小 demo 的价值不在代码本身,而在你愿不愿意把它拆开再装回去。第一遍照着写能跑,第二遍把useTasks重写一遍、故意改坏几个地方再修好,第三遍加上测试和撤销功能,三遍下来对响应式和组件通信的理解会比看十篇文章都扎实。真要说有什么技巧,那就是每次只加一个功能,加完立刻刷新页面验证,别一口气改五个文件再一起调试,那样出了问题根本不知道是哪一步引入的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 21:01:33

Typora 1.12.3版本升级与高效Markdown写作指南

1. Typora 1.12.3 版本核心升级解析作为一款广受写作者喜爱的Markdown编辑器&#xff0c;Typora在1.12.3版本中带来了多项实质性改进。这个版本最显著的变化集中在编辑体验优化和稳定性提升两个方面。首先是实时预览功能的响应速度提升了约40%&#xff0c;这得益于底层渲染引擎…

作者头像 李华
网站建设 2026/11/3 21:00:37

MySQL主从复制报错:找不到binlog文件名的根源与修复

1. 这个报错不是“找不到文件”&#xff0c;而是MySQL在说“你给的起点根本不存在”刚看到could not find first log file name in binary log index file这条错误时&#xff0c;我第一反应也是——“哦&#xff0c;binlog文件被删了&#xff1f;” 然后急急忙忙去数据目录翻my…

作者头像 李华
网站建设 2026/11/3 20:57:00

HBase高可用架构设计与实践指南

1. 为什么HBase需要高可用架构&#xff1f;在分布式数据库领域&#xff0c;HBase作为Hadoop生态中的核心组件&#xff0c;其高可用性设计直接关系到企业级应用的稳定性。我经历过一次生产事故——某电商平台大促期间RegionServer宕机&#xff0c;导致整个订单系统瘫痪2小时&…

作者头像 李华
网站建设 2026/11/3 20:56:41

GitLab实例自动化迁移方案与实现

1. 项目背景与需求分析在企业级代码管理场景中&#xff0c;GitLab实例迁移是常见的运维需求。当需要将代码仓库从旧实例&#xff08;Instance A&#xff09;完整迁移到新实例&#xff08;Instance B&#xff09;时&#xff0c;传统的手动迁移方式存在以下痛点&#xff1a;项目数…

作者头像 李华
网站建设 2026/11/3 20:56:08

IDEA Maven依赖导入失败:pom、镜像与本地仓库排查

项目标题里带着“依赖导入问题”这几个字&#xff0c;说明写下这个内容的人大概率是在某个下午被 IDEA 右下角那串红色进度条和满屏的Could not resolve dependencies折腾过。Maven 项目的 pom.xml 导入失败&#xff0c;是 Java 开发里最典型的“看起来只是点一下刷新的事&…

作者头像 李华
网站建设 2026/11/3 20:55:15

C++游戏开发入门:从环境搭建到贪吃蛇实现

1. C小游戏开发入门指南作为一名有十年游戏开发经验的程序员&#xff0c;我经常被问到如何用C开发小游戏。今天我就来分享一个完整的C小游戏开发流程&#xff0c;从环境搭建到最终发布&#xff0c;适合有一定C基础但想尝试游戏开发的朋友。2. 开发环境准备2.1 编译器选择对于C游…

作者头像 李华