待办应用核心逻辑重构
这次重构不直接修改 11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo 里的独立子项目,而是把其中值得复用的核心逻辑抽离成一份文档化方案。
原始示例里的几个关键文件很适合拿来做组件化和组合式 API 的重构练习:
11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/todo/todo.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/todo/item.vue11Vue学习/vue2相关整理都放这,vue3后的直接放目录下/vue-todo/client/views/todo/helper.vue11Vue学习/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 才能围绕同一份模型协作。
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 中,把页面层变成一个纯粹的组装壳。
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 中 - 页面组件不需要知道“怎么改列表”,只关心“触发哪个动作”
- 数据源可以替换,视图层不用跟着改
页面层只保留页面职责
原来的页面一边拿数据,一边处理输入,一边渲染子组件。重构后,页面层只做三件事:准入、初始化、装配。
<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 已经接近展示组件了,但事件语义还可以更清晰。推荐让列表项明确抛出 toggle 和 delete 两类事件,避免父组件在模板里猜测业务意图。
<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。重构后,输入组件只管理草稿值和提交事件,新增成功与否由外层统一决定。
<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,底部组件只负责接收结果并展示。
<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,让页面只负责接入认证服务和路由。
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、输入组件、列表组件和底部组件都拆出来以后,真正的容器组件会非常稳定。它只负责连接状态和事件,不再处理实现细节。
<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 - 输入和列表是否已经拆成小组件
- 副作用和请求逻辑是否已经移出展示层
只要这四个问题能答“是”,组件边界通常就不会太差。
