🌈 Vue TodoList 2.0
学习目标: 从一个功能完整但逻辑集中在
App.vue的 TodoList,逐步整理成一个结构清晰、职责分明、可以继续扩展的小型 Vue 项目。
一、今天完成了什么
今天主要完成了四件事:
- 组件拆分:把不同界面区域拆成独立组件。
- 逻辑抽离 :把 Todo 业务逻辑从
App.vue抽到组合式函数。 - Pinia 状态管理:把 Todo 数据迁移到全局 Store。
- 理解模块化 :学习
export、import、props、emit、v-model、插槽等概念。
二、组件拆分
最开始,TodoList 的输入框、任务列表、编辑功能、统计区域和样式都写在 App.vue 中。
随着功能增多,App.vue 会越来越长,所以我们按照"一个组件负责一类界面功能"的原则进行了拆分。
当前组件结构
text
src
├── components
│ ├── BaseCard.vue
│ ├── TodoInput.vue
│ ├── TodoItem.vue
│ └── TodoSummary.vue
│
├── stores
│ └── todoStore.js
│
├── App.vue
└── main.js
各文件职责如下:
| 文件 | 主要职责 |
|---|---|
BaseCard.vue |
卡片外壳、默认插槽和具名插槽 |
TodoInput.vue |
新任务输入框和提交按钮 |
TodoItem.vue |
单条任务展示、编辑、删除、状态切换 |
TodoSummary.vue |
未完成/已完成统计、清除按钮 |
todoStore.js |
Todo 数据、统计结果、操作函数、本地存储 |
App.vue |
组合页面和协调组件 |
main.js |
创建 Vue 应用,安装 TDesign 和 Pinia |
为什么要拆组件
拆组件不是为了让文件数量变多,而是为了让每个文件只负责一件相对明确的事情。
text
App.vue
原来:既负责页面,又负责输入、任务、编辑、统计、存储
拆分后:
App.vue 只负责组合
TodoInput.vue 负责输入
TodoItem.vue 负责单条任务
TodoSummary.vue 负责统计
todoStore.js 负责数据和业务
拆分后的好处:
- 更容易阅读;
- 某个功能出错时,更容易定位;
- 组件可以复用;
- 样式可以跟着组件放置;
- 后续增加新功能时,不需要一直修改一个超长文件。
三、props:父组件向子组件传数据
props 用来完成:
父组件 → 子组件
例如 App.vue 把单条任务传给 TodoItem.vue:
vue
<TodoItem :todo="todo" />
子组件接收:
javascript
const props = defineProps({
todo: {
type: Object,
required: true,
},
})
数据方向:
text
App.vue 中的 todo
↓ props
TodoItem.vue 中的 props.todo
四、emit:子组件向父组件报告事件
emit 用来完成:
子组件 → 父组件
例如 TodoItem.vue 中点击删除按钮:
javascript
const emit = defineEmits(['remove'])
function removeTodo() {
emit('remove', props.todo.id)
}
父组件监听:
vue
<TodoItem @remove="handleRemoveTodo" />
完整过程:
text
用户点击删除按钮
↓
TodoItem emit('remove', id)
↓
App.vue 接收 remove 事件
↓
App.vue 调用 handleRemoveTodo(id)
关键理解:
emit 本身不删除任务,它只是通知父组件"用户想删除这条任务"。
五、v-model:双向绑定
普通输入框中:
vue
<t-input v-model="newTodo" />
可以理解为两件事的组合:
vue
<t-input
:model-value="newTodo"
@update:model-value="newTodo = $event"
/>
也就是:
text
父组件把数据传给输入框
+
输入框变化后通知父组件更新数据
组件中的自定义 v-model
父组件:
vue
<TodoInput v-model="newTodo" />
子组件需要接收:
javascript
const props = defineProps({
modelValue: String,
})
并发送:
javascript
emit('update:modelValue', value)
具名 v-model
任务编辑使用了:
vue
<TodoItem v-model:editing-text="editingText" />
它对应:
text
prop:editingText
事件:update:editingText
六、插槽 slot
BaseCard.vue 使用了插槽,让父组件决定卡片里面显示什么。
默认插槽
子组件:
vue
<div class="card-content">
<slot />
</div>
父组件:
vue
<BaseCard>
<TodoInput />
<TodoSummary />
</BaseCard>
具名插槽
子组件:
vue
<header>
<slot name="header" />
</header>
父组件:
vue
<BaseCard>
<template #header>
<h1>我的 TodoList</h1>
</template>
</BaseCard>
对应关系:
text
#header
↓
slot name="header"
七、样式跟随组件迁移
我们没有单独把所有 CSS 随便搬到一个文件中,而是按照职责迁移:
text
BaseCard.vue
→ 卡片外壳样式
TodoInput.vue
→ 输入区域样式
TodoItem.vue
→ 单条任务样式
TodoSummary.vue
→ 底部统计样式
App.vue
→ 整体页面布局样式
核心原则:
哪个组件负责哪块结构,就让对应样式尽量放在那个组件中。
八、组合式函数 useTodos
最开始,我们把 Todo 数据和操作逻辑抽到了:
text
src/composables/useTodos.js
里面管理:
text
todos
remainingCount
completedCount
addTodo
toggleTodo
removeTodo
updateTodo
clearCompleted
localStorage
组合式函数可以理解为:
把一组相关的响应式数据和函数封装起来,供组件使用。
javascript
const {
todos,
addTodo,
removeTodo,
} = useTodos()
九、为什么后来又学习 Pinia
useTodos() 已经能完成任务,但它更适合局部复用。
普通组合式函数中,如果状态写在函数内部:
javascript
export function useTodos() {
const todos = ref([])
return {
todos,
}
}
不同组件分别调用:
javascript
const a = useTodos()
const b = useTodos()
通常会创建两套状态。
Pinia 则保证同一个应用中:
javascript
const a = useTodoStore()
const b = useTodoStore()
拿到的是同一个 Store。
text
App.vue ───────────┐
├── 同一个 Todo Store
TodoSummary.vue ───┘
十、Pinia 的三个核心概念
| Pinia 概念 | 当前项目中的例子 | 类似 Vue 概念 |
|---|---|---|
state |
todos |
ref / data |
getters |
remainingCount |
computed |
actions |
addTodo() |
普通函数 / methods |
State
javascript
const todos = ref([])
表示需要共享的正式数据。
Getter
javascript
const remainingCount = computed(() => {
return todos.value.filter((todo) => !todo.done).length
})
它不是独立数据,而是根据 todos 计算出来的结果。
Action
javascript
function addTodo(text) {
todos.value.push({
id: Date.now(),
text,
done: false,
})
}
负责修改 Store 数据。
十一、创建 Pinia Store
核心写法:
javascript
export const useTodoStore = defineStore('todos', () => {
const todos = ref([])
const remainingCount = computed(() => {
return todos.value.filter((todo) => !todo.done).length
})
function addTodo(text) {
// 添加任务
}
return {
todos,
remainingCount,
addTodo,
}
})
其中:
javascript
defineStore('todos', ...)
表示定义一个名称为 todos 的 Store。
javascript
useTodoStore()
用于在组件中取得这个 Store。
十二、storeToRefs
从 Store 中获取响应式状态时使用:
javascript
const todoStore = useTodoStore()
const {
todos,
remainingCount,
completedCount,
} = storeToRefs(todoStore)
storeToRefs() 会把 Store 中的 state 和 getter 安全地转换成 ref。
操作函数可以直接解构:
javascript
const {
addTodo,
removeTodo,
clearCompleted,
} = todoStore
可以记成:
text
状态和 getter
→ storeToRefs(todoStore)
action 函数
→ 直接从 todoStore 解构
十三、Pinia 的全局共享体验
最开始统计数据由 App.vue 通过 props 传给 TodoSummary.vue:
text
Todo Store
↓
App.vue
↓ props
TodoSummary.vue
后来改为:
text
Todo Store
/ \
/ \
App.vue TodoSummary.vue
TodoSummary.vue 自己访问 Store:
javascript
const todoStore = useTodoStore()
const {
remainingCount,
completedCount,
} = storeToRefs(todoStore)
这样统计数字不再由 App.vue 逐层传递。
十四、并不是所有数据都要放 Pinia
今天明确了一个重要原则:
Pinia 适合共享业务数据,但不应该把所有临时状态都塞进去。
当前放在 Store 中:
text
todos
任务统计
任务增删改查
localStorage
继续留在 App.vue:
text
newTodo
editingId
editingText
原因是:
todos是正式业务数据;newTodo只是输入框里还没提交的草稿;editingId和editingText是当前页面的临时编辑状态。
十五、export 和 import
export
表示:
把当前文件中的变量或函数开放给其他文件使用。
javascript
export const useTodoStore = defineStore(...)
它等价于:
javascript
const useTodoStore = defineStore(...)
export {
useTodoStore,
}
import
表示:
从其他文件中引入已经导出的内容。
javascript
import { useTodoStore } from './stores/todoStore.js'
可以记成:
text
export:从当前文件送出去
import:从其他文件拿进来
具名导出
javascript
export const useTodoStore = ...
导入时使用花括号:
javascript
import { useTodoStore } from './todoStore.js'
默认导出
javascript
export default App
导入时不使用花括号:
javascript
import App from './App.vue'
十六、模板 ref 的理解
模板 ref:
vue
<h1 ref="titleRef">
{{ title }}
</h1>
它的作用不是显示标题。
真正显示标题的是:
vue
{{ title }}
模板 ref 的作用是:
让 JavaScript 获取页面中的真实 DOM 元素或子组件。
常见用途:
text
自动聚焦输入框
滚动到某个位置
测量元素大小
控制 video/audio
调用子组件公开的方法
但是当前标题没有直接操作 DOM 的需求,因此:
vue
ref="titleRef"
没有必要,可以删除。
重要原则:
普通页面更新优先通过响应式数据完成,不要为了使用模板 ref 而直接操作 DOM。
十七、今天形成的完整数据流
添加任务
text
TodoInput
↓ emit submit
App.vue 执行 submitTodo()
↓
调用 Store 的 addTodo()
↓
todos 更新
↓
localStorage 保存
↓
页面和统计自动更新
切换任务状态
text
TodoItem
↓ emit toggle
App.vue 调用 toggleTodo(id)
↓
Store 修改 todo.done
↓
remainingCount / completedCount 重新计算
↓
TodoSummary 自动更新
删除任务
text
TodoItem
↓ emit remove
App.vue 执行 handleRemoveTodo(id)
↓
Store 删除任务
↓
必要时 App.vue 退出编辑状态
清除已完成
text
TodoSummary
↓ emit clear-completed
App.vue 检查当前编辑状态
↓
调用 Store 的 clearCompleted()
↓
已完成任务被清除
十八、当前项目能力清单
完成今天的学习后,已经接触并实际使用:
- Vue 单文件组件;
-
<script setup>; -
ref; -
computed; -
watch; -
v-if/v-else; -
v-for和key; -
props; -
emit; - 普通
v-model; - 具名
v-model; - 默认插槽;
- 具名插槽;
- 组件拆分;
- 样式跟随组件;
- Composable;
-
localStorage; - Pinia;
-
storeToRefs; -
export/import; - 区分全局状态和局部状态。
十九、今天最重要的五句话
1.
props用于父传子,emit用于子传父。
2.v-model本质上是"传值 + 接收更新事件"。
3. 组件拆分要按职责进行,样式也应该跟随组件。
4. Pinia 让不同组件访问同一份响应式数据。
5. 不是所有状态都要放 Pinia,临时界面状态应该保留在组件中。
二十、下一阶段
按照当前计划,接下来进入:
text
TypeScript 基础
重点会学习:
text
基本类型
数组和对象类型
interface
函数参数和返回值类型
Todo 数据类型
Vue props 类型
Pinia Store 类型
最终目标是把当前:
text
todoStore.js
App.vue
组件 props
逐步改造成带类型检查的 Vue + TypeScript 项目。