📌 学习目标
通过这个项目,你将获得:
- ✅ 前端基础开发能力
- ✅ 能独立从 0 起一个 Vue 项目
- ✅ TodoList 不看资料能写出来
- ✅ 路由 + Tab 联动不踩坑
- ✅ 表单 + 校验熟练
- ✅ 能回答"为什么这样设计"
🎯 项目最终效果
你将完成一个 Todo 管理系统,包含:
- Todo 列表页:增删改查、筛选、统计
- 表单示例页:Element Plus 表单 + 校验
- 个人中心页:占位页面(模拟多 Tab)
📚 阶段一:工程初始化
1.1 创建项目
步骤 1:使用 Vite 创建项目
bash
npm create vite@latest vue-todo-practice -- --template vue-ts
自检问题:为什么 Vue3 官方推荐 Vite?
点击查看答案
- 快速启动:Vite 利用浏览器原生 ES 模块,无需打包即可启动开发服务器
- 即时热更新:不管项目多大,热更新速度都很快
- 按需编译:只编译当前页面需要的代码
- 更好的开发体验:Vue3 的 Composition API 配合 Vite 的开发体验更佳
步骤 2:进入项目目录
bash
cd vue-todo-practice
步骤 3:安装核心依赖
bash
npm install
npm install vue-router@4 pinia element-plus axios
依赖说明:
vue-router@4:Vue3 官方路由管理器pinia:Vue3 官方推荐的状态管理库element-plus:基于 Vue3 的组件库axios:HTTP 请求库
1.2 项目结构设计
步骤 1:创建目录结构
在 src 目录下创建以下文件夹:
src/
├── components/ # 可复用的 UI 组件
│ └── todo/ # Todo 相关组件
├── views/ # 页面级组件
├── router/ # 路由配置
├── stores/ # Pinia 状态管理
├── services/ # API 请求封装
├── types/ # TypeScript 类型定义
└── utils/ # 工具函数
自检问题:components 和 views 的职责边界是什么?
点击查看答案
-
components:
- 可复用的 UI 组件
- 不包含业务逻辑或只包含少量业务逻辑
- 通过 props 接收数据,通过 emit 发送事件
- 例如:Button、Input、TodoItem
-
views:
- 页面级组件
- 包含业务逻辑
- 对应路由
- 组合多个 components
- 例如:TodoView、FormView
自检问题:为什么要有 services 这一层?
点击查看答案
- 关注点分离:将 API 请求逻辑从组件中抽离
- 复用性:多个组件可以复用同一个 API 请求
- 可维护性:API 变更时只需修改 services 层
- 统一管理:统一处理请求拦截、响应拦截、错误处理
- 测试友好:方便 mock 和单元测试
🚀 阶段二:路由 + Tab 联动
2.1 路由设计
步骤 1:创建路由配置文件
创建 src/router/index.ts:
typescript
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/todo'
},
{
path: '/todo',
name: 'Todo',
component: () => import('@/views/TodoView.vue'),
meta: { title: 'Todo 列表' }
},
{
path: '/form',
name: 'Form',
component: () => import('@/views/FormView.vue'),
meta: { title: '表单示例' }
},
{
path: '/profile',
name: 'Profile',
component: () => import('@/views/ProfileView.vue'),
meta: { title: '个人中心' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
自检问题:路由为什么要分文件管理?
点击查看答案
- 可维护性:路由配置独立,方便查找和修改
- 扩展性:方便添加路由守卫、路由元信息等
- 类型安全:TypeScript 类型检查更友好
- 职责分离:路由逻辑与业务逻辑分离
自检问题:路由懒加载解决了什么问题?
点击查看答案
- 减少首屏加载时间:只加载当前路由需要的代码
- 按需加载:用户访问某个路由时才加载对应组件
- 代码分割:Webpack/Vite 会自动将懒加载的组件分割成独立的 chunk
- 提升性能:减少首屏 JavaScript 体积
步骤 2:在 main.ts 中注册路由
typescript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
2.2 Element Plus Tabs + 路由联动
步骤 1:创建基础布局组件
修改 src/App.vue:
vue
<template>
<div id="app">
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="Todo 列表" name="/todo" />
<el-tab-pane label="表单示例" name="/form" />
<el-tab-pane label="个人中心" name="/profile" />
</el-tabs>
<router-view />
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const activeTab = ref(route.path)
// 监听路由变化,更新 Tab 状态
watch(
() => route.path,
(newPath) => {
activeTab.value = newPath
}
)
// Tab 切换时,更新路由
const handleTabChange = (name: string) => {
router.push(name)
}
</script>
<style scoped>
#app {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
关键点:
- 使用
useRoute和useRouter获取路由信息和方法 activeTab与路由路径保持同步- 监听路由变化更新 Tab 状态
- Tab 切换时更新路由
自检问题:为什么不单独用 ref 维护 tab 状态?
点击查看答案
- 单一可信源(Single Source of Truth):路由是状态的唯一来源
- 刷新保持状态:刷新页面后,Tab 状态能正确恢复
- 浏览器前进/后退:能正确响应浏览器导航
- URL 分享:用户可以直接访问特定 Tab 的 URL
- 避免状态不一致:Tab 状态和路由状态始终同步
💡 阶段三:TodoList 核心逻辑
3.1 Todo 数据模型设计
步骤 1:定义类型
创建 src/types/todo.ts:
typescript
export interface Todo {
id: string
title: string
completed: boolean
createdAt: Date
}
export type FilterType = 'all' | 'active' | 'completed'
自检问题:为什么不用数组下标当 id?
点击查看答案
- 删除问题:删除元素后,后续元素的下标会改变
- 唯一性:下标不能保证全局唯一(如数据库存储)
- 可读性:字符串 id 更易读和调试
- 扩展性:方便与后端 API 对接
- 稳定性:元素移动时 id 不变
自检问题:completed 应该由谁修改?
点击查看答案
- 状态管理者:completed 应该由状态管理器(Store 或组件)修改
- 组件职责:组件只负责触发事件,不直接修改状态
- 单向数据流:数据从上往下流,事件从下往上发
3.2 状态管理(两种方式对比)
方式一:组件内部 state
创建 src/views/TodoView.vue(使用 ref/reactive):
vue
<template>
<div class="todo-view">
<h1>Todo 列表</h1>
<!-- 输入框 -->
<el-input
v-model="newTodo"
placeholder="输入新的 Todo"
@keyup.enter="addTodo"
>
<template #append>
<el-button @click="addTodo">添加</el-button>
</template>
</el-input>
<!-- 筛选 -->
<el-radio-group v-model="filter" class="filter-group">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="active">未完成</el-radio-button>
<el-radio-button label="completed">已完成</el-radio-button>
</el-radio-group>
<!-- 列表 -->
<ul class="todo-list">
<li
v-for="todo in filteredTodos"
:key="todo.id"
class="todo-item"
>
<el-checkbox
v-model="todo.completed"
@change="updateTodo(todo)"
/>
<span :class="{ completed: todo.completed }">
{{ todo.title }}
</span>
<el-button
type="danger"
size="small"
@click="deleteTodo(todo.id)"
>
删除
</el-button>
</li>
</ul>
<!-- 统计 -->
<div class="stats">
<span>总计:{{ total }}</span>
<span>已完成:{{ completedCount }}</span>
<span>未完成:{{ activeCount }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Todo, FilterType } from '@/types/todo'
// 状态
const todos = ref<Todo[]>([])
const newTodo = ref('')
const filter = ref<FilterType>('all')
// 计算属性:筛选后的 Todo
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(todo => !todo.completed)
case 'completed':
return todos.value.filter(todo => todo.completed)
default:
return todos.value
}
})
// 计算属性:统计
const total = computed(() => todos.value.length)
const completedCount = computed(() =>
todos.value.filter(todo => todo.completed).length
)
const activeCount = computed(() =>
todos.value.filter(todo => !todo.completed).length
)
// 方法
const addTodo = () => {
if (!newTodo.value.trim()) return
todos.value.push({
id: Date.now().toString(),
title: newTodo.value.trim(),
completed: false,
createdAt: new Date()
})
newTodo.value = ''
}
const deleteTodo = (id: string) => {
const index = todos.value.findIndex(todo => todo.id === id)
if (index !== -1) {
todos.value.splice(index, 1)
}
}
const updateTodo = (todo: Todo) => {
// 在 ref 中,直接修改对象属性是响应式的
// 如果是 reactive,也是响应式的
console.log('Updated:', todo)
}
</script>
<style scoped>
.todo-view {
padding: 20px;
}
.filter-group {
margin: 20px 0;
}
.todo-list {
list-style: none;
padding: 0;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #eee;
}
.completed {
text-decoration: line-through;
color: #999;
}
.stats {
margin-top: 20px;
display: flex;
gap: 20px;
}
</style>
方式二:Pinia Store
创建 src/stores/todo.ts:
typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Todo, FilterType } from '@/types/todo'
export const useTodoStore = defineStore('todo', () => {
// State
const todos = ref<Todo[]>([])
const filter = ref<FilterType>('all')
// Getters
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter(todo => !todo.completed)
case 'completed':
return todos.value.filter(todo => todo.completed)
default:
return todos.value
}
})
const total = computed(() => todos.value.length)
const completedCount = computed(() =>
todos.value.filter(todo => todo.completed).length
)
const activeCount = computed(() =>
todos.value.filter(todo => !todo.completed).length
)
// Actions
const addTodo = (title: string) => {
if (!title.trim()) return
todos.value.push({
id: Date.now().toString(),
title: title.trim(),
completed: false,
createdAt: new Date()
})
}
const deleteTodo = (id: string) => {
const index = todos.value.findIndex(todo => todo.id === id)
if (index !== -1) {
todos.value.splice(index, 1)
}
}
const toggleTodo = (id: string) => {
const todo = todos.value.find(todo => todo.id === id)
if (todo) {
todo.completed = !todo.completed
}
}
const setFilter = (newFilter: FilterType) => {
filter.value = newFilter
}
return {
// State
todos,
filter,
// Getters
filteredTodos,
total,
completedCount,
activeCount,
// Actions
addTodo,
deleteTodo,
toggleTodo,
setFilter
}
})
使用 Store 的组件:
vue
<script setup lang="ts">
import { ref } from 'vue'
import { useTodoStore } from '@/stores/todo'
import type { FilterType } from '@/types/todo'
const todoStore = useTodoStore()
const newTodo = ref('')
const addTodo = () => {
todoStore.addTodo(newTodo.value)
newTodo.value = ''
}
</script>
<template>
<!-- 使用 todoStore.filteredTodos 等 -->
</template>
自检问题:什么情况下不应该用 Pinia?
点击查看答案
- 简单组件状态:只在单个组件内使用的状态
- 临时状态:表单输入、UI 状态(如 loading)
- 小型项目:状态简单,不需要全局管理
- 过度设计:避免为了用而用,增加复杂度
- 父子组件通信:props/emit 就能解决的情况
什么时候该用 Pinia?
- 跨组件共享状态
- 需要持久化的状态
- 复杂的状态逻辑
- 需要状态快照/回滚
3.3 组件拆分练习
步骤 1:拆分 TodoItem 组件
创建 src/components/todo/TodoItem.vue:
vue
<template>
<li class="todo-item">
<el-checkbox
:model-value="todo.completed"
@update:model-value="$emit('toggle', todo.id)"
/>
<span :class="{ completed: todo.completed }">
{{ todo.title }}
</span>
<el-button
type="danger"
size="small"
@click="$emit('delete', todo.id)"
>
删除
</el-button>
</li>
</template>
<script setup lang="ts">
import type { Todo } from '@/types/todo'
defineProps<{
todo: Todo
}>()
defineEmits<{
toggle: [id: string]
delete: [id: string]
}>()
</script>
步骤 2:拆分 TodoInput 组件
创建 src/components/todo/TodoInput.vue:
vue
<template>
<el-input
:model-value="modelValue"
placeholder="输入新的 Todo"
@update:model-value="$emit('update:modelValue', $event)"
@keyup.enter="$emit('add')"
>
<template #append>
<el-button @click="$emit('add')">添加</el-button>
</template>
</el-input>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
defineEmits<{
'update:modelValue': [value: string]
add: []
}>()
</script>
步骤 3:拆分 TodoFilter 组件
创建 src/components/todo/TodoFilter.vue:
vue
<template>
<el-radio-group
:model-value="modelValue"
@update:model-value="$emit('update:modelValue', $event)"
>
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="active">未完成</el-radio-button>
<el-radio-button label="completed">已完成</el-radio-button>
</el-radio-group>
</template>
<script setup lang="ts">
import type { FilterType } from '@/types/todo'
defineProps<{
modelValue: FilterType
}>()
defineEmits<{
'update:modelValue': [value: FilterType]
}>()
</script>
📝 阶段四:表单页(对齐真实业务)
4.1 Element Plus 表单
创建 src/views/FormView.vue:
vue
<template>
<div class="form-view">
<h1>用户信息编辑</h1>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" />
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="formData.age" :min="0" :max="150" />
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="formData.gender">
<el-radio label="male">男</el-radio>
<el-radio label="female">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="城市" prop="city">
<el-select v-model="formData.city" placeholder="请选择城市">
<el-option label="北京" value="beijing" />
<el-option label="上海" value="shanghai" />
<el-option label="广州" value="guangzhou" />
<el-option label="深圳" value="shenzhen" />
</el-select>
</el-form-item>
<el-form-item label="简介" prop="bio">
<el-input
v-model="formData.bio"
type="textarea"
:rows="4"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit">
提交
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus'
// 表单引用
const formRef = ref<FormInstance>()
// 表单数据
const formData = reactive({
name: '',
email: '',
age: 18,
gender: '',
city: '',
bio: ''
})
// 自定义校验规则
const validateEmail = (rule: any, value: any, callback: any) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!value) {
callback(new Error('请输入邮箱'))
} else if (!emailRegex.test(value)) {
callback(new Error('请输入正确的邮箱格式'))
} else {
callback()
}
}
const validateAge = (rule: any, value: any, callback: any) => {
if (!value && value !== 0) {
callback(new Error('请输入年龄'))
} else if (value < 0 || value > 150) {
callback(new Error('年龄必须在 0-150 之间'))
} else {
callback()
}
}
// 校验规则
const rules: FormRules = {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, validator: validateEmail, trigger: 'blur' }
],
age: [
{ required: true, validator: validateAge, trigger: 'blur' }
],
gender: [
{ required: true, message: '请选择性别', trigger: 'change' }
],
city: [
{ required: true, message: '请选择城市', trigger: 'change' }
]
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
console.log('表单数据:', formData)
ElMessage.success('提交成功!')
} else {
ElMessage.error('请检查表单填写是否正确')
}
})
}
// 重置表单
const handleReset = () => {
if (!formRef.value) return
formRef.value.resetFields()
}
</script>
<style scoped>
.form-view {
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
</style>
自检问题:表单校验是前端负责还是后端负责?为什么?
点击查看答案
答案:前后端都要负责,但职责不同。
前端校验:
- 提升用户体验,快速反馈
- 减少无效请求,节省服务器资源
- 防止用户误操作
后端校验:
- 安全性:前端校验可以被绕过
- 数据一致性:确保存储的数据符合规则
- 业务逻辑:某些校验需要查询数据库
最佳实践:
- 前端:格式校验、必填校验、即时反馈
- 后端:业务规则校验、权限校验、数据唯一性校验
🌐 阶段五:请求封装(工程意识)
5.1 axios 封装
创建 src/services/request.ts:
typescript
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: '/api', // 基础 URL
timeout: 10000 // 超时时间
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 可以在这里添加 token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
// 对响应数据做处理
const { data } = response
return data
},
(error) => {
// 统一错误处理
let message = '请求失败'
if (error.response) {
switch (error.response.status) {
case 400:
message = '请求参数错误'
break
case 401:
message = '未授权,请登录'
break
case 403:
message = '拒绝访问'
break
case 404:
message = '请求资源不存在'
break
case 500:
message = '服务器内部错误'
break
default:
message = `请求失败:${error.response.status}`
}
} else if (error.request) {
message = '网络错误,请检查网络连接'
}
ElMessage.error(message)
return Promise.reject(error)
}
)
// 封装请求方法
export const request = {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
}
}
export default service
创建 src/services/todo.ts:
typescript
import { request } from './request'
import type { Todo } from '@/types/todo'
// Mock 数据(实际项目中会调用真实 API)
const mockTodos: Todo[] = [
{
id: '1',
title: '学习 Vue3',
completed: false,
createdAt: new Date()
},
{
id: '2',
title: '学习 TypeScript',
completed: true,
createdAt: new Date()
}
]
// 模拟延迟
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export const todoService = {
// 获取所有 Todo
async getTodos(): Promise<Todo[]> {
// 模拟 API 请求
await delay(500)
return [...mockTodos]
},
// 添加 Todo
async addTodo(title: string): Promise<Todo> {
await delay(300)
const newTodo: Todo = {
id: Date.now().toString(),
title,
completed: false,
createdAt: new Date()
}
mockTodos.push(newTodo)
return newTodo
},
// 删除 Todo
async deleteTodo(id: string): Promise<void> {
await delay(300)
const index = mockTodos.findIndex(todo => todo.id === id)
if (index !== -1) {
mockTodos.splice(index, 1)
}
},
// 切换完成状态
async toggleTodo(id: string): Promise<Todo> {
await delay(300)
const todo = mockTodos.find(todo => todo.id === id)
if (todo) {
todo.completed = !todo.completed
}
return todo!
}
}
自检问题:为什么不在组件里直接写 axios?
点击查看答案
- 复用性:多个组件可以复用同一个请求
- 可维护性:API 变更时只需修改 services 层
- 统一处理:统一处理拦截器、错误处理、token 等
- 测试友好:方便 mock 和单元测试
- 关注点分离:组件关注 UI,services 关注数据获取
5.2 fetch 对比
创建 src/services/todoFetch.ts:
typescript
import type { Todo } from '@/types/todo'
const BASE_URL = '/api'
// 封装 fetch 请求
async function fetchRequest<T>(
url: string,
options?: RequestInit
): Promise<T> {
try {
const response = await fetch(`${BASE_URL}${url}`, {
headers: {
'Content-Type': 'application/json',
...options?.headers
},
...options
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('Fetch error:', error)
throw error
}
}
export const todoFetchService = {
async getTodos(): Promise<Todo[]> {
return fetchRequest<Todo[]>('/todos')
},
async addTodo(title: string): Promise<Todo> {
return fetchRequest<Todo>('/todos', {
method: 'POST',
body: JSON.stringify({ title })
})
},
async deleteTodo(id: string): Promise<void> {
return fetchRequest<void>(`/todos/${id}`, {
method: 'DELETE'
})
}
}
axios vs fetch 对比:
| 特性 | axios | fetch |
|---|---|---|
| 浏览器支持 | 需要引入库 | 原生支持 |
| 请求/响应拦截 | ✅ 支持 | ❌ 需要手动封装 |
| 自动转换 JSON | ✅ 支持 | ❌ 需要手动调用 response.json() |
| 超时处理 | ✅ 支持 | ❌ 需要配合 AbortController |
| 错误处理 | HTTP 错误会 reject | HTTP 错误不会 reject |
| 取消请求 | ✅ CancelToken | ✅ AbortController |
适用场景:
- axios:企业级项目,需要完善的拦截器和错误处理
- fetch:轻量级项目,或需要原生 API
✅ 完成标准
当你 全部满足以下条件,才算完成:
- 能独立从 0 起一个 Vue 项目
- TodoList 不看资料能写出来
- 路由 + Tab 联动不踩坑
- 表单 + 校验熟练
- 能回答"为什么这样设计"
💪 练习建议
学习方法
- 不看视频
- 不复制现成项目
- 允许查官方文档
- 使用 AI 作为讲解者(不会的直接把本文不会的部分喂给 GPT)
- 所有功能必须自己从 0 写出来,并能解释为什么这样做
卡住时怎么办
- 先查官方文档
- 再问 AI
- 实在不会再问导师
练习次数
- 至少完整写 3 遍
- 第一遍:参考教程
- 第二遍:不看教程,卡住时再查
- 第三遍:完全独立完成
📖 官方文档链接
🎉 完成这一阶段,你将获得
- ✅ 前端基础开发能力
- ✅ CRUD 项目你能闭眼起
- ✅ 面试里任何 Vue / 工程化追问你都有"做过"的东西可说
- ✅ 进入下一阶段:项目包装
祝你学习顺利!加油!💪