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的异步请求,可能会遇到这些问题:
- 响应式数据更新不够直观:
reactive和ref的使用需要更精确的控制 - 类型支持薄弱:没有良好的TypeScript支持,接口返回的数据类型无法得到保障
- 错误处理分散:每个请求都要单独写
try-catch,代码重复严重 - 加载状态管理混乱:每个组件都要自己维护
loading状态,状态逻辑分散 - 代码复用性差:相似的请求逻辑在不同组件中重复编写
更关键的是,Vue3的响应式系统底层已经重写,对异步更新的处理也更加精细。如果我们还沿用旧模式,不仅无法发挥Vue3的全部优势,还可能因为对响应式系统理解不深而写出有性能问题的代码。
提示:Vue3的
ref和reactive在异步更新时的行为略有不同。ref更适合在模板中直接使用,而reactive更适合复杂对象的响应式转换。理解这两者的区别对编写高效的异步代码很重要。
1.1 现代前端异步请求的发展趋势
看看现在的前端生态,你会发现异步请求的处理方式正在发生明显变化:
- 从分散到集中:越来越多的项目开始采用统一的请求层管理,而不是在每个组件中零散地调用接口
- 从命令式到声明式:像React Query、SWR这样的库让数据获取变得更加声明式
- 类型安全成为标配:TypeScript的普及让接口类型定义变得必不可少
- 状态管理集成:异步请求的状态(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()
这里有几个关键点需要注意:
- 环境区分:开发环境和生产环境使用不同的baseURL
- 超时设置:15秒的超时时间对于大多数接口来说比较合适
- withCredentials:如果需要携带cookie,这个配置必须为true
- 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)
}
)
这个拦截器做了三件事:
- 自动添加认证token:从本地存储获取token并添加到请求头
- GET请求防缓存:为所有GET请求添加时间戳参数
- 开发环境日志:方便调试,生产环境不会输出
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 })
}
}
这种组织方式有几个好处:
- 类型集中管理:所有与用户相关的类型都在一个文件中定义
- API函数化:每个接口都是一个纯函数,易于测试和复用
- 参数类型安全:调用时会有完整的类型提示
- 易于扩展:新增接口只需要在对应模块中添加函数
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()
}
这种封装的好处显而易见:
- 逻辑复用:任何需要异步数据的组件都可以使用这个hook
- 自动取消请求:组件卸载时自动取消未完成的请求,避免内存泄漏
- 统一的加载和错误状态:不需要在每个组件中重复定义
- 更好的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}`)
}
经验二:统一错误处理策略
在项目开始时就确定错误处理的策略。是使用全局拦截器?还是在每个组件中单独处理?我推荐的做法是:
- 网络错误和HTTP错误:在axios拦截器中统一处理
- 业务逻辑错误:在调用处根据具体业务需求处理
- 表单验证错误:在表单组件中处理,提供详细的字段级错误信息
经验三:合理使用加载状态
不要过度使用加载状态。对于快速完成的请求(<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的响应式系统很强大,但也更容易造成内存泄漏。特别是在使用setInterval、addEventListener或第三方库时,一定要在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的引入,我们需要重新思考如何组织异步代码。通过合理的分层设计、完善的错误处理、良好的类型支持和充分的测试,我们可以构建出既健壮又易维护的异步请求系统。
我在实际项目中最深的体会是:没有银弹。不同的项目规模、不同的团队习惯、不同的业务需求,都需要不同的解决方案。本文分享的这套方案是一个起点,你可以根据自己的实际情况进行调整和扩展。最重要的是保持代码的一致性和可维护性,这样无论项目如何发展,你都能从容应对。
&spm=1001.2101.3001.5002&articleId=153761578&d=1&t=3&u=eba09c36300a45e78cd102bf1cc52d45)
2856

被折叠的 条评论
为什么被折叠?



