Vue Todo 实战练习教程(简略版)

📌 学习目标

通过这个项目,你将获得:

  • ✅ 前端基础开发能力
  • ✅ 能独立从 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?
点击查看答案

  1. 快速启动:Vite 利用浏览器原生 ES 模块,无需打包即可启动开发服务器
  2. 即时热更新:不管项目多大,热更新速度都很快
  3. 按需编译:只编译当前页面需要的代码
  4. 更好的开发体验: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 这一层?
点击查看答案

  1. 关注点分离:将 API 请求逻辑从组件中抽离
  2. 复用性:多个组件可以复用同一个 API 请求
  3. 可维护性:API 变更时只需修改 services 层
  4. 统一管理:统一处理请求拦截、响应拦截、错误处理
  5. 测试友好:方便 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

自检问题:路由为什么要分文件管理?
点击查看答案

  1. 可维护性:路由配置独立,方便查找和修改
  2. 扩展性:方便添加路由守卫、路由元信息等
  3. 类型安全:TypeScript 类型检查更友好
  4. 职责分离:路由逻辑与业务逻辑分离

自检问题:路由懒加载解决了什么问题?
点击查看答案

  1. 减少首屏加载时间:只加载当前路由需要的代码
  2. 按需加载:用户访问某个路由时才加载对应组件
  3. 代码分割:Webpack/Vite 会自动将懒加载的组件分割成独立的 chunk
  4. 提升性能:减少首屏 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>

关键点:

  • 使用 useRouteuseRouter 获取路由信息和方法
  • activeTab 与路由路径保持同步
  • 监听路由变化更新 Tab 状态
  • Tab 切换时更新路由

自检问题:为什么不单独用 ref 维护 tab 状态?
点击查看答案

  1. 单一可信源(Single Source of Truth):路由是状态的唯一来源
  2. 刷新保持状态:刷新页面后,Tab 状态能正确恢复
  3. 浏览器前进/后退:能正确响应浏览器导航
  4. URL 分享:用户可以直接访问特定 Tab 的 URL
  5. 避免状态不一致: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?
点击查看答案

  1. 删除问题:删除元素后,后续元素的下标会改变
  2. 唯一性:下标不能保证全局唯一(如数据库存储)
  3. 可读性:字符串 id 更易读和调试
  4. 扩展性:方便与后端 API 对接
  5. 稳定性:元素移动时 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?
点击查看答案

  1. 简单组件状态:只在单个组件内使用的状态
  2. 临时状态:表单输入、UI 状态(如 loading)
  3. 小型项目:状态简单,不需要全局管理
  4. 过度设计:避免为了用而用,增加复杂度
  5. 父子组件通信: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?
点击查看答案

  1. 复用性:多个组件可以复用同一个请求
  2. 可维护性:API 变更时只需修改 services 层
  3. 统一处理:统一处理拦截器、错误处理、token 等
  4. 测试友好:方便 mock 和单元测试
  5. 关注点分离:组件关注 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 联动不踩坑
  • 表单 + 校验熟练
  • 能回答"为什么这样设计"

💪 练习建议

学习方法

  1. 不看视频
  2. 不复制现成项目
  3. 允许查官方文档
  4. 使用 AI 作为讲解者(不会的直接把本文不会的部分喂给 GPT)
  5. 所有功能必须自己从 0 写出来,并能解释为什么这样做

卡住时怎么办

  1. 先查官方文档
  2. 再问 AI
  3. 实在不会再问导师

练习次数

  • 至少完整写 3 遍
  • 第一遍:参考教程
  • 第二遍:不看教程,卡住时再查
  • 第三遍:完全独立完成

📖 官方文档链接


🎉 完成这一阶段,你将获得

  • ✅ 前端基础开发能力
  • ✅ CRUD 项目你能闭眼起
  • ✅ 面试里任何 Vue / 工程化追问你都有"做过"的东西可说
  • ✅ 进入下一阶段:项目包装

祝你学习顺利!加油!💪

相关推荐
dzj8882 小时前
云朵字生成器-html
前端·css·html·云朵字
FlyWIHTSKY2 小时前
Vue 3 单文件组件加载顺序详解
前端·javascript·vue.js
周万宁.FoBJ2 小时前
vue源码讲解之 reactive解析(仅proxy部分)
开发语言·javascript·ecmascript
乔磊2 小时前
我开发了一个 Ralph CLI
javascript
霪霖笙箫2 小时前
真授之以渔:我是怎么从"想给文章配几张图",一步步做出一个可发布 skill 的
前端·人工智能·开源
yzin2 小时前
【源码】【react】useCallback、useMemo、memo 原理
前端·react.js
CHU7290352 小时前
扭蛋机盲盒小程序前端功能设计及核心玩法介绍
前端·小程序
进击的尘埃2 小时前
Module Federation 2.0 共享策略翻车实录:版本协商、热更新与依赖冲突的排查工具链
javascript