Vue3异步请求实战:从封装axios到表格数据渲染(附完整代码)

Vue3异步请求实战:从封装axios到表格数据渲染(附完整代码)

最近在重构一个后台管理系统,前端技术栈选用了Vue3。说实话,从Vue2迁移过来,最让我头疼的不是Composition API的语法变化,而是如何优雅地处理异步请求。在Vue2时代,我们习惯了在mounted里直接调用接口,数据回来后再this.xxx = res.data。但在Vue3的响应式系统里,特别是配合TypeScript使用时,我发现原来的那套做法虽然能用,但代码组织得相当混乱,维护起来简直是灾难。

于是我开始思考:能不能设计一套既符合Vue3哲学,又能让团队快速上手的异步请求方案?经过几个项目的迭代,我总结出了一套从axios封装到数据渲染的完整流程。这套方案不仅解决了基础的数据获取问题,还考虑了错误处理、加载状态、类型安全等实际开发中必然会遇到的痛点。今天我就把这套方案完整地分享出来,希望能帮你少走弯路。

1. 为什么需要重新思考Vue3的异步请求?

在Vue2时代,我们处理异步请求的模式相对固定。通常是在组件的methods里定义方法,然后在mounted或某个事件中调用,最后通过this.data = response.data来更新视图。这种模式简单直接,对于小型项目来说完全够用。

但到了Vue3,情况发生了变化。Composition API的引入让我们可以更灵活地组织代码逻辑,但同时也带来了新的挑战。如果你还在用Vue2的思维写Vue3的异步请求,可能会遇到这些问题:

  • 响应式数据更新不够直观reactiveref的使用需要更精确的控制
  • 类型支持薄弱:没有良好的TypeScript支持,接口返回的数据类型无法得到保障
  • 错误处理分散:每个请求都要单独写try-catch,代码重复严重
  • 加载状态管理混乱:每个组件都要自己维护loading状态,状态逻辑分散
  • 代码复用性差:相似的请求逻辑在不同组件中重复编写

更关键的是,Vue3的响应式系统底层已经重写,对异步更新的处理也更加精细。如果我们还沿用旧模式,不仅无法发挥Vue3的全部优势,还可能因为对响应式系统理解不深而写出有性能问题的代码。

提示:Vue3的refreactive在异步更新时的行为略有不同。ref更适合在模板中直接使用,而reactive更适合复杂对象的响应式转换。理解这两者的区别对编写高效的异步代码很重要。

1.1 现代前端异步请求的发展趋势

看看现在的前端生态,你会发现异步请求的处理方式正在发生明显变化:

  1. 从分散到集中:越来越多的项目开始采用统一的请求层管理,而不是在每个组件中零散地调用接口
  2. 从命令式到声明式:像React Query、SWR这样的库让数据获取变得更加声明式
  3. 类型安全成为标配:TypeScript的普及让接口类型定义变得必不可少
  4. 状态管理集成:异步请求的状态(loading、error、data)需要与全局状态管理方案更好地集成

虽然Vue生态中也有类似Pinia Query这样的解决方案,但对于大多数项目来说,基于axios的轻量级封装仍然是性价比最高的选择。它既不会引入过多的学习成本,又能解决大部分实际问题。

2. 构建健壮的axios封装层

axios的封装不是简单地设置个baseURL就完事了。一个完整的请求层应该考虑以下几个方面:

  • 统一的请求/响应拦截器
  • 错误处理的标准化
  • 请求取消机制
  • 类型安全支持
  • 开发环境与生产环境的配置分离

下面是我在实际项目中使用的axios封装方案,这个方案经过了多个项目的验证,稳定性和可维护性都不错。

2.1 基础axios实例配置

首先,在项目的src/utils目录下创建request.ts文件:

import axios, {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig
} from 'axios'
import { ElMessage } from 'element-plus' // 如果你使用Element Plus

// 环境配置
const isDevelopment = process.env.NODE_ENV === 'development'

// 创建axios实例
const createAxiosInstance = (config?: AxiosRequestConfig): AxiosInstance => {
  const instance = axios.create({
    baseURL: isDevelopment 
      ? 'http://localhost:3000/api' 
      : 'https://api.yourdomain.com',
    timeout: 15000,
    withCredentials: true,
    headers: {
      'Content-Type': 'application/json;charset=UTF-8'
    },
    ...config
  })

  return instance
}

// 默认实例
const request = createAxiosInstance()

这里有几个关键点需要注意:

  1. 环境区分:开发环境和生产环境使用不同的baseURL
  2. 超时设置:15秒的超时时间对于大多数接口来说比较合适
  3. withCredentials:如果需要携带cookie,这个配置必须为true
  4. Content-Type:明确指定字符集,避免中文乱码问题

2.2 请求拦截器的实战技巧

请求拦截器不仅仅是添加token那么简单,它还可以做很多有用的事情:

// 请求拦截器
request.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 从localStorage或pinia store获取token
    const token = localStorage.getItem('access_token')
    
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    
    // 为GET请求添加时间戳,避免缓存
    if (config.method?.toLowerCase() === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now()
      }
    }
    
    // 开发环境日志
    if (isDevelopment) {
      console.log(`🚀 [${config.method?.toUpperCase()}] ${config.url}`, config.params || config.data)
    }
    
    return config
  },
  (error) => {
    console.error('请求拦截器错误:', error)
    return Promise.reject(error)
  }
)

这个拦截器做了三件事:

  1. 自动添加认证token:从本地存储获取token并添加到请求头
  2. GET请求防缓存:为所有GET请求添加时间戳参数
  3. 开发环境日志:方便调试,生产环境不会输出

2.3 响应拦截器的错误处理策略

响应拦截器是错误处理的中心,这里需要处理各种HTTP状态码和业务错误码:

// 响应拦截器
request.interceptors.response.use(
  (response: AxiosResponse) => {
    const { data, config } = response
    
    // 开发环境日志
    if (isDevelopment) {
      console.log(`✅ [${config.method?.toUpperCase()}] ${config.url} 响应:`, data)
    }
    
    // 处理业务逻辑错误
    if (data.code !== 0 && data.code !== 200) {
      const errorMsg = data.message || '请求失败'
      
      // 根据错误码进行不同处理
      switch (data.code) {
        case 401:
          // token过期,跳转到登录页
          localStorage.removeItem('access_token')
          window.location.href = '/login'
          break
        case 403:
          ElMessage.error('权限不足')
          break
        default:
          ElMessage.error(errorMsg)
      }
      
      return Promise.reject(new Error(errorMsg))
    }
    
    // 返回真正的业务数据
    return data.data ?? data
  },
  (error) => {
    // 网络错误或超时
    if (!error.response) {
      ElMessage.error('网络连接失败,请检查网络设置')
      return Promise.reject(error)
    }
    
    const { status, data } = error.response
    
    // HTTP状态码处理
    switch (status) {
      case 400:
        ElMessage.error(data?.message || '请求参数错误')
        break
      case 404:
        ElMessage.error('请求的资源不存在')
        break
      case 500:
        ElMessage.error('服务器内部错误')
        break
      case 502:
      case 503:
      case 504:
        ElMessage.error('服务器暂时不可用,请稍后重试')
        break
      default:
        ElMessage.error(`请求失败: ${status}`)
    }
    
    return Promise.reject(error)
  }
)

这个响应拦截器的设计有几个优点:

  • 统一的错误提示:所有错误都在这里处理,组件中不需要重复写错误提示
  • 业务错误码分离:HTTP状态码和业务错误码分开处理
  • 自动跳转登录:token过期时自动清理并跳转,提升用户体验
  • 返回数据清洗:只返回真正的业务数据,避免在每个组件中都要res.data.data

2.4 类型安全的请求函数封装

为了让TypeScript发挥最大作用,我们需要为请求函数添加完整的类型支持:

// 定义通用的响应结构
export interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
}

// 定义分页响应结构
export interface PaginatedResponse<T = any> {
  items: T[]
  total: number
  page: number
  pageSize: number
  totalPages: number
}

// 封装通用的请求方法
export const http = {
  get<T = any>(url: string, params?: any): Promise<T> {
    return request.get(url, { params })
  },
  
  post<T = any>(url: string, data?: any): Promise<T> {
    return request.post(url, data)
  },
  
  put<T = any>(url: string, data?: any): Promise<T> {
    return request.put(url, data)
  },
  
  delete<T = any>(url: string, params?: any): Promise<T> {
    return request.delete(url, { params })
  },
  
  // 文件上传
  upload<T = any>(url: string, formData: FormData): Promise<T> {
    return request.post(url, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })
  }
}

现在,我们有了一个类型安全、功能完整的请求层。接下来看看如何在实际的业务场景中使用它。

3. 业务API层的设计与实现

有了底层的请求封装,我们需要在此基础上构建业务API层。这个层的主要职责是:

  • 组织不同模块的API
  • 定义接口的请求/响应类型
  • 提供统一的错误处理(如果需要特殊处理)
  • 实现接口的复用和组合

3.1 用户模块API示例

让我们以用户管理模块为例,看看如何设计业务API:

// src/api/user.ts
import { http } from '@/utils/request'
import type { PaginatedResponse } from '@/utils/request'

// 类型定义
export interface User {
  id: number
  username: string
  email: string
  avatar?: string
  role: 'admin' | 'user' | 'guest'
  createdAt: string
  updatedAt: string
}

export interface CreateUserDto {
  username: string
  email: string
  password: string
  role?: User['role']
}

export interface UpdateUserDto extends Partial<CreateUserDto> {
  id: number
}

export interface LoginDto {
  username: string
  password: string
}

export interface LoginResponse {
  token: string
  user: User
}

// API函数
export const userApi = {
  // 登录
  login(data: LoginDto): Promise<LoginResponse> {
    return http.post('/auth/login', data)
  },
  
  // 获取当前用户信息
  getCurrentUser(): Promise<User> {
    return http.get('/users/me')
  },
  
  // 获取用户列表(分页)
  getUsers(params: {
    page?: number
    pageSize?: number
    keyword?: string
    role?: User['role']
  }): Promise<PaginatedResponse<User>> {
    return http.get('/users', params)
  },
  
  // 获取单个用户
  getUser(id: number): Promise<User> {
    return http.get(`/users/${id}`)
  },
  
  // 创建用户
  createUser(data: CreateUserDto): Promise<User> {
    return http.post('/users', data)
  },
  
  // 更新用户
  updateUser(data: UpdateUserDto): Promise<User> {
    return http.put(`/users/${data.id}`, data)
  },
  
  // 删除用户
  deleteUser(id: number): Promise<void> {
    return http.delete(`/users/${id}`)
  },
  
  // 批量删除用户
  batchDeleteUsers(ids: number[]): Promise<void> {
    return http.post('/users/batch-delete', { ids })
  }
}

这种组织方式有几个好处:

  1. 类型集中管理:所有与用户相关的类型都在一个文件中定义
  2. API函数化:每个接口都是一个纯函数,易于测试和复用
  3. 参数类型安全:调用时会有完整的类型提示
  4. 易于扩展:新增接口只需要在对应模块中添加函数

3.2 更复杂的API组合示例

在实际开发中,我们经常需要组合多个API调用。比如,获取用户详情的同时获取该用户的订单列表:

// src/api/combined.ts
import { userApi } from './user'
import { orderApi } from './order'
import type { User, Order } from './types'

export interface UserWithOrders {
  user: User
  orders: Order[]
  orderCount: number
}

export const combinedApi = {
  // 并行请求:同时获取用户信息和订单列表
  async getUserWithOrders(userId: number): Promise<UserWithOrders> {
    const [user, orders] = await Promise.all([
      userApi.getUser(userId),
      orderApi.getUserOrders(userId)
    ])
    
    return {
      user,
      orders: orders.items,
      orderCount: orders.total
    }
  },
  
  // 串行请求:先创建用户,然后为其分配默认角色
  async createUserWithDefaultRole(data: CreateUserDto): Promise<User> {
    const user = await userApi.createUser(data)
    
    // 为新用户分配默认角色
    await roleApi.assignDefaultRole(user.id)
    
    return user
  }
}

这种组合API的模式特别适合复杂业务场景,它把多个相关的操作封装在一起,让组件代码更加简洁。

4. Vue3组件中的异步数据管理

现在来到了最核心的部分:如何在Vue3组件中优雅地使用这些API。我见过太多开发者在这个环节写出难以维护的代码,主要问题集中在:

  • 加载状态管理混乱
  • 错误处理重复
  • 数据更新时机不当
  • 内存泄漏(组件卸载后仍然更新状态)

4.1 基础数据获取模式

让我们从一个简单的用户列表组件开始:

<template>
  <div class="user-management">
    <!-- 加载状态 -->
    <div v-if="loading" class="loading-container">
      <el-skeleton :rows="5" animated />
    </div>
    
    <!-- 错误状态 -->
    <div v-else-if="error" class="error-container">
      <el-alert
        :title="error"
        type="error"
        show-icon
        @close="fetchUsers"
      />
      <el-button type="primary" @click="fetchUsers">
        重试
      </el-button>
    </div>
    
    <!-- 正常状态 -->
    <div v-else>
      <!-- 搜索和操作区域 -->
      <div class="toolbar">
        <el-input
          v-model="searchKeyword"
          placeholder="搜索用户名或邮箱"
          clearable
          @input="handleSearch"
        />
        <el-button type="primary" @click="handleCreate">
          新建用户
        </el-button>
      </div>
      
      <!-- 数据表格 -->
      <el-table :data="users" style="width: 100%">
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="username" label="用户名" />
        <el-table-column prop="email" label="邮箱" />
        <el-table-column prop="role" label="角色">
          <template #default="{ row }">
            <el-tag :type="roleTagType(row.role)">
              {{ row.role }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createdAt" label="创建时间">
          <template #default="{ row }">
            {{ formatDate(row.createdAt) }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="200">
          <template #default="{ row }">
            <el-button size="small" @click="handleEdit(row)">
              编辑
            </el-button>
            <el-button
              size="small"
              type="danger"
              @click="handleDelete(row.id)"
            >
              删除
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      
      <!-- 分页 -->
      <div class="pagination">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :total="total"
          :page-sizes="[10, 20, 50, 100]"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handlePageChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { userApi } from '@/api/user'
import type { User } from '@/api/user'

// 响应式数据
const users = ref<User[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)

// 获取用户列表
const fetchUsers = async () => {
  loading.value = true
  error.value = null
  
  try {
    const params = {
      page: currentPage.value,
      pageSize: pageSize.value,
      keyword: searchKeyword.value || undefined
    }
    
    const response = await userApi.getUsers(params)
    users.value = response.items
    total.value = response.total
  } catch (err: any) {
    error.value = err.message || '获取用户列表失败'
    console.error('获取用户列表失败:', err)
  } finally {
    loading.value = false
  }
}

// 搜索处理(防抖)
let searchTimer: number | null = null
const handleSearch = () => {
  if (searchTimer) {
    clearTimeout(searchTimer)
  }
  
  searchTimer = setTimeout(() => {
    currentPage.value = 1 // 搜索时回到第一页
    fetchUsers()
  }, 500)
}

// 分页处理
const handleSizeChange = (newSize: number) => {
  pageSize.value = newSize
  fetchUsers()
}

const handlePageChange = (newPage: number) => {
  currentPage.value = newPage
  fetchUsers()
}

// 删除用户
const handleDelete = async (id: number) => {
  try {
    await ElMessageBox.confirm('确定要删除这个用户吗?', '提示', {
      type: 'warning'
    })
    
    await userApi.deleteUser(id)
    ElMessage.success('删除成功')
    
    // 重新获取数据
    fetchUsers()
  } catch (err) {
    // 用户点击取消,不执行删除
    if (err !== 'cancel') {
      ElMessage.error('删除失败')
    }
  }
}

// 辅助函数
const roleTagType = (role: string) => {
  const map: Record<string, string> = {
    admin: 'danger',
    user: 'primary',
    guest: 'info'
  }
  return map[role] || 'default'
}

const formatDate = (dateString: string) => {
  return new Date(dateString).toLocaleDateString()
}

// 生命周期
onMounted(() => {
  fetchUsers()
})

// 监听搜索关键词变化
watch(searchKeyword, () => {
  handleSearch()
})
</script>

<style scoped>
.user-management {
  padding: 20px;
}

.loading-container,
.error-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 400px;
}

.toolbar {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
}

.pagination {
  margin-top: 20px;
  display: flex;
  justify-content: flex-end;
}
</style>

这个组件展示了Vue3中处理异步请求的完整模式:

功能实现方式说明
加载状态loading ref变量控制骨架屏的显示
错误处理error ref变量 + try-catch统一错误提示和重试机制
数据获取fetchUsers 函数封装所有获取逻辑
搜索功能防抖处理避免频繁请求
分页监听分页组件事件参数变化时重新请求
用户操作独立的处理函数每个操作都有完整的反馈

4.2 使用Composition API封装复用逻辑

上面的组件虽然功能完整,但逻辑都写在组件内部,难以复用。我们可以使用Composition API把这些逻辑抽离出来:

// src/composables/useAsyncData.ts
import { ref, onUnmounted } from 'vue'

export interface UseAsyncDataOptions<T, P extends any[]> {
  // 异步函数
  handler: (...args: P) => Promise<T>
  // 是否立即执行
  immediate?: boolean
  // 初始数据
  initialData?: T
  // 错误处理函数
  onError?: (error: Error) => void
}

export interface UseAsyncDataReturn<T, P extends any[]> {
  // 数据
  data: Ref<T | null>
  // 加载状态
  loading: Ref<boolean>
  // 错误信息
  error: Ref<string | null>
  // 执行函数
  execute: (...args: P) => Promise<T>
  // 重置状态
  reset: () => void
}

export function useAsyncData<T, P extends any[] = []>(
  options: UseAsyncDataOptions<T, P>
): UseAsyncDataReturn<T, P> {
  const {
    handler,
    immediate = true,
    initialData = null,
    onError
  } = options
  
  const data = ref<T | null>(initialData) as Ref<T | null>
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // 取消请求的标记
  let abortController: AbortController | null = null
  
  const execute = async (...args: P): Promise<T> => {
    // 取消之前的请求
    if (abortController) {
      abortController.abort()
    }
    
    // 创建新的AbortController
    abortController = new AbortController()
    
    loading.value = true
    error.value = null
    
    try {
      const result = await handler(...args)
      data.value = result
      return result
    } catch (err: any) {
      // 如果是取消请求,不处理错误
      if (err.name === 'AbortError') {
        throw err
      }
      
      error.value = err.message || '请求失败'
      
      // 调用自定义错误处理
      if (onError) {
        onError(err)
      } else {
        console.error('异步请求失败:', err)
      }
      
      throw err
    } finally {
      loading.value = false
      abortController = null
    }
  }
  
  const reset = () => {
    data.value = initialData
    error.value = null
    loading.value = false
    
    // 取消未完成的请求
    if (abortController) {
      abortController.abort()
      abortController = null
    }
  }
  
  // 组件卸载时取消请求
  onUnmounted(() => {
    if (abortController) {
      abortController.abort()
    }
  })
  
  // 立即执行
  if (immediate) {
    // @ts-ignore
    execute()
  }
  
  return {
    data,
    loading,
    error,
    execute,
    reset
  }
}

现在,我们可以用这个hook重写用户列表组件的数据获取逻辑:

// 在组件中使用
const {
  data: users,
  loading,
  error,
  execute: fetchUsers
} = useAsyncData({
  handler: async () => {
    const params = {
      page: currentPage.value,
      pageSize: pageSize.value,
      keyword: searchKeyword.value || undefined
    }
    const response = await userApi.getUsers(params)
    total.value = response.total
    return response.items
  },
  initialData: [],
  onError: (err) => {
    ElMessage.error(err.message || '获取用户列表失败')
  }
})

// 手动触发请求
const handleSearch = () => {
  currentPage.value = 1
  fetchUsers()
}

这种封装的好处显而易见:

  1. 逻辑复用:任何需要异步数据的组件都可以使用这个hook
  2. 自动取消请求:组件卸载时自动取消未完成的请求,避免内存泄漏
  3. 统一的加载和错误状态:不需要在每个组件中重复定义
  4. 更好的TypeScript支持:完整的类型推断

4.3 处理更复杂的异步场景

在实际项目中,我们经常会遇到更复杂的异步场景,比如:

场景一:表单提交与数据获取的联动

<script setup lang="ts">
import { useAsyncData } from '@/composables/useAsyncData'

// 获取用户列表
const {
  data: users,
  loading: listLoading,
  execute: fetchUsers
} = useAsyncData({
  handler: userApi.getUsers,
  initialData: { items: [], total: 0 }
})

// 创建用户
const {
  loading: createLoading,
  execute: createUser,
  reset: resetCreate
} = useAsyncData({
  handler: userApi.createUser,
  immediate: false,
  onError: (err) => {
    ElMessage.error(`创建用户失败: ${err.message}`)
  }
})

// 提交表单
const handleSubmit = async (formData: CreateUserDto) => {
  try {
    await createUser(formData)
    ElMessage.success('创建成功')
    
    // 重置表单
    resetCreate()
    
    // 刷新列表
    fetchUsers()
  } catch {
    // 错误已经在onError中处理了
  }
}
</script>

场景二:依赖多个API的复杂数据

// 获取用户及其相关数据
const fetchUserWithDetails = async (userId: number) => {
  const [user, orders, logs] = await Promise.all([
    userApi.getUser(userId),
    orderApi.getUserOrders(userId),
    logApi.getUserLogs(userId)
  ])
  
  return {
    user,
    orders: orders.items,
    logs: logs.items,
    orderCount: orders.total,
    logCount: logs.total
  }
}

const {
  data: userDetails,
  loading: detailsLoading
} = useAsyncData({
  handler: () => fetchUserWithDetails(props.userId),
  immediate: true
})

场景三:轮询数据更新

import { onUnmounted, ref } from 'vue'

export function usePolling<T>(
  handler: () => Promise<T>,
  interval: number = 5000
) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  let timer: number | null = null
  
  const execute = async () => {
    loading.value = true
    try {
      data.value = await handler()
      error.value = null
    } catch (err: any) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  const start = () => {
    // 立即执行一次
    execute()
    
    // 设置定时器
    timer = setInterval(execute, interval)
  }
  
  const stop = () => {
    if (timer) {
      clearInterval(timer)
      timer = null
    }
  }
  
  // 组件卸载时停止轮询
  onUnmounted(stop)
  
  return {
    data,
    loading,
    error,
    start,
    stop,
    execute
  }
}

// 使用示例
const {
  data: realtimeStats,
  start: startPolling,
  stop: stopPolling
} = usePolling(() => statsApi.getRealtime(), 3000)

// 在需要时开始轮询
onMounted(() => {
  startPolling()
})

5. 性能优化与最佳实践

异步请求处理不当很容易成为性能瓶颈。下面是一些我在实际项目中总结的优化技巧:

5.1 请求缓存策略

对于不经常变化的数据,我们可以实现简单的缓存机制:

// src/utils/cache.ts
interface CacheItem<T> {
  data: T
  timestamp: number
  expiresIn: number
}

class RequestCache {
  private cache = new Map<string, CacheItem<any>>()
  private defaultExpiresIn = 5 * 60 * 1000 // 5分钟
  
  set<T>(key: string, data: T, expiresIn?: number): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now(),
      expiresIn: expiresIn || this.defaultExpiresIn
    })
  }
  
  get<T>(key: string): T | null {
    const item = this.cache.get(key)
    
    if (!item) {
      return null
    }
    
    // 检查是否过期
    if (Date.now() - item.timestamp > item.expiresIn) {
      this.cache.delete(key)
      return null
    }
    
    return item.data
  }
  
  delete(key: string): void {
    this.cache.delete(key)
  }
  
  clear(): void {
    this.cache.clear()
  }
}

export const requestCache = new RequestCache()

// 使用缓存的请求hook
export function useCachedRequest<T, P extends any[]>(
  key: string,
  handler: (...args: P) => Promise<T>,
  options?: {
    expiresIn?: number
    useCache?: boolean
  }
) {
  const { expiresIn, useCache = true } = options || {}
  
  const { data, loading, error, execute } = useAsyncData({
    handler: async (...args: P) => {
      // 尝试从缓存获取
      if (useCache) {
        const cached = requestCache.get<T>(key)
        if (cached) {
          return cached
        }
      }
      
      // 执行实际请求
      const result = await handler(...args)
      
      // 缓存结果
      if (useCache) {
        requestCache.set(key, result, expiresIn)
      }
      
      return result
    }
  })
  
  // 清除缓存的方法
  const clearCache = () => {
    requestCache.delete(key)
  }
  
  return {
    data,
    loading,
    error,
    execute,
    clearCache
  }
}

5.2 请求合并与防抖

在搜索场景中,我们需要防止用户快速输入导致的频繁请求:

// src/utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeout: number | null = null
  
  return (...args: Parameters<T>) => {
    if (timeout) {
      clearTimeout(timeout)
    }
    
    timeout = setTimeout(() => {
      func(...args)
    }, wait)
  }
}

// 在组件中使用
const searchUsers = debounce(async (keyword: string) => {
  const response = await userApi.getUsers({ keyword })
  users.value = response.items
}, 500)

// 输入框变化时调用
const handleInput = (value: string) => {
  searchUsers(value)
}

5.3 请求优先级与取消

对于可能同时发起的多个请求,我们可以实现优先级控制:

// src/utils/requestQueue.ts
interface QueuedRequest {
  id: string
  priority: number
  execute: () => Promise<any>
  resolve: (value: any) => void
  reject: (reason?: any) => void
}

class RequestQueue {
  private queue: QueuedRequest[] = []
  private running = false
  private maxConcurrent = 3
  private activeCount = 0
  
  add<T>(
    id: string,
    execute: () => Promise<T>,
    priority: number = 0
  ): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push({
        id,
        priority,
        execute,
        resolve,
        reject
      })
      
      // 按优先级排序
      this.queue.sort((a, b) => b.priority - a.priority)
      
      this.processQueue()
    })
  }
  
  private async processQueue() {
    if (this.running || this.activeCount >= this.maxConcurrent) {
      return
    }
    
    this.running = true
    
    while (this.queue.length > 0 && this.activeCount < this.maxConcurrent) {
      const request = this.queue.shift()
      
      if (request) {
        this.activeCount++
        
        try {
          const result = await request.execute()
          request.resolve(result)
        } catch (error) {
          request.reject(error)
        } finally {
          this.activeCount--
        }
      }
    }
    
    this.running = false
    
    // 如果还有请求且有空闲槽位,继续处理
    if (this.queue.length > 0 && this.activeCount < this.maxConcurrent) {
      this.processQueue()
    }
  }
  
  cancel(id: string) {
    const index = this.queue.findIndex(req => req.id === id)
    if (index !== -1) {
      const [request] = this.queue.splice(index, 1)
      request.reject(new Error('请求被取消'))
    }
  }
  
  clear() {
    this.queue.forEach(request => {
      request.reject(new Error('队列已清空'))
    })
    this.queue = []
  }
}

export const requestQueue = new RequestQueue()

// 使用示例
const loadUserData = async (userId: number) => {
  return requestQueue.add(
    `user-${userId}`,
    () => userApi.getUser(userId),
    1 // 高优先级
  )
}

5.4 错误重试机制

对于可能因网络波动失败的请求,实现自动重试:

// src/utils/retry.ts
export async function withRetry<T>(
  fn: () => Promise<T>,
  options: {
    maxRetries?: number
    retryDelay?: number
    shouldRetry?: (error: Error) => boolean
  } = {}
): Promise<T> {
  const {
    maxRetries = 3,
    retryDelay = 1000,
    shouldRetry = () => true
  } = options
  
  let lastError: Error
  let retries = 0
  
  while (retries <= maxRetries) {
    try {
      return await fn()
    } catch (error: any) {
      lastError = error
      
      // 检查是否应该重试
      if (retries === maxRetries || !shouldRetry(error)) {
        break
      }
      
      retries++
      
      // 指数退避
      const delay = retryDelay * Math.pow(2, retries - 1)
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }
  
  throw lastError
}

// 使用示例
const fetchWithRetry = async () => {
  return withRetry(
    () => userApi.getUsers({ page: 1, pageSize: 10 }),
    {
      maxRetries: 3,
      shouldRetry: (error) => {
        // 只对网络错误重试
        return error.message.includes('network') || 
               error.message.includes('timeout')
      }
    }
  )
}

6. 测试策略与调试技巧

良好的测试是保证异步代码质量的关键。下面分享一些我在项目中使用的测试方法:

6.1 单元测试示例

// tests/unit/useAsyncData.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/vue'
import { useAsyncData } from '@/composables/useAsyncData'

describe('useAsyncData', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })
  
  it('应该正确处理成功请求', async () => {
    const mockHandler = vi.fn().mockResolvedValue('test data')
    
    const { result } = renderHook(() => 
      useAsyncData({
        handler: mockHandler,
        immediate: true
      })
    )
    
    // 初始状态
    expect(result.current.loading.value).toBe(true)
    expect(result.current.data.value).toBeNull()
    expect(result.current.error.value).toBeNull()
    
    // 等待请求完成
    await waitFor(() => {
      expect(result.current.loading.value).toBe(false)
    })
    
    // 验证结果
    expect(result.current.data.value).toBe('test data')
    expect(result.current.error.value).toBeNull()
    expect(mockHandler).toHaveBeenCalledTimes(1)
  })
  
  it('应该正确处理请求失败', async () => {
    const mockError = new Error('请求失败')
    const mockHandler = vi.fn().mockRejectedValue(mockError)
    
    const { result } = renderHook(() =>
      useAsyncData({
        handler: mockHandler,
        immediate: true
      })
    )
    
    await waitFor(() => {
      expect(result.current.loading.value).toBe(false)
    })
    
    expect(result.current.error.value).toBe('请求失败')
    expect(result.current.data.value).toBeNull()
  })
  
  it('应该支持手动执行', async () => {
    const mockHandler = vi.fn()
      .mockResolvedValueOnce('第一次')
      .mockResolvedValueOnce('第二次')
    
    const { result } = renderHook(() =>
      useAsyncData({
        handler: mockHandler,
        immediate: false
      })
    )
    
    // 初始状态
    expect(result.current.loading.value).toBe(false)
    expect(mockHandler).not.toHaveBeenCalled()
    
    // 手动执行
    await result.current.execute()
    
    expect(result.current.data.value).toBe('第一次')
    expect(mockHandler).toHaveBeenCalledTimes(1)
    
    // 再次执行
    await result.current.execute()
    
    expect(result.current.data.value).toBe('第二次')
    expect(mockHandler).toHaveBeenCalledTimes(2)
  })
})

6.2 组件测试示例

// tests/unit/UserList.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import UserList from '@/components/UserList.vue'
import { userApi } from '@/api/user'

// Mock API
vi.mock('@/api/user', () => ({
  userApi: {
    getUsers: vi.fn()
  }
}))

describe('UserList', () => {
  const mockUsers = [
    { id: 1, username: 'user1', email: 'user1@test.com', role: 'user' },
    { id: 2, username: 'user2', email: 'user2@test.com', role: 'admin' }
  ]
  
  beforeEach(() => {
    vi.clearAllMocks()
  })
  
  it('应该正确渲染用户列表', async () => {
    // Mock API响应
    vi.mocked(userApi.getUsers).mockResolvedValue({
      items: mockUsers,
      total: 2,
      page: 1,
      pageSize: 10,
      totalPages: 1
    })
    
    const wrapper = mount(UserList, {
      global: {
        plugins: [createTestingPinia()]
      }
    })
    
    // 应该显示加载状态
    expect(wrapper.find('.loading-container').exists()).toBe(true)
    
    // 等待数据加载
    await vi.waitFor(() => {
      expect(userApi.getUsers).toHaveBeenCalled()
    })
    
    // 应该显示用户数据
    expect(wrapper.findAll('tbody tr')).toHaveLength(2)
    expect(wrapper.text()).toContain('user1')
    expect(wrapper.text()).toContain('user2')
  })
  
  it('应该处理搜索功能', async () => {
    vi.mocked(userApi.getUsers).mockResolvedValue({
      items: [mockUsers[0]],
      total: 1,
      page: 1,
      pageSize: 10,
      totalPages: 1
    })
    
    const wrapper = mount(UserList, {
      global: {
        plugins: [createTestingPinia()]
      }
    })
    
    // 等待初始加载
    await vi.waitFor(() => {
      expect(userApi.getUsers).toHaveBeenCalled()
    })
    
    // 清空mock调用记录
    vi.mocked(userApi.getUsers).mockClear()
    
    // 输入搜索关键词
    const searchInput = wrapper.find('input[placeholder*="搜索"]')
    await searchInput.setValue('user1')
    
    // 等待防抖
    await new Promise(resolve => setTimeout(resolve, 600))
    
    // 应该使用关键词调用API
    expect(userApi.getUsers).toHaveBeenCalledWith(
      expect.objectContaining({ keyword: 'user1' })
    )
  })
})

6.3 调试技巧

在实际开发中,我经常使用这些调试技巧:

技巧一:使用Vue DevTools观察响应式数据

// 在组件中添加调试代码
const debugData = () => {
  console.log('当前状态:', {
    users: users.value,
    loading: loading.value,
    error: error.value,
    searchKeyword: searchKeyword.value
  })
}

// 在需要时调用
watch([users, loading, error], debugData, { deep: true })

技巧二:网络请求拦截与Mock

// 开发环境下的请求拦截
if (import.meta.env.DEV) {
  // Mock用户数据
  const mockUsers = [
    { id: 1, username: 'test1', email: 'test1@example.com', role: 'user' },
    { id: 2, username: 'test2', email: 'test2@example.com', role: 'admin' }
  ]
  
  // 拦截特定请求
  request.interceptors.request.use(config => {
    if (config.url?.includes('/users') && config.method === 'get') {
      console.log('📡 拦截请求:', config.url)
      
      // 返回mock数据
      return {
        ...config,
        adapter: () => Promise.resolve({
          data: {
            code: 0,
            message: 'success',
            data: {
              items: mockUsers,
              total: mockUsers.length,
              page: 1,
              pageSize: 10,
              totalPages: 1
            }
          },
          status: 200,
          statusText: 'OK',
          headers: {},
          config
        })
      }
    }
    
    return config
  })
}

技巧三:性能监控

// 请求性能监控
const monitorRequest = async <T>(
  name: string,
  requestFn: () => Promise<T>
): Promise<T> => {
  const startTime = performance.now()
  
  try {
    const result = await requestFn()
    const endTime = performance.now()
    
    console.log(`⏱️ ${name} 耗时: ${(endTime - startTime).toFixed(2)}ms`)
    
    return result
  } catch (error) {
    const endTime = performance.now()
    console.error(`❌ ${name} 失败,耗时: ${(endTime - startTime).toFixed(2)}ms`, error)
    throw error
  }
}

// 使用示例
const fetchUsersWithMonitor = async () => {
  return monitorRequest('获取用户列表', () => 
    userApi.getUsers({ page: 1, pageSize: 10 })
  )
}

7. 实际项目中的经验总结

经过多个Vue3项目的实践,我总结了一些在处理异步请求时的经验教训:

经验一:尽早定义类型

不要等到项目后期才加TypeScript类型。从一开始就为每个接口定义完整的类型,这会让你在开发过程中节省大量调试时间。

// 不好的做法:使用any
const getUser = async (id: any): Promise<any> => {
  return http.get(`/users/${id}`)
}

// 好的做法:明确定义类型
interface User {
  id: number
  username: string
  email: string
  role: 'admin' | 'user'
}

const getUser = async (id: number): Promise<User> => {
  return http.get(`/users/${id}`)
}

经验二:统一错误处理策略

在项目开始时就确定错误处理的策略。是使用全局拦截器?还是在每个组件中单独处理?我推荐的做法是:

  1. 网络错误和HTTP错误:在axios拦截器中统一处理
  2. 业务逻辑错误:在调用处根据具体业务需求处理
  3. 表单验证错误:在表单组件中处理,提供详细的字段级错误信息

经验三:合理使用加载状态

不要过度使用加载状态。对于快速完成的请求(<200ms),显示加载状态反而会让用户感到界面闪烁。可以考虑:

  • 为请求设置最小加载时间(如300ms)
  • 使用骨架屏而不是简单的loading图标
  • 对于局部更新,考虑使用乐观更新(optimistic update)
// 乐观更新示例
const updateUserName = async (userId: number, newName: string) => {
  // 先更新本地数据
  const oldName = users.value.find(u => u.id === userId)?.username
  users.value = users.value.map(user =>
    user.id === userId ? { ...user, username: newName } : user
  )
  
  try {
    // 发送请求
    await userApi.updateUser({ id: userId, username: newName })
  } catch (error) {
    // 失败时回滚
    users.value = users.value.map(user =>
      user.id === userId ? { ...user, username: oldName } : user
    )
    throw error
  }
}

经验四:注意内存泄漏

Vue3的响应式系统很强大,但也更容易造成内存泄漏。特别是在使用setIntervaladdEventListener或第三方库时,一定要在onUnmounted中清理。

// 正确的清理
onMounted(() => {
  const timer = setInterval(() => {
    // 定期更新数据
  }, 5000)
  
  onUnmounted(() => {
    clearInterval(timer)
  })
})

经验五:保持代码的可测试性

写代码时要考虑如何测试。避免在组件中直接调用全局API,而是通过props或provide/inject传递依赖。

// 不好的做法:直接导入使用
import { userApi } from '@/api/user'

// 好的做法:通过props或provide传递
const props = defineProps<{
  fetchUsers: () => Promise<User[]>
}>()

// 或者使用依赖注入
const userApi = inject('userApi')

异步请求是前端开发中最常见也最容易出问题的部分。在Vue3中,由于响应式系统的变化和Composition API的引入,我们需要重新思考如何组织异步代码。通过合理的分层设计、完善的错误处理、良好的类型支持和充分的测试,我们可以构建出既健壮又易维护的异步请求系统。

我在实际项目中最深的体会是:没有银弹。不同的项目规模、不同的团队习惯、不同的业务需求,都需要不同的解决方案。本文分享的这套方案是一个起点,你可以根据自己的实际情况进行调整和扩展。最重要的是保持代码的一致性和可维护性,这样无论项目如何发展,你都能从容应对。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值