Skip to content

待办应用核心逻辑重构

这次重构不直接修改 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo 里的独立子项目,而是把其中值得复用的核心逻辑抽离成一份文档化方案。

原始示例里的几个关键文件很适合拿来做组件化和组合式 API 的重构练习:

  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/todo/todo.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/todo/item.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/todo/helper.vue
  • 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/login/login.vue

这份文档的目标很明确:把旧的单文件职责,整理成更容易维护、更容易复用、也更贴近 Vue 3 的结构。

先看原实现的几个问题

todo.vue 同时负责了路由守卫、数据获取、输入处理、筛选状态、列表渲染、底部统计和删除逻辑。这个文件虽然能跑,但职责太多,后续加需求时很容易变成“所有事情都堆在一个页面里”。

login.vue 也有类似问题。它同时负责表单状态、同步校验、登录请求和跳转。这种写法对小 demo 足够,但一旦登录逻辑变复杂,代码就会迅速膨胀。

从组件化角度看,真正应该保留在页面层的内容只有两类:

  • 路由级的数据准入和页面拼装
  • 将状态和事件交给子组件

其余逻辑更适合放进 composable 或更小的展示组件里。

重构后的边界划分

推荐把待办应用拆成下面几层:

  • pages/todos/index.vue:路由页,只负责鉴权、首屏数据准备和组装页面
  • components/todo/TodoApp.vue:待办功能容器,连接 composable 和子组件
  • components/todo/TodoCreateForm.vue:新增待办输入框和提交事件
  • components/todo/TodoFilterTabs.vue:筛选标签,只处理当前筛选值
  • components/todo/TodoList.vue:负责列表渲染
  • components/todo/TodoListItem.vue:负责单条待办展示与事件抛出
  • components/todo/TodoFooter.vue:展示剩余数量和清理已完成动作
  • composables/useTodos.ts:待办核心状态与业务动作
  • composables/useLoginForm.ts:登录表单状态、校验和提交流程

这样拆完以后,页面层就不再承担实现细节,组件层也不再直接操作路由和外部状态。

领域模型先稳定下来

重构前先收敛数据结构。待办列表最核心的领域对象其实很简单,先把类型定清楚,后面的组件和 composable 才能围绕同一份模型协作。

ts
export type TodoFilter = 'all' | 'active' | 'completed'

export interface TodoItem {
  id: string
  content: string
  completed: boolean
}

export interface LoginPayload {
  username: string
  password: string
}

这一层的价值不是“多写了几个类型”,而是让筛选、统计、更新和提交行为都围绕固定模型展开。后面不管你接 Vuex、Pinia,还是直接请求接口,边界都更稳定。

把待办业务动作抽成 useTodos

原来的 todo.vue 把筛选、输入、新增、切换、删除、清空已完成都堆在一个组件里。更合理的做法是把这些动作收到一个 composable 中,把页面层变成一个纯粹的组装壳。

ts
import { computed, shallowRef } from 'vue'
import type { TodoFilter, TodoItem } from '../types/todo'

export interface TodoService {
  fetchAll: () => Promise<TodoItem[]>
  create: (content: string) => Promise<TodoItem>
  update: (id: string, patch: Partial<TodoItem>) => Promise<TodoItem>
  remove: (id: string) => Promise<void>
  removeCompleted: () => Promise<void>
}

export function useTodos(service: TodoService) {
  const todos = shallowRef<TodoItem[]>([])
  const filter = shallowRef<TodoFilter>('all')
  const draft = shallowRef('')
  const pending = shallowRef(false)

  const filteredTodos = computed(() => {
    if (filter.value === 'all') {
      return todos.value
    }

    const shouldCompleted = filter.value === 'completed'
    return todos.value.filter(todo => todo.completed === shouldCompleted)
  })

  const activeCount = computed(() => {
    return todos.value.filter(todo => !todo.completed).length
  })

  async function hydrate() {
    pending.value = true

    try {
      todos.value = await service.fetchAll()
    } finally {
      pending.value = false
    }
  }

  async function addTodo() {
    const content = draft.value.trim()

    if (!content) {
      return false
    }

    const created = await service.create(content)
    todos.value = [created, ...todos.value]
    draft.value = ''
    return true
  }

  async function toggleTodo(todo: TodoItem) {
    const updated = await service.update(todo.id, {
      completed: !todo.completed
    })

    todos.value = todos.value.map(item => {
      return item.id === todo.id ? updated : item
    })
  }

  async function deleteTodo(id: string) {
    await service.remove(id)
    todos.value = todos.value.filter(todo => todo.id !== id)
  }

  async function clearCompleted() {
    await service.removeCompleted()
    todos.value = todos.value.filter(todo => !todo.completed)
  }

  return {
    todos,
    filter,
    draft,
    pending,
    filteredTodos,
    activeCount,
    hydrate,
    addTodo,
    toggleTodo,
    deleteTodo,
    clearCompleted
  }
}

这个版本有三个直接收益:

  • 派生状态全部通过 computed 维护,不再散落在模板和 methods 中
  • 页面组件不需要知道“怎么改列表”,只关心“触发哪个动作”
  • 数据源可以替换,视图层不用跟着改

页面层只保留页面职责

原来的页面一边拿数据,一边处理输入,一边渲染子组件。重构后,页面层只做三件事:准入、初始化、装配。

vue
<script setup lang="ts">
import { onMounted } from 'vue'
import TodoApp from '../../components/todo/TodoApp.vue'
import { createTodoService } from '../../services/todo-service'
import { useTodos } from '../../composables/useTodos'

const todoService = createTodoService()
const todoState = useTodos(todoService)

onMounted(() => {
  todoState.hydrate()
})
</script>

<template>
  <TodoApp v-bind="todoState" />
</template>

这里的关键点不是代码变短,而是职责被压缩到了页面真正该负责的部分。TodoApp 收到的是一组明确的状态和动作,后续接 SSR、缓存或权限拦截时,也更容易替换。

展示组件只做展示和抛事件

原始 item.vue 已经接近展示组件了,但事件语义还可以更清晰。推荐让列表项明确抛出 toggledelete 两类事件,避免父组件在模板里猜测业务意图。

vue
<script setup lang="ts">
import type { TodoItem } from '../../types/todo'

const props = defineProps<{
  todo: TodoItem
}>()

const emit = defineEmits<{
  toggle: [todo: TodoItem]
  delete: [id: string]
}>()
</script>

<template>
  <div :class="['todo-item', { completed: props.todo.completed }]">
    <input
      type="checkbox"
      :checked="props.todo.completed"
      @change="emit('toggle', props.todo)"
    >
    <label>{{ props.todo.content }}</label>
    <button type="button" @click="emit('delete', props.todo.id)">
      删除
    </button>
  </div>
</template>

这样的好处很直接:

  • 子组件不再关心父层怎么存储数据
  • 父组件不再依赖模糊事件名,比如 del
  • 类型签名能清楚表达组件契约

输入组件只管理输入,不直接操作列表

原始实现里,新增逻辑直接写在 todo.vue 里,通过键盘事件拼出对象再调 action。重构后,输入组件只管理草稿值和提交事件,新增成功与否由外层统一决定。

vue
<script setup lang="ts">
const model = defineModel<string>({ required: true })

const emit = defineEmits<{
  submit: []
}>()

function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Enter') {
    emit('submit')
  }
}
</script>

<template>
  <input
    v-model="model"
    class="add-input"
    type="text"
    placeholder="接下去要做什么?"
    @keydown="handleKeydown"
  >
</template>

这一步很重要。输入框本身不应该拼接业务对象,也不应该决定错误提示怎么展示。这样组件才能在别的列表场景里复用。

底部统计改成纯派生视图

helper.vue 目前只做了未完成数量统计和清理动作,这本身没有问题,但统计结果最好不要在组件里重新计算。更推荐由 useTodos 直接暴露 activeCount,底部组件只负责接收结果并展示。

vue
<script setup lang="ts">
import type { TodoFilter } from '../../types/todo'

defineProps<{
  activeCount: number
  filter: TodoFilter
}>()

const emit = defineEmits<{
  changeFilter: [filter: TodoFilter]
  clearCompleted: []
}>()

const filters: TodoFilter[] = ['all', 'active', 'completed']
</script>

<template>
  <footer class="todo-footer">
    <span>{{ activeCount }} items left</span>

    <div class="tabs">
      <button
        v-for="item in filters"
        :key="item"
        type="button"
        :class="{ active: item === filter }"
        @click="emit('changeFilter', item)"
      >
        {{ item }}
      </button>
    </div>

    <button type="button" @click="emit('clearCompleted')">
      Clear completed
    </button>
  </footer>
</template>

这样以后如果“未完成数量”的定义改变,比如要排除隐藏项,调整点只在 composable 里,不会影响展示组件。

登录表单也应该抽成组合式逻辑

原始 login.vue 的问题不在于写法错误,而在于它把状态、校验、提交和跳转绑定得太紧。更好的方式是把可复用的表单逻辑抽到 useLoginForm,让页面只负责接入认证服务和路由。

ts
import { computed, reactive, shallowRef } from 'vue'
import type { LoginPayload } from '../types/todo'

export function useLoginForm(onSubmit: (payload: LoginPayload) => Promise<void>) {
  const form = reactive({
    username: '',
    password: ''
  })

  const pending = shallowRef(false)
  const errorMessage = shallowRef('')

  const canSubmit = computed(() => {
    return Boolean(form.username.trim() && form.password.trim() && !pending.value)
  })

  function validate() {
    if (!form.username.trim()) {
      errorMessage.value = '姓名不能为空'
      return false
    }

    if (!form.password.trim()) {
      errorMessage.value = '密码不能为空'
      return false
    }

    errorMessage.value = ''
    return true
  }

  async function submit() {
    if (!validate()) {
      return false
    }

    pending.value = true

    try {
      await onSubmit({
        username: form.username,
        password: form.password
      })
      return true
    } finally {
      pending.value = false
    }
  }

  return {
    form,
    pending,
    errorMessage,
    canSubmit,
    submit
  }
}

这个 composable 可以直接复用到别的登录页、弹窗登录或管理后台登录入口里。页面层不再关心“校验字符串怎么写”,而是只关心“提交成功后做什么”。

一份更清晰的 TodoApp 容器写法

useTodos、输入组件、列表组件和底部组件都拆出来以后,真正的容器组件会非常稳定。它只负责连接状态和事件,不再处理实现细节。

vue
<script setup lang="ts">
import TodoCreateForm from './TodoCreateForm.vue'
import TodoFooter from './TodoFooter.vue'
import TodoList from './TodoList.vue'
import type { TodoFilter, TodoItem } from '../../types/todo'

const props = defineProps<{
  draft: string
  filter: TodoFilter
  filteredTodos: TodoItem[]
  activeCount: number
}>()

const emit = defineEmits<{
  'update:draft': [value: string]
  'update:filter': [value: TodoFilter]
  addTodo: []
  toggleTodo: [todo: TodoItem]
  deleteTodo: [id: string]
  clearCompleted: []
}>()
</script>

<template>
  <section class="real-app">
    <TodoCreateForm
      :model-value="props.draft"
      @update:model-value="emit('update:draft', $event)"
      @submit="emit('addTodo')"
    />

    <TodoList
      :items="props.filteredTodos"
      @toggle="emit('toggleTodo', $event)"
      @delete="emit('deleteTodo', $event)"
    />

    <TodoFooter
      :active-count="props.activeCount"
      :filter="props.filter"
      @change-filter="emit('update:filter', $event)"
      @clear-completed="emit('clearCompleted')"
    />
  </section>
</template>

你会发现这个组件几乎没有“业务实现代码”。这正是容器组件应该达到的状态:它像装配台,而不是功能仓库。

这次文档化重构真正沉淀了什么

和直接改旧 demo 相比,这次整理出的价值主要有四个:

  • 把旧的 Options API 页面职责,翻译成 Vue 3 可复用的组合式结构
  • 把筛选、统计、增删改这些核心业务动作收敛到 composable
  • 把列表项、输入框、底部统计都变成可复用的展示组件
  • 把登录这种常见表单逻辑也纳入统一的“状态 + 校验 + 提交”模型

以后再整理类似练手项目时,可以优先套用同一套判断标准:

  • 页面是否只负责页面级职责
  • 派生状态是否都来自 computed
  • 输入和列表是否已经拆成小组件
  • 副作用和请求逻辑是否已经移出展示层

只要这四个问题能答“是”,组件边界通常就不会太差。

共 20 个模块,1301 篇 Markdown 文档。