【实战】Vue 3 + TypeScript 实现一个 Todo List(最佳实践版)

Todo List 不难,难的是写得可维护:类型怎么建模?状态怎么组织?组件怎么拆?本文用一套“能长期迭代”的实现,把 Vue 3 + TS 的最佳实践串起来。

6 分钟阅读
小明

【实战】Vue 3 + TypeScript 实现一个 Todo List(最佳实践版)

Todo List 是前端界的「书注」。

谁都做过:输入、添加、勾选完成、删除、筛选。

  • 业务一增长,状态散落在各个组件里;
  • 类型只写在脑子里,IDE 不敢帮你补全;
  • 最后“重构”变成“重写”,你一边改一边祈祷。

这篇文章我们不追求花里胡哨的 UI,而是追求一件更硬的东西:可维护性

目标是实现一个 Todo List,同时把 Vue 3 + TypeScript 的一套“长期可迭代”写法落到代码里:

  • 类型建模:不靠猜
  • 状态组织:不打架
  • 更新逻辑:不拧巴
  • 组件拆分:不面条
  • 持久化与派生状态:不混乱

你可以把它当成一个小项目的骨架:以后你做更复杂的业务,也能按这个方式长出来。


一、先定规矩:我们到底要做哪些功能?

为了让“最佳实践”有落点,我们先把功能边界写清楚(越清楚越不容易写成一坨):

  1. 新增 Todo(输入标题)
  2. 切换完成状态(done/undone)
  3. 删除 Todo
  4. 编辑标题(可选,我们也做)
  5. 筛选:全部 / 未完成 / 已完成
  6. 持久化:刷新不丢(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,又写回去一遍。
  • visibleTodosactiveCountcompletedCount 都是 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 里,最常见的性能杀手:

  1. 大列表没虚拟滚动(超过 1000 项时严重)
  2. computed 嵌套计算(每次都重新筛选 + 计数)
  3. 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)
  })
})

**为什么测这个?**逻辑独立,可重复,容易验证。组件测试反而容易脆弱。


七、你可以怎么继续升级这个项目?

如果你想把它当成作品集项目继续迭代,小明建议按这个顺序:

  1. 加一个“批量操作”(全选完成、清空已完成)
  2. 加一个“优先级”和“排序”
  3. 把 localStorage 替换成 IndexedDB(更适合大数据量)
  4. 加单元测试(用 Vitest,只测 composable 逻辑层)
  5. 接入后端 API(这时你就进入真正的"工程化实战"了)
  6. 用 Pinia 替换 composable(如果项目扩成多页面)

总结

这篇 Todo List 我们追求的不是“能跑”,而是“能长”。

  • 用 TypeScript 把领域模型定下来
  • 用 composable 把变更集中起来
  • 用 computed 管理派生状态
  • 用合理的组件拆分控制复杂度
  • 用持久化让体验闭环

小明冷笑话收尾:

写 Todo List 很容易,难的是别把你的代码也写成一堆 todo。