【实战】Vue 3 + TypeScript 实现一个 Todo List(最佳实践版)
Todo List 不难,难的是写得可维护:类型怎么建模?状态怎么组织?组件怎么拆?本文用一套“能长期迭代”的实现,把 Vue 3 + TS 的最佳实践串起来。
【实战】Vue 3 + TypeScript 实现一个 Todo List(最佳实践版)
Todo List 是前端界的「书注」。
谁都做过:输入、添加、勾选完成、删除、筛选。
- 业务一增长,状态散落在各个组件里;
- 类型只写在脑子里,IDE 不敢帮你补全;
- 最后“重构”变成“重写”,你一边改一边祈祷。
这篇文章我们不追求花里胡哨的 UI,而是追求一件更硬的东西:可维护性。
目标是实现一个 Todo List,同时把 Vue 3 + TypeScript 的一套“长期可迭代”写法落到代码里:
- 类型建模:不靠猜
- 状态组织:不打架
- 更新逻辑:不拧巴
- 组件拆分:不面条
- 持久化与派生状态:不混乱
你可以把它当成一个小项目的骨架:以后你做更复杂的业务,也能按这个方式长出来。
一、先定规矩:我们到底要做哪些功能?
为了让“最佳实践”有落点,我们先把功能边界写清楚(越清楚越不容易写成一坨):
- 新增 Todo(输入标题)
- 切换完成状态(done/undone)
- 删除 Todo
- 编辑标题(可选,我们也做)
- 筛选:全部 / 未完成 / 已完成
- 持久化:刷新不丢(localStorage)
注意:我们刻意不做“登录/多人协作/服务端同步”。那是另一篇文章。
二、类型建模:Todo 不是一个 any
很多项目的 Todo 是这么开始的:
type Todo = any
然后结束得也很潦草。
在真实业务里,你的 Todo 会慢慢长出更多字段:
- 创建时间、更新时间
- 完成时间
- 优先级
- 归档/删除(软删除)
- 标签、所属项目
所以最开始的类型建模很关键。我们用一个“小但可扩展”的模型:
export type TodoId = string
export type Todo = {
id: TodoId
title: string
done: boolean
createdAt: number
updatedAt: number
}
export type TodoFilter = 'all' | 'active' | 'completed'
几个细节:
TodoId单独起别名:将来你要换成 number/UUID,都不需要全局替换。- 时间用
number(Unix ms):序列化、比较、存储都更简单。 TodoFilter用 union:模板里写错字符串会直接报错。
三、把“变更”集中起来:一个可靠的 useTodos()
你写 Todo List,最容易出的问题不是 UI,而是状态更新逻辑散落各地。
小明强烈建议:把状态 + 变更函数集中在一个 composable 里。
好处很直接:
- 组件只负责展示与交互
- 逻辑可以复用
- 未来接入 Pinia、接入服务端同步,也更容易迁移
3.1 最小实现:状态、增删改、切换
import { computed, ref, watch } from 'vue'
export type TodoId = string
export type Todo = {
id: TodoId
title: string
done: boolean
createdAt: number
updatedAt: number
}
export type TodoFilter = 'all' | 'active' | 'completed'
function now() {
return Date.now()
}
function createId() {
// 这里用浏览器原生的 crypto
// 兼容性不够时可以降级成时间戳 + 随机数
return crypto.randomUUID()
}
export function useTodos(storageKey = 'todos:v1') {
const todos = ref<Todo[]>([])
const filter = ref<TodoFilter>('all')
function add(title: string) {
const trimmed = title.trim()
if (!trimmed) return
const t = now()
todos.value.unshift({
id: createId(),
title: trimmed,
done: false,
createdAt: t,
updatedAt: t
})
}
function toggle(id: TodoId) {
const item = todos.value.find(t => t.id === id)
if (!item) return
item.done = !item.done
item.updatedAt = now()
}
function remove(id: TodoId) {
todos.value = todos.value.filter(t => t.id !== id)
}
function updateTitle(id: TodoId, title: string) {
const item = todos.value.find(t => t.id === id)
if (!item) return
const trimmed = title.trim()
if (!trimmed) return
item.title = trimmed
item.updatedAt = now()
}
const activeCount = computed(() => todos.value.filter(t => !t.done).length)
const completedCount = computed(() => todos.value.length - activeCount.value)
const visibleTodos = computed(() => {
if (filter.value === 'active') return todos.value.filter(t => !t.done)
if (filter.value === 'completed') return todos.value.filter(t => t.done)
return todos.value
})
// 持久化
const loaded = ref(false)
function load() {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) {
loaded.value = true
return
}
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) {
loaded.value = true
return
}
// 轻量的运行时校验:只取我们认识的字段
todos.value = parsed
.filter(v => v && typeof v === 'object')
.map((v: any) => ({
id: String(v.id),
title: String(v.title ?? ''),
done: Boolean(v.done),
createdAt: Number(v.createdAt ?? now()),
updatedAt: Number(v.updatedAt ?? now())
}))
.filter(v => v.title.trim() !== '')
loaded.value = true
} catch {
loaded.value = true
}
}
watch(
todos,
next => {
if (!loaded.value) return
localStorage.setItem(storageKey, JSON.stringify(next))
},
{ deep: true }
)
return {
todos,
filter,
visibleTodos,
activeCount,
completedCount,
add,
toggle,
remove,
updateTitle,
load
}
}
你会注意到几个“看似多余但很值”的点:
load()单独函数:避免 composable 在导入时就读 localStorage(对 SSR/单测更友好)。loaded这个开关:避免“加载旧数据”触发 watch,又写回去一遍。visibleTodos、activeCount、completedCount都是 computed:这是派生状态,不要存,否则你会遇到“真实数据改了,派生数据忘了同步”的经典事故。
3.2 一个现实问题:localStorage 不是类型安全的
TypeScript 只能保证你写进 localStorage 的类型;它无法保证你读出来的 JSON 结构仍然正确。
所以我们做了轻量的运行时校验:
- 只取我们认识的字段
- 统一用
String/Number/Boolean做转换 - 空 title 的条目直接丢掉
如果你项目更严格,建议上 schema 校验(比如 zod)。但本篇我们保持“轻量但不裸奔”。
四、组件拆分:别把一个页面写成一个大组件
我们推荐最小三层结构:
TodoPage:页面容器,负责组合TodoInput:只负责输入TodoList:只负责展示列表TodoItem:只负责一条 Todo 的交互(完成、编辑、删除)
这样拆的好处是:
- 复用容易(你以后做“项目列表”也能照抄)
- 测试容易(每个组件逻辑简单)
- 读代码不累(每个文件都短)
4.1 页面容器(使用 composable)
<script setup lang="ts">
import { onMounted } from 'vue'
import { useTodos } from './useTodos'
import TodoInput from './TodoInput.vue'
import TodoList from './TodoList.vue'
const {
filter,
visibleTodos,
activeCount,
completedCount,
add,
toggle,
remove,
updateTitle,
load
} = useTodos()
onMounted(() => {
load()
})
</script>
<template>
<section>
<TodoInput @submit="add" />
<div>
<button @click="filter = 'all'">全部</button>
<button @click="filter = 'active'">未完成</button>
<button @click="filter = 'completed'">已完成</button>
<span>未完成:{{ activeCount }}</span>
<span>已完成:{{ completedCount }}</span>
</div>
<TodoList
:todos="visibleTodos"
@toggle="toggle"
@remove="remove"
@update:title="updateTitle"
/>
</section>
</template>
这里有个细节:@update:title 这个事件名,能让你的组件 API 更像 Vue 自己的 v-model 语义。
4.2 TodoInput:输入组件只做一件事
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
(e: 'submit', title: string): void
}>()
const title = ref('')
function submit() {
emit('submit', title.value)
title.value = ''
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="title" placeholder="添加一个 todo" />
<button type="submit">添加</button>
</form>
</template>
注意:不要在输入组件里直接改 todos。它只管“把用户输入变成事件”。这会让组件变得干净、可复用。
4.3 TodoItem:编辑逻辑的“边界”
编辑标题最容易写成状态机灾难。一个更稳的方式是:
- 显示态:展示 title
- 编辑态:用一个局部
draftTitle - 保存时 emit,取消时恢复
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
type Todo = {
id: string
title: string
done: boolean
}
const props = defineProps<{ todo: Todo }>()
const emit = defineEmits<{
(e: 'toggle', id: string): void
(e: 'remove', id: string): void
(e: 'update:title', id: string, title: string): void
}>()
const editing = ref(false)
const draftTitle = ref(props.todo.title)
watch(
() => props.todo.title,
t => {
if (!editing.value) draftTitle.value = t
}
)
const displayTitle = computed(() => props.todo.title)
function startEdit() {
editing.value = true
draftTitle.value = props.todo.title
}
function cancelEdit() {
editing.value = false
draftTitle.value = props.todo.title
}
function saveEdit() {
emit('update:title', props.todo.id, draftTitle.value)
editing.value = false
}
</script>
<template>
<li>
<label>
<input type="checkbox" :checked="todo.done" @change="emit('toggle', todo.id)" />
<span v-if="!editing">{{ displayTitle }}</span>
</label>
<input
v-if="editing"
v-model="draftTitle"
@keydown.enter.prevent="saveEdit"
@keydown.esc.prevent="cancelEdit"
/>
<button v-if="!editing" @click="startEdit">编辑</button>
<button v-if="editing" @click="saveEdit">保存</button>
<button @click="emit('remove', todo.id)">删除</button>
</li>
</template>
这段代码看似“啰嗦”,但它把编辑行为拆成了明确的三个动作:开始、取消、保存。
业务复杂时,“明确”就是稳定。
五、最佳实践的核心:把复杂度留在该留的地方
写到这里,你会发现我们做了一个不太“偷懒”的选择:
- 不把所有逻辑塞进组件
- 不把所有状态塞进全局
- 不靠“先写出来再说”
这背后其实是一个工程原则:
复杂度无法消失,但可以被管理。
5.1 组件只负责交互,逻辑在 composable
当你把“增删改查”的规则写在 composable:
- UI 变化(换组件库、换样式)基本不影响逻辑
- 逻辑变化(新增优先级、增加归档)只改一处
这就是“关注点分离”。
5.2 派生状态用 computed,别存
visibleTodos、count 这类东西,存起来就是埋雷。
你以为你在“缓存”,其实你在“制造一致性问题”。
5.3 不要迷信全局状态
Todo 这种级别的项目,用 composable 完全够用。
等你真的遇到:
- 多页面共享
- 多模块互相影响
- 需要 devtools 时间旅行
再上 Pinia,也不晚。
六、性能与单元测试
6.1 什么时候会出现性能问题?
Todo List 里,最常见的性能杀手:
- 大列表没虚拟滚动(超过 1000 项时严重)
- computed 嵌套计算(每次都重新筛选 + 计数)
- watch 没防抖(用户输入联想时频繁计算)
优化方案:
// 如果 Todo 超过 1000,用虚拟滚动组件(vue-virtual-scroller)
// visibleTodos 如果计算复杂,可以缓存上一次结果
const visibleTodos = computed(() => {
const result = todoList.value.filter(...)
// 只有 filter 改变时才重新计算
return result
}, { flush: 'post' }) // 延迟到 DOM 更新后
实战建议:优先测量,后优化。不是所有 Todo 都需要虚拟滚动。
6.2 该不该加单元测试?
小明的答案是:最起码测 useTodos 这个核心 composable。
import { describe, it, expect, beforeEach } from 'vitest'
import { useTodos } from './useTodos'
describe('useTodos', () => {
let { todos, add, toggle, remove } = useTodos()
beforeEach(() => {
// 每个测试前清空
todos.value = []
})
it('should add a todo', () => {
add('学习 Vue 3')
expect(todos.value).toHaveLength(1)
expect(todos.value[0].title).toBe('学习 Vue 3')
expect(todos.value[0].done).toBe(false)
})
it('should toggle done status', () => {
add('测试')
const id = todos.value[0].id
toggle(id)
expect(todos.value[0].done).toBe(true)
})
it('should not add empty todo', () => {
add(' ')
expect(todos.value).toHaveLength(0)
})
})
**为什么测这个?**逻辑独立,可重复,容易验证。组件测试反而容易脆弱。
七、你可以怎么继续升级这个项目?
如果你想把它当成作品集项目继续迭代,小明建议按这个顺序:
- 加一个“批量操作”(全选完成、清空已完成)
- 加一个“优先级”和“排序”
- 把 localStorage 替换成 IndexedDB(更适合大数据量)
- 加单元测试(用 Vitest,只测 composable 逻辑层)
- 接入后端 API(这时你就进入真正的"工程化实战"了)
- 用 Pinia 替换 composable(如果项目扩成多页面)
总结
这篇 Todo List 我们追求的不是“能跑”,而是“能长”。
- 用 TypeScript 把领域模型定下来
- 用 composable 把变更集中起来
- 用 computed 管理派生状态
- 用合理的组件拆分控制复杂度
- 用持久化让体验闭环
小明冷笑话收尾:
写 Todo List 很容易,难的是别把你的代码也写成一堆 todo。