API 请求封装与治理
本文讨论浏览器端的超时、取消、竞态、重试、缓存和身份续期。前端可以改善交互并携带协议所需的标识,但不能替代服务端的鉴权、幂等与数据约束。
一、为什么需要 API 请求治理
| 问题 | 后果 | 对标后端概念 |
|---|---|---|
| 请求重复发出 | 可能重复创建或覆盖数据 | 接口幂等性 |
| Token 过期 | 多个请求同时失败或反复刷新 | 身份续期 |
| 请求失败 | 用户不知道能否安全重试 | 失败语义与重试策略 |
| 竞态请求 | 先发的请求后返回,覆盖最新数据 | 并发控制 |
| 相同请求并发 | 同一资源重复加载,浪费带宽 | 请求合并 |
| 组件卸载后拿到结果 | setState on unmounted component 报错 | 生命周期管理 |
| 无缓存策略 | 相同数据反复请求,浪费性能 | 旁路缓存 |
核心思想:先统一错误模型和请求生命周期,再按真实需求加入重试、缓存等能力。功能越多,越需要明确哪些请求适用,避免网络层自动行为改变业务语义。
二、请求库选择与基础封装
2.1 选型对比
| 特性 | Axios | Fetch (原生) | 说明 |
|---|---|---|---|
| 请求拦截器 | 内置 axios.interceptors.request |
无,需手动包装 | Token 注入等 |
| 响应拦截器 | 内置 axios.interceptors.response |
无,需手动包装 | 统一错误处理 |
| 请求取消 | AbortController |
AbortController |
Axios 的 CancelToken 已弃用 |
| 超时设置 | timeout 配置 |
AbortSignal.timeout() |
原生较新才支持 |
| 上传进度 | onUploadProgress |
XMLHttpRequest 需封装 |
Axios 更友好 |
| 拦截器链 | 成熟,可动态增减 | 需自行封装 | Axios 更灵活 |
| Bundle 大小 | 增加依赖体积 | 无额外依赖 | 以项目实际构建结果为准 |
| TypeScript | 类型定义完善 | Response 需手动断言 | 都可用 |
已经使用 Axios、需要拦截器生态时可继续使用 Axios;依赖敏感或运行时原生支持完善时可优先使用 fetch。两者都需要自行定义业务错误、超时和重试规则,不存在对所有生产项目通用的选择。
2.2 请求配置类型定义
// ==== utils/http/type.ts ====
/** 后端统一响应结构 */
export interface ApiResponse<T = unknown> {
code: number
message: string
data: T
/** 请求追踪 ID,方便联调 */
traceId?: string
}
/** 分页响应结构 */
export interface PaginatedData<T> {
list: T[]
total: number
page: number
pageSize: number
}
/** 扩展请求配置 */
export interface RequestConfig {
/** 是否显示全局 loading */
showLoading?: boolean
/** 是否自动处理错误(弹 toast) */
autoError?: boolean
/** 是否跳过 Token 注入 */
skipAuth?: boolean
/** 重试次数 */
retryCount?: number
/** 缓存 TTL(毫秒),设置后才缓存 */
cacheTTL?: number
/** 幂等键(防重复提交) */
idempotentKey?: string
/** 自定义 Base URL */
baseURL?: string
}
// 让自定义选项作为 Axios 配置字段传递,不要塞进 HTTP headers。
declare module 'axios' {
interface AxiosRequestConfig {
requestOptions?: RequestConfig
__retriedAfterRefresh?: boolean
__loadingShown?: boolean
}
interface InternalAxiosRequestConfig {
requestOptions?: RequestConfig
__retriedAfterRefresh?: boolean
__loadingShown?: boolean
}
}
2.3 Base URL 环境变量管理
// ==== utils/http/config.ts ====
/** 环境判断 */
const isDev = import.meta.env.DEV
const isProd = import.meta.env.PROD
/** 从环境变量读取 API 地址,带兜底 */
export const getBaseURL = (): string => {
// Vite: VITE_ prefix, 其他构建工具类似
const envURL = import.meta.env.VITE_API_BASE_URL as string | undefined
if (envURL) return envURL
// 按环境兜底
if (isDev) return 'http://localhost:8080/api'
if (isProd) return 'https://api.yourdomain.com/api'
return 'https://api.yourdomain.com/api'
}
/** 请求超时时间(毫秒) */
export const DEFAULT_TIMEOUT = 15_000
/** 上传超时(毫秒) */
export const UPLOAD_TIMEOUT = 60_000
三、Axios 拦截器封装
3.1 请求拦截器
// ==== utils/http/requestInterceptors.ts ====
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import { getBaseURL, DEFAULT_TIMEOUT } from './config'
import { useAuthStore } from '@/stores/auth'
import { useGlobalLoading } from '@/composables/useGlobalLoading'
export function setupRequestInterceptors(instance: AxiosInstance) {
// ---------- 1. 基础配置 ----------
instance.defaults.baseURL = getBaseURL()
instance.defaults.timeout = DEFAULT_TIMEOUT
instance.defaults.headers.common['Content-Type'] = 'application/json'
// ---------- 2. 请求拦截器 ----------
instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 2.1 Token 注入
const authStore = useAuthStore()
const token = authStore.token
// 永远不要信任存不存在,主动判断
if (token && !config.skipAuth) {
config.headers.Authorization = `Bearer ${token}`
}
// 2.2 幂等键注入(后文 3.6 节详述)
const customConfig = config.requestOptions
if (customConfig?.idempotentKey) {
config.headers['Idempotency-Key'] = customConfig.idempotentKey
}
// 2.3 全局 Loading 控制
if (customConfig?.showLoading) {
const { startLoading } = useGlobalLoading()
startLoading()
// 在 config 上标记,响应拦截器中关闭
;(config as any).__loadingShown = true
}
// 2.4 请求追踪 ID
config.headers['X-Request-Id'] = crypto.randomUUID()
return config
},
(error) => Promise.reject(error)
)
}
请求拦截器流程
请求发起
│
├── 注入 Authorization Token ──→ 有 Token 且非 skipAuth
│
├── 注入幂等键 ──→ Header: Idempotency-Key(名称以接口契约为准)
│
├── 全局 Loading ──→ showLoading=true 则显示 loading 遮罩
│
├── 请求追踪 ID ──→ Header: X-Request-Id
│
└── 发送请求
3.2 响应拦截器
// ==== utils/http/responseInterceptors.ts ====
import axios, { type AxiosInstance, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus' // Vue 示例
// import { message } from 'antd' // React 示例
import router from '@/router'
import { useAuthStore } from '@/stores/auth'
import { useGlobalLoading } from '@/composables/useGlobalLoading'
import { getRefreshedToken } from './tokenRefresh'
/** 业务状态码约定 */
enum ApiCode {
Success = 0,
Unauthorized = 401,
Forbidden = 403,
TokenExpired = 4001,
}
export function setupResponseInterceptors(instance: AxiosInstance) {
instance.interceptors.response.use(
// ============ 成功拦截器 ============
(response: AxiosResponse<ApiResponse>) => {
// 关闭 Loading
if ((response.config as any).__loadingShown) {
const { stopLoading } = useGlobalLoading()
stopLoading()
}
const { data: raw } = response
const apiData = raw as ApiResponse
// 此项目选择在拦截器中检查统一业务外层结构。
if (apiData.code !== ApiCode.Success) {
// 业务错误统一处理
return handleBusinessError(response, apiData)
}
// 解包:返回真正的 data,调用方不用关心外层结构
return apiData.data as any
},
// ============ 异常拦截器 ============
async (error) => {
// 关闭 Loading
if (error.config?.__loadingShown) {
const { stopLoading } = useGlobalLoading()
stopLoading()
}
// 请求被取消,直接忽略
if (axios.isCancel(error)) {
return Promise.reject(new Error('CANCELLED'))
}
const status = error.response?.status
// 401 / Token 过期 → 自动刷新或跳登录
if (status === 401) {
if (!error.config?.__retriedAfterRefresh) {
const token = await getRefreshedToken()
if (token) {
// 刷新成功,原请求只重放一次
const config = error.config
config.__retriedAfterRefresh = true
config.headers.Authorization = `Bearer ${token}`
return instance.request(config)
}
}
// 无法刷新或重放后仍为 401 → 回到登录态
useAuthStore().logout()
router.push('/login')
return Promise.reject(error)
}
// 403 → 无权限
if (status === 403) {
ElMessage.error('没有操作权限')
return Promise.reject(error)
}
// 网络异常(无响应)
if (!error.response) {
ElMessage.error('网络连接异常,请检查网络')
return Promise.reject(error)
}
// 其他错误
ElMessage.error(error.message || '服务器异常')
return Promise.reject(error)
}
)
}
/** 业务错误处理 */
function handleBusinessError(
response: AxiosResponse,
apiData: ApiResponse
): Promise<never> {
// 业务特定的错误处理
switch (apiData.code) {
case ApiCode.TokenExpired:
// 触发 Token 刷新
break
default:
// 默认弹出错误信息
ElMessage.error(apiData.message || '请求失败')
break
}
return Promise.reject(new Error(apiData.message))
}
响应拦截器流程
HTTP 响应
│
├── HTTP 异常 ──→ status 401? ──→ 尝试刷新 Token
│ ├── 成功 → 重放请求
│ └── 失败 → 跳登录页
│
│ ──→ status 403? ──→ 弹无权限提示
│ ──→ 网络异常? ──→ 弹网络异常提示
│ ──→ 其他异常? ──→ 弹通用错误提示
│
└── HTTP 成功
│
├── code !== 0 ──→ 业务错误处理 → 弹错误提示 → reject
│
└── code === 0 ──→ data 解包 → 返回真实数据
3.3 Token 刷新机制
// ==== utils/http/tokenRefresh.ts ====
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
/** 所有并发 401 共享同一个刷新 Promise。 */
let refreshPromise: Promise<string | null> | null = null
/**
* 尝试刷新 Token
* 核心设计:同一时刻只允许一个刷新请求,其他请求排队等待
*/
export function getRefreshedToken(): Promise<string | null> {
const authStore = useAuthStore()
const refreshToken = authStore.refreshToken
if (!refreshToken) return Promise.resolve(null)
if (refreshPromise) return refreshPromise
// 使用不带业务响应拦截器的实例调用刷新接口,避免刷新请求自身递归。
refreshPromise = axios
.post('/api/auth/refresh', { refreshToken })
.then((res) => {
const { token, refreshToken: newRefreshToken } = res.data
authStore.setToken(token)
if (newRefreshToken) authStore.setRefreshToken(newRefreshToken)
return token as string
})
.catch(() => null)
.finally(() => {
refreshPromise = null
})
return refreshPromise
}
同一个请求最多重放一次,刷新请求使用独立实例。是否自动刷新由身份协议决定;基于 Cookie 的会话、OAuth/OIDC 客户端和自定义 Token 方案不能直接套用同一段代码。
3.4 封装实例
// ==== utils/http/index.ts ====
import axios from 'axios'
import { setupRequestInterceptors } from './requestInterceptors'
import { setupResponseInterceptors } from './responseInterceptors'
import type { RequestConfig, ApiResponse } from './type'
/** 创建 Axios 实例 */
const instance = axios.create()
/** 安装拦截器 */
setupRequestInterceptors(instance)
setupResponseInterceptors(instance)
/**
* 通用请求函数
* 对比 Java 的 RestTemplate / OpenFeign,等效于前端唯一的 HTTP 入口
*/
async function request<T = unknown>(
config: {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
data?: unknown
params?: Record<string, unknown>
} & RequestConfig
): Promise<T> {
const {
url,
method = 'GET',
data,
params,
baseURL,
...requestOptions
} = config
const response = await instance.request({
url,
method,
data,
params,
baseURL,
requestOptions,
})
return response as unknown as T
}
// 导出便捷方法
export const http = {
get<T = unknown>(url: string, config?: RequestConfig & { params?: Record<string, unknown> }) {
return request<T>({ ...config, url, method: 'GET' })
},
post<T = unknown>(url: string, data?: unknown, config?: RequestConfig) {
return request<T>({ ...config, url, method: 'POST', data })
},
put<T = unknown>(url: string, data?: unknown, config?: RequestConfig) {
return request<T>({ ...config, url, method: 'PUT', data })
},
delete<T = unknown>(url: string, config?: RequestConfig) {
return request<T>({ ...config, url, method: 'DELETE' })
},
}
export { instance as axiosInstance }
export type { ApiResponse, RequestConfig }
3.5 使用示例
// ==== api/user.ts ====
import { http } from '@/utils/http'
import type { ApiResponse } from '@/utils/http'
// ======== 类型定义 ========
interface UserInfo {
id: number
name: string
email: string
avatar: string
}
// ======== API 封装 ========
export const userApi = {
/** 获取用户信息 */
getUserInfo(userId: number) {
return http.get<UserInfo>('/user/info', {
params: { userId },
// 开启缓存 30s
cacheTTL: 30_000,
})
},
/** 更新用户信息 */
updateUserInfo(data: Partial<UserInfo>, idempotencyKey: string) {
return http.put<UserInfo>('/user/info', data, {
// 显示全局 loading
showLoading: true,
// 同一次用户操作的首次请求与后续重试复用同一个键
idempotentKey: idempotencyKey,
})
},
/** 批量获取用户信息 */
batchGetUsers(userIds: number[]) {
return http.post<UserInfo[]>('/user/batch', { userIds })
},
}
四、请求防抖与节流
4.1 防抖(Debounce)vs 节流(Throttle)
| 维度 | 防抖 Debounce | 节流 Throttle |
|---|---|---|
| 行为 | 停止触发后 N 毫秒执行一次 | 每 N 毫秒最多执行一次 |
| 类比 | 电梯关门 — 等没人进来了再关 | 地铁发车 — 每 5 分钟一班 |
| 典型场景 | 搜索框输入、窗口 resize | 滚动事件、点击提交 |
| 是否忽略中间结果 | 是 | 否 |
4.2 搜索框防抖请求
// ==== Vue 3 示例:搜索框防抖 ====
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { ElInput, ElTable, ElTableColumn } from 'element-plus'
import { userApi } from '@/api/user'
const searchText = ref('')
const searchResults = ref<UserInfo[]>([])
const isSearching = ref(false)
// 方式一:利用 watch + 防抖
// ❌ 错误:watch 直接发请求,每次输入都触发
watch(searchText, async (val) => {
// 不防抖的情况下,快速输入 "hello" 会触发 5 次请求
searchResults.value = await userApi.search(val)
})
// ✅ 正确:使用防抖,仅用户在 300ms 内没有输入时才请求
import { useDebounceFn } from '@vueuse/core'
const debouncedSearch = useDebounceFn(async (keyword: string) => {
if (!keyword.trim()) {
searchResults.value = []
return
}
isSearching.value = true
try {
searchResults.value = await userApi.search(keyword)
} finally {
isSearching.value = false
}
}, 300)
watch(searchText, (val) => {
debouncedSearch(val)
})
</script>
// ==== React 示例:搜索框防抖 ====
import { useState, useEffect } from 'react'
import { Input, Table, Spin } from 'antd'
import { userApi } from '@/api/user'
import { useDebounce } from 'ahooks'
function UserSearch() {
const [searchText, setSearchText] = useState('')
const [results, setResults] = useState<UserInfo[]>([])
const [loading, setLoading] = useState(false)
// ✅ 使用 ahooks 的防抖 Hook
const debouncedValue = useDebounce(searchText, { wait: 300 })
useEffect(() => {
if (!debouncedValue.trim()) {
setResults([])
return
}
let cancelled = false
setLoading(true)
userApi.search(debouncedValue).then((data) => {
// 防御:组件可能已卸载或搜索词已变
if (!cancelled) {
setResults(data)
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [debouncedValue])
return (
<div>
<Input.Search
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="搜索用户..."
/>
<Spin spinning={loading}>
<Table dataSource={results} rowKey="id" />
</Spin>
</div>
)
}
4.3 提交按钮防重复点击
// ==== Vue 示例:提交按钮防重 ====
<script setup lang="ts">
import { ref } from 'vue'
import { ElButton } from 'element-plus'
const submitting = ref(false)
async function handleSubmit() {
// ✅ 利用 loading 状态天然防重
if (submitting.value) return
submitting.value = true
try {
await submitOrder()
ElMessage.success('提交成功')
} catch (error) {
ElMessage.error('提交失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<ElButton
type="primary"
:loading="submitting"
@click="handleSubmit"
>
{{ submitting ? '提交中...' : '提交订单' }}
</ElButton>
</template>
// ==== React 示例:提交按钮防重 ====
import { useState } from 'react'
import { Button, message } from 'antd'
import { useRequest } from 'ahooks'
function SubmitOrder() {
const { run, loading } = useRequest(submitOrder, {
manual: true,
// ✅ loading 状态自动防重,无需额外判断
onSuccess: () => message.success('提交成功'),
onError: () => message.error('提交失败'),
})
return (
<Button type="primary" loading={loading} onClick={run}>
{loading ? '提交中...' : '提交订单'}
</Button>
)
}
对比 Java 防重复提交:
维度 前端 后端 防重手段 Loading 状态 + 按钮禁用 Token 机制 + 数据库约束 可靠性 中间(可绕过) 强(安全底线) 定位 用户体验优化 数据安全保护 原则:前端防重是体验兜底,后端幂等是安全底线。两者缺一不可。
五、请求去重
5.1 什么是请求去重
当多个组件/多个位置在短时间内发起完全相同的请求(同 URL、同参数、同方法),只发送一次真正的 HTTP 请求,其余复用同一个 Promise。
典型场景:
- 多个组件同时渲染,各自调用
getUserInfo() - 列表页和详情页同时请求同一条数据
- Tabs 切换时,内部组件重复请求同一接口
5.2 实现 requestDeduplicator
// ==== utils/http/deduplicator.ts ====
/** 请求去重器:相同请求只发一次,其余复用 Promise */
class RequestDeduplicator {
/** 存储进行中的请求 */
private pendingMap = new Map<string, Promise<unknown>>()
/**
* 生成请求唯一 Key
* 将 method + url + params + data 作为请求标识
*/
private generateKey(config: {
method?: string
url: string
params?: unknown
data?: unknown
}): string {
const { method = 'GET', url, params, data } = config
return [
method.toUpperCase(),
url,
JSON.stringify(params || {}),
JSON.stringify(data || {}),
].join('::')
}
/**
* 执行请求(带去重)
* @returns 已有的 Promise(如果并发中存在相同请求);否则执行并缓存
*/
async execute<T>(
config: {
method?: string
url: string
params?: unknown
data?: unknown
},
requestFn: () => Promise<T>
): Promise<T> {
const key = this.generateKey(config)
// 如果已有相同请求进行中,直接返回已有 Promise
const existing = this.pendingMap.get(key)
if (existing) {
return existing as Promise<T>
}
// 新请求,缓存 Promise
const promise = requestFn().finally(() => {
// 请求完成后从 Map 移除
this.pendingMap.delete(key)
})
this.pendingMap.set(key, promise)
return promise
}
/** 清理所有去重缓存(重置时调用) */
clear(): void {
this.pendingMap.clear()
}
}
export const requestDeduplicator = new RequestDeduplicator()
5.3 集成到请求封装中
// 在请求函数中集成去重
async function request<T = unknown>(
config: {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
data?: unknown
params?: Record<string, unknown>
} & RequestConfig
): Promise<T> {
// GET 请求使用去重(读请求天然适合去重)
if ((config.method || 'GET').toUpperCase() === 'GET') {
return requestDeduplicator.execute(
{ method: config.method, url: config.url, params: config.params, data: config.data },
() => instance.request(/* ... */) as Promise<T>
)
}
// 写操作不用去重
return instance.request(/* ... */) as Promise<T>
}
5.4 去重流程
组件 A 请求 getUserInfo(1) ──┐
├── 检查 pendingMap
组件 B 请求 getUserInfo(1) ──┘
│
├── 未命中 → 发起真实请求 → 存入 pendingMap
│
└── 命中 → 复用已有 Promise
│
┌──────┴──────┐
│ │
组件 A 获得 组件 B 获得
相同结果 相同结果
注意:去重只对并发存在的相同请求有效。如果请求 A 已完成而请求 B 才发起,不会复用(需要缓存机制来满足)。
六、请求取消
6.1 为什么需要请求取消
| 场景 | 问题 | 后果 |
|---|---|---|
| 组件卸载后请求才返回 | React: setState on unmounted component |
内存泄漏 + 控制台报错 |
| 用户快速切换 Tab | 前 Tab 的请求还在进行中 | 数据错乱(竞态) |
| 搜索框输入过快 | 前一次搜索结果覆盖后一次 | 展示错误结果 |
| 页面跳转后 | 上一页的请求占用带宽 | 浪费用户流量 |
6.2 使用 AbortController
// ==== utils/http/abortManager.ts ====
/**
* 请求取消管理器
* 管理所有待取消的请求,支持按 Key 取消或批量取消
*/
class AbortManager {
/** controller 集合 */
private controllers = new Map<string, AbortController>()
/** 生成信号 */
createSignal(key: string): AbortSignal {
// 如果已有相同 key 的请求,先取消旧的
const existing = this.controllers.get(key)
if (existing) {
existing.abort()
}
const controller = new AbortController()
this.controllers.set(key, controller)
return controller.signal
}
/** 取消特定请求 */
abort(key: string): void {
const controller = this.controllers.get(key)
if (controller) {
controller.abort()
this.controllers.delete(key)
}
}
/** 取消所有请求 */
abortAll(): void {
this.controllers.forEach((controller) => controller.abort())
this.controllers.clear()
}
/** 释放 controller(请求完成后调用) */
release(key: string): void {
this.controllers.delete(key)
}
}
export const abortManager = new AbortManager()
6.3 Vue 组件卸载时自动取消
// ==== Vue composable: useRequestWithCancel ====
import { onUnmounted } from 'vue'
import { abortManager } from '@/utils/http/abortManager'
export function useAutoCancel(scope: string) {
onUnmounted(() => {
// 组件卸载时自动取消该作用域下所有请求
abortManager.abortAll()
// 或更精细的粒度:
// abortManager.abort(`user:${props.userId}`)
})
}
// ==== Vue 组件中使用 ====
<script setup lang="ts">
import { http } from '@/utils/http'
import { abortManager } from '@/utils/http/abortManager'
import { useAutoCancel } from '@/composables/useRequestWithCancel'
const props = defineProps<{ userId: number }>()
// 组件卸载时自动取消 user 命名空间下的请求
useAutoCancel('user')
async function fetchUser() {
const signal = abortManager.createSignal(`user:${props.userId}`)
try {
const result = await http.get(`/user/${props.userId}`, {
signal: signal as any,
})
// 正常处理
} catch (error: any) {
if (error?.message === 'CANCELLED') {
// 请求被取消,不做任何处理
return
}
// 其他错误处理
}
}
</script>
6.4 React useEffect 清理函数中取消
// ==== React 组件中使用 ====
import { useEffect, useState } from 'react'
import { http } from '@/utils/http'
import { abortManager } from '@/utils/http/abortManager'
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<UserInfo | null>(null)
useEffect(() => {
const signal = abortManager.createSignal(`user:${userId}`)
let cancelled = false
http.get<UserInfo>(`/user/${userId}`, {
signal: signal as any,
})
.then((data) => {
// 双重保险:同时检查 cancelled 标记和 AbortController
if (!cancelled) {
setUser(data)
}
})
.catch((error: any) => {
if (error?.message === 'CANCELLED') return
// 其他错误处理
})
return () => {
// 清理函数中取消请求
cancelled = true
abortManager.abort(`user:${userId}`)
}
}, [userId])
return <div>{/* 渲染 user */}</div>
}
6.5 可取消的 useRequest(React)
// ==== React: useRequest with cancel ====
import { useEffect, useRef } from 'react'
import { useRequest } from 'ahooks'
// 对 ahooks useRequest 的二次封装,默认支持取消
function useCancellableRequest<T>(
service: (...args: unknown[]) => Promise<T>,
options?: Parameters<typeof useRequest>[1]
) {
const abortRef = useRef<AbortController | null>(null)
const { run, cancel, ...rest } = useRequest(
async (...args: unknown[]) => {
// 每次执行前取消上一次请求
if (abortRef.current) {
abortRef.current.abort()
}
abortRef.current = new AbortController()
// 将 signal 注入到 service 的参数中
return service(...args, abortRef.current.signal)
},
{
...options,
// 默认不自动执行,让调用方决定
manual: true,
}
)
useEffect(() => {
return () => {
abortRef.current?.abort()
}
}, [])
return { run: run as any, cancel, ...rest }
}
6.6 竞态问题(Race Condition)处理
// ❌ 错误:先发的请求后返回导致数据错乱
async function search(keyword: string) {
const result = await api.search(keyword)
// 如果用户连续搜索 "a" → "ab" → "abc"
// 可能 "ab" 的结果比 "abc" 晚到,导致最终显示 "ab" 的结果
searchResults.value = result
}
// ✅ 正确:使用请求取消 + 计数器防止竞态
// ==== Vue 方案 ====
<script setup lang="ts">
import { ref } from 'vue'
const searchText = ref('')
const searchResults = ref<SearchItem[]>([])
let requestId = 0
watch(searchText, async (keyword: string) => {
const currentId = ++requestId
// 取消上一个请求
abortManager.abort(`search:${currentId - 1}`)
const signal = abortManager.createSignal(`search:${currentId}`)
try {
const result = await api.search(keyword, signal)
// 只有当前最新的请求才更新数据
if (currentId === requestId) {
searchResults.value = result
}
} catch {
if (currentId === requestId) {
// 处理错误
}
}
})
</script>
竞态问题图解
时间线 →
搜索 "a" 搜索 "ab" 搜索 "abc"
请求发出: ──A── ──B── ──C──
后端返回: ──C── ──A── ──B──
↑
如果没有取消/防竞态,最终显示 A 的结果
而用户最后输入的是 "abc"
===> 永远只接受最后一次发起的请求的结果
七、请求重试
7.1 为什么需要重试
| 场景 | 错误类型 | 是否需要重试 |
|---|---|---|
| 网络瞬时抖动 | net::ERR_CONNECTION_RESET |
仅限可安全重放的请求 |
| 服务器 5xx | 500 Internal Server Error |
仅在判断为临时故障时 |
| 请求超时 | timeout of 15000ms exceeded |
结果未知;写操作必须同键重试或先查询 |
| 429 限流 | Too Many Requests |
读取 Retry-After,超过等待预算则失败 |
| 4xx 客户端错误 | 400 Bad Request |
否(参数错误,重试也无用) |
| 401 未授权 | Unauthorized |
按身份协议刷新一次或重新登录 |
重试条件不能只看状态码,还要看 HTTP 方法、业务语义和幂等键。POST 超时后服务端可能已经完成,换新键重试会制造重复数据;PUT 在 HTTP 语义上幂等,也不代表任意业务实现都适合自动重放。
7.2 指数退避重试策略
第 1 次失败 → 等待 1s → 重试
第 2 次失败 → 等待 2s → 重试
第 3 次失败 → 等待 4s → 重试
第 4 次失败 → 等待 8s → 重试
第 5 次失败 → 放弃(已达最大重试次数)
公式: wait = baseDelay * 2^(attempt-1) + random(0, jitter)
7.3 手动实现重试机制
// ==== utils/http/retry.ts ====
interface RetryOptions {
/** 最大重试次数 */
maxRetries: number
/** 基础延迟(毫秒) */
baseDelay: number
/** 抖动最大值(毫秒),防止惊群效应 */
maxJitter: number
/** 可重试的状态码 */
retryableStatuses: number[]
}
const defaultRetryOptions: RetryOptions = {
maxRetries: 3,
baseDelay: 1000,
maxJitter: 500,
retryableStatuses: [408, 429, 500, 502, 503, 504],
}
/**
* 带指数退避的请求重试
* 对比 Java 的 Spring Retry / resilience4j 重试机制
*/
export async function withRetry<T>(
requestFn: () => Promise<T>,
canRetry: (error: unknown) => boolean,
options: Partial<RetryOptions> = {}
): Promise<T> {
const opts = { ...defaultRetryOptions, ...options }
let lastError: unknown
for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
try {
return await requestFn()
} catch (error: unknown) {
lastError = error
// 最后一次尝试失败,不再重试
if (attempt === opts.maxRetries) break
if (!canRetry(error)) throw error
const axiosError = error as any
const status = axiosError?.response?.status
// 判断是否值得重试
if (status && !opts.retryableStatuses.includes(status)) {
// 非可重试状态码(如 4xx),直接抛出
throw error
}
// canRetry 已确认该操作可安全重放;这里再计算退避时间。
if (!status || opts.retryableStatuses.includes(status)) {
// 指数退避 + 随机抖动
const delay = Math.min(
opts.baseDelay * Math.pow(2, attempt) + Math.random() * opts.maxJitter,
30_000 // 最大等待 30s
)
console.warn(
`[HTTP Retry] 请求失败 (attempt ${attempt + 1}/${opts.maxRetries}), ` +
`status: ${status || 'NETWORK'}, 等待 ${delay}ms 后重试`
)
await new Promise((resolve) => setTimeout(resolve, delay))
}
}
}
throw lastError
}
7.4 使用 axios-retry
// ==== 更推荐的方式:使用 axios-retry ====
import axios from 'axios'
import axiosRetry from 'axios-retry'
const instance = axios.create({
baseURL: getBaseURL(),
timeout: 15_000,
})
// 配置重试
axiosRetry(instance, {
retries: 3,
retryDelay: (retryCount) => {
// 指数退避:1s, 2s, 4s
return axiosRetry.exponentialDelay(retryCount)
},
retryCondition: (error) => {
// 只对网络错误和 5xx 重试
return axiosRetry.isNetworkOrIdempotentRequestError(error)
// 或自定义:
// return !error.response || error.response.status >= 500
},
// 库只能按网络错误和 HTTP 方法做通用判断。
// POST/PATCH 等写操作如需重试,应在业务层确认幂等键和结果查询协议。
})
7.5 重试策略对比
| 方式 | 复杂度 | 灵活性 | 推荐度 |
|---|---|---|---|
手动 withRetry |
中 | 高 | 适合需要精细控制的场景 |
axios-retry |
低 | 中 | 适合规则统一的读取请求 |
业务层手动 try-catch 重试 |
低 | 低 | 不推荐,侵入性强 |
八、接口幂等性(前端视角)
8.1 前端幂等面临的挑战
后端要做幂等,前端能做的是:
1. 生成幂等键,跟随请求传递
2. 本地禁止重复提交(loading / 按钮禁用)
3. 在网络层防止同一请求被重复发出
但前端无法保证:
- 用户手动 F5 刷新页面重放请求
- 浏览器开发者工具重放 XHR
- 使用 curl / Postman 直接调用
8.2 前端生成幂等键
// 键标识一次用户意图,不标识请求内容。
export function generateIdempotentKey(businessType: string): string {
return `${businessType}_${crypto.randomUUID()}`
}
同一订单即使内容相同,也可能代表两次合法购买,因此不要只用请求体哈希当幂等键。服务端可以保存请求体指纹,用它拒绝“同一个键、不同请求内容”。键还应受当前用户或租户及操作类型约束。
8.3 在请求封装中集成幂等键
// ==== 在 request 函数中 ====
async function request<T>(config) {
// 如果配置了幂等键,注入请求头
if (config.idempotentKey) {
(config as any).headers = {
...(config as any).headers,
'Idempotency-Key': config.idempotentKey,
}
}
return instance.request(config)
}
8.4 业务代码中使用
// ==== Vue 组件中使用 ====
<script setup lang="ts">
import { ref } from 'vue'
import { generateIdempotentKey } from '@/utils/idempotent'
const operationKey = ref<string | null>(null)
async function createOrder() {
// 新操作生成键;超时或 5xx 后重试继续复用。
operationKey.value ??= generateIdempotentKey('createOrder')
try {
const order = await http.post('/api/order', formData, {
idempotentKey: operationKey.value,
showLoading: true,
})
ElMessage.success('创建成功')
operationKey.value = null
} catch (error) {
// 网络错误和超时意味着结果未知,保留键并提示用户查询或同键重试。
// 只有服务端明确拒绝且用户开始一笔新操作时才清空键。
showRetryOrQueryState(error)
}
}
</script>
8.5 幂等机制对比
| 角色 | 方案 | 作用 |
|---|---|---|
| 前端 | 生成幂等键 + 传递 Header | 标记请求唯一性 |
| 网关 | 拦截幂等 Header,做请求去重 | 第一道防线 |
| 后端 | Token 机制 / 唯一约束 / 状态机 | 最终的幂等保障 |
注意:前端幂等键只是协议标识,最终保障必须依赖服务端的唯一约束、请求指纹和结果存储。若页面刷新后仍要恢复未决操作,应把“操作键 + 非敏感状态”持久化,并提供按键查询结果的接口;不要把完整表单、Token 或敏感响应一起写入
localStorage。
九、并发请求管理
9.1 请求池(限制最大并发数)
当页面需要同时发起大量请求时,需要限制并发数以保护浏览器和服务器。
// ==== utils/http/requestPool.ts ====
/**
* 请求池 — 限制最大并发请求数
* 对比 Java 的线程池 / Semaphore 控制并发
*/
class RequestPool {
private maxConcurrent: number
private running = 0
private queue: Array<{
executor: () => Promise<unknown>
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}> = []
constructor(maxConcurrent: number = 6) {
// 浏览器通常限制同一域名并发 6 个连接
this.maxConcurrent = maxConcurrent
}
/** 提交请求到池中执行 */
submit<T>(executor: () => Promise<T>): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ executor, resolve, reject } as any)
this.processNext()
})
}
/** 处理下一个请求 */
private processNext(): void {
while (this.running < this.maxConcurrent && this.queue.length > 0) {
const task = this.queue.shift()!
this.running++
task
.executor()
.then((result) => task.resolve(result))
.catch((error) => task.reject(error))
.finally(() => {
this.running--
this.processNext()
})
}
}
/** 获取当前等待数 */
get pendingCount(): number {
return this.queue.length
}
/** 获取当前执行数 */
get runningCount(): number {
return this.running
}
}
/** 全局请求池实例 */
export const requestPool = new RequestPool(6)
// ==== 使用示例:批量上传文件 ====
async function uploadFiles(files: File[]) {
const uploadPromises = files.map((file) =>
requestPool.submit(() => uploadFile(file))
)
return Promise.allSettled(uploadPromises)
}
9.2 Promise.allSettled vs Promise.all
Promise.all |
Promise.allSettled |
|
|---|---|---|
| 遇到拒绝 | 立即失败 | 等待所有完成 |
| 结果包含 | 所有成功值 | 每个的 status + value/reason |
| 适用场景 | 所有请求必须全部成功 | 部分失败不影响整体 |
| 典型场景 | 表单提交关联多个 API | 批量获取数据,容忍部分失败 |
// ❌ Promise.all:一个请求失败,全部失败
async function loadDashboard_bad() {
try {
const [user, orders, stats] = await Promise.all([
api.getUserInfo(),
api.getOrders(),
api.getStats(),
])
return { user, orders, stats }
} catch {
// 即使只是 stats 失败,整个仪表盘都加载不了
}
}
// ✅ Promise.allSettled:部分失败不影响整体
interface DashboardData {
user?: UserInfo
orders?: Order[]
stats?: Stats
}
async function loadDashboard(): Promise<DashboardData> {
const results = await Promise.allSettled([
api.getUserInfo(),
api.getOrders(),
api.getStats(),
])
// ✅ 防御式处理:每个结果单独检查
// 永远不要信任后端返回的 Promise 都会成功
const data: DashboardData = {}
if (results[0].status === 'fulfilled') {
data.user = results[0].value
} else {
console.warn('获取用户信息失败:', results[0].reason)
}
if (results[1].status === 'fulfilled') {
data.orders = results[1].value
}
if (results[2].status === 'fulfilled') {
data.stats = results[2].value
}
return data
}
十、请求缓存
10.1 为什么需要请求缓存
| 场景 | 无缓存 | 有缓存 |
|---|---|---|
| 用户频繁切换 Tab | 每次切换都重新请求 | 缓存未过期则直接使用 |
| 列表页翻回前一页 | 重复请求相同数据 | 直接读取缓存 |
| 多个组件依赖同一数据 | 每个组件各自请求一次 | 共享缓存结果 |
10.2 基于 URL + 参数的请求缓存
// ==== utils/http/cacheManager.ts ====
interface CacheEntry<T = unknown> {
data: T
timestamp: number
/** TTL(毫秒) */
ttl: number
}
class CacheManager {
/** 缓存 Map: key → 缓存条目 */
private cache = new Map<string, CacheEntry>()
/**
* 生成缓存 Key
* 对比 Java 的 @Cacheable 注解的 key 生成策略
*/
private generateKey(config: {
url: string
method?: string
params?: unknown
data?: unknown
}): string {
return [
config.method || 'GET',
config.url,
JSON.stringify(config.params || {}),
JSON.stringify(config.data || {}),
].join('|')
}
/** 获取缓存(过期则返回 null) */
get<T>(config: {
url: string
method?: string
params?: unknown
data?: unknown
}): T | null {
const key = this.generateKey(config)
const entry = this.cache.get(key)
if (!entry) return null
// 检查是否过期
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key)
return null
}
return entry.data as T
}
/** 设置缓存 */
set<T>(
config: {
url: string
method?: string
params?: unknown
data?: unknown
},
data: T,
ttl: number
): void {
const key = this.generateKey(config)
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
})
}
/** 清除特定 URL 的缓存 */
invalidate(url: string): void {
// 遍历删除以该 url 开头的缓存
for (const key of this.cache.keys()) {
if (key.includes(url)) {
this.cache.delete(key)
}
}
}
/** 清除所有缓存 */
clear(): void {
this.cache.clear()
}
/** 获取缓存大小 */
get size(): number {
return this.cache.size
}
/**
* 自动清理过期缓存
* 建议在定时器或路由切换时调用
*/
cleanExpired(): number {
let cleaned = 0
const now = Date.now()
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key)
cleaned++
}
}
return cleaned
}
}
export const cacheManager = new CacheManager()
10.3 集成到请求封装中
// 在请求函数中添加缓存逻辑
async function request<T>(config) {
// GET 请求且配置了缓存 TTL
if ((config.method || 'GET') === 'GET' && config.cacheTTL) {
// 命中缓存则直接返回
const cached = cacheManager.get<T>({
url: config.url,
params: config.params,
})
if (cached !== null) {
return cached
}
// 未命中则发起请求并缓存
const result = await instance.request(config)
cacheManager.set(
{ url: config.url, params: config.params },
result,
config.cacheTTL
)
return result
}
// 写操作清除相关缓存
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method || 'GET')) {
cacheManager.invalidate(config.url)
}
return instance.request(config)
}
10.4 缓存策略对比
| 特性 | 本机内存缓存 | SessionStorage | LocalStorage | Service Worker |
|---|---|---|---|---|
| 作用域 | 当前页面 | 当前会话 | 跨会话持久 | 跨页面 |
| 容量 | 取决于内存 | ~5MB | ~5MB | 取决于实现 |
| 刷新后 | 消失 | 保留 | 保留 | 保留 |
| 适用 | 短时缓存 | 同会话非敏感状态 | 少量非敏感偏好 | 离线资源与自定义策略 |
缓存位置由数据敏感度、过期要求和离线需求决定。服务端数据优先使用带失效策略的查询缓存;localStorage 适合非敏感偏好,不应因为“需要跨页面”就把访问令牌或个人数据放进去。写入成功后按资源键失效缓存,不能只用 URL 字符串包含关系猜测关联数据。
十一、Composable / Hook 封装
11.1 Vue 3: useRequest Composable
// ==== composables/useRequest.ts ====
import { ref, type Ref, onUnmounted } from 'vue'
import { abortManager } from '@/utils/http/abortManager'
/**
* Vue 3 通用请求 Composable
* 对标 Java 的 RpcService 封装模式
*
* 职责:
* - 统一管理 loading / data / error 状态
* - 组件卸载时自动取消请求
* - 支持手动 refresh
* - 支持竞态保护
*/
export function useRequest<T, P extends any[] = []>(
/** 请求函数 */
fetcher: (...args: P) => Promise<T>,
options?: {
/** 是否立刻执行 */
immediate?: boolean
/** 是否在组件卸载时取消请求 */
autoCancel?: boolean
/** 请求作用域(用于取消) */
scope?: string
}
) {
const { immediate = false, autoCancel = true, scope = 'useRequest' } = options || {}
// ============ 状态管理 ============
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
// 竞态防护
let latestCallId = 0
// ============ 执行函数 ============
const execute = async (...args: P): Promise<T | null> => {
const callId = ++latestCallId
loading.value = true
error.value = null
try {
const result = await fetcher(...args)
// 竞态保护:只有最新的调用才更新数据
if (callId === latestCallId) {
data.value = result as any
}
return result
} catch (err: any) {
if (callId === latestCallId) {
// 请求取消不视为错误
if (err?.message !== 'CANCELLED') {
error.value = err
}
}
return null
} finally {
if (callId === latestCallId) {
loading.value = false
}
}
}
// ============ 刷新 ============
const refresh = async (): Promise<T | null> => {
// 在泛型无法精确追踪参数类型时,手动断言
return execute(...([] as unknown as P))
}
// ============ 生命周期 ============
if (immediate) {
execute()
}
if (autoCancel) {
onUnmounted(() => {
abortManager.abort(scope)
})
}
// ============ 重置 ============
const reset = () => {
data.value = null
error.value = null
loading.value = false
latestCallId = 0
}
return {
data,
error,
loading,
execute,
refresh,
reset,
}
}
// ==== Vue 组件使用 ====
<script setup lang="ts">
import { useRequest } from '@/composables/useRequest'
import { userApi } from '@/api/user'
const props = defineProps<{ userId: number }>()
// 一行代码完成请求管理
const { data: user, loading, error, refresh } = useRequest(
() => userApi.getUserInfo(props.userId),
{
immediate: true,
scope: `user:${props.userId}`,
}
)
</script>
<template>
<div v-if="loading">加载中...</div>
<div v-else-if="error">加载失败: {{ error.message }}</div>
<div v-else-if="user">
<span>{{ user.name }}</span>
<el-button @click="refresh">刷新</el-button>
</div>
<div v-else>暂无数据</div>
</template>
11.2 React: useRequest Hook
// ==== hooks/useRequest.ts ====
import { useState, useEffect, useCallback, useRef } from 'react'
/**
* React 通用请求 Hook
* 对标 ahooks 的 useRequest,但更轻量
*/
export function useRequest<T, P extends any[] = []>(
fetcher: (...args: P) => Promise<T>,
options?: {
/** 依赖项,变化时自动重新请求 */
deps?: any[]
/** 是否自动执行 */
manual?: boolean
/** 请求成功后回调 */
onSuccess?: (data: T) => void
/** 请求失败后回调 */
onError?: (error: Error) => void
}
) {
const { deps = [], manual = false, onSuccess, onError } = options || {}
const [data, setData] = useState<T | null>(null)
const [error, setError] = useState<Error | null>(null)
const [loading, setLoading] = useState(false)
// 竞态防护
const latestCallId = useRef(0)
// 组件是否挂载
const mountedRef = useRef(true)
const execute = useCallback(
async (...args: P): Promise<T | null> => {
const callId = ++latestCallId.current
setLoading(true)
setError(null)
try {
const result = await fetcher(...args)
// 竞态保护 + 卸载保护
if (callId === latestCallId.current && mountedRef.current) {
setData(result as any)
onSuccess?.(result)
}
return result
} catch (err: any) {
if (callId === latestCallId.current && mountedRef.current) {
setError(err)
onError?.(err)
}
return null
} finally {
if (callId === latestCallId.current && mountedRef.current) {
setLoading(false)
}
}
},
[fetcher]
) as any
const refresh = useCallback(() => {
return execute()
}, [execute])
// 自动执行:依赖变化时重新请求
useEffect(() => {
if (!manual) {
execute()
}
}, deps)
// 清理函数
useEffect(() => {
return () => {
mountedRef.current = false
}
}, [])
return {
data,
error,
loading,
execute,
refresh,
}
}
// ==== React 组件使用 ====
import { useRequest } from '@/hooks/useRequest'
import { userApi } from '@/api/user'
import { Spin, Button, Alert } from 'antd'
function UserProfile({ userId }: { userId: number }) {
// ✅ 自动请求 + 依赖 userId
const { data: user, loading, error, refresh } = useRequest(
() => userApi.getUserInfo(userId),
{ deps: [userId] }
)
if (loading) return <Spin />
if (error) return <Alert type="error" message={error.message} />
if (!user) return <Alert type="info" message="暂无数据" />
return (
<div>
<span>{user.name}</span>
<Button onClick={refresh}>刷新</Button>
</div>
)
}
11.3 对标 Java 封装模式
| Java 后端 | 前端等效 | 说明 |
|---|---|---|
RpcService / FeignClient |
api/user.ts + http.get |
接口定义层,解耦调用方 |
Response<T> 通用返回体 |
ApiResponse<T> 拦截器解包 |
统一处理返回结构 |
@Retryable 重试注解 |
withRetry / axios-retry |
自动重试能力 |
@HystrixCommand / @SentinelResource |
try-catch + allSettled |
部分失败不影响整体 |
@Cacheable |
cacheManager |
请求结果缓存 |
ExecutorService 线程池 |
RequestPool 请求池 |
并发控制 |
Interceptor / Filter |
Axios 拦截器 | 横切关注点 |
traceId 全链路追踪 |
X-Request-Id |
请求追踪 |
@Idempotent 注解 |
idempotentManager + Header |
幂等防重 |
十二、完整实践:一个健壮的 API 层架构
12.1 目录结构
src/
├── utils/
│ └── http/
│ ├── index.ts # 导出 http 实例
│ ├── type.ts # 类型定义
│ ├── config.ts # 环境配置
│ ├── requestInterceptors.ts # 请求拦截器
│ ├── responseInterceptors.ts # 响应拦截器
│ ├── tokenRefresh.ts # Token 刷新
│ ├── deduplicator.ts # 请求去重
│ ├── cacheManager.ts # 请求缓存
│ ├── requestPool.ts # 请求池
│ ├── abortManager.ts # 请求取消
│ ├── retry.ts # 请求重试
│ └── idempotent.ts # 幂等键管理
│
├── api/
│ ├── user.ts # 用户相关 API
│ ├── order.ts # 订单相关 API
│ └── product.ts # 商品相关 API
│
├── composables/ # Vue 专用
│ ├── useRequest.ts # 通用请求 composable
│ └── useGlobalLoading.ts # 全局 loading
│
└── hooks/ # React 专用
└── useRequest.ts # 通用请求 hook
12.2 完整使用链路
用户交互
│
▼
Composable/Hook (useRequest)
│ ── 管理 loading/data/error 状态
│ ── 竞态保护
│ ── 组件卸载时取消请求
│
▼
API 层 (api/user.ts)
│ ── 类型安全的接口调用
│ ── 业务相关的参数组装
│
▼
请求封装 (utils/http)
│
├─ 请求拦截器
│ ├─ Token 注入
│ ├─ 幂等键注入
│ ├─ 全局 Loading
│ └─ 请求追踪 ID
│
├─ 请求执行
│ ├─ 去重(并发相同 GET 请求)
│ ├─ 缓存(命中则直接返回)
│ ├─ 请求池(并发控制)
│ └─ 重试(指数退避)
│
└─ 响应拦截器
├─ 数据解包
├─ 业务错误处理
├─ Token 过期 → 刷新 → 重放
└─ HTTP 异常处理
│
▼
服务端 API
十三、生产检查清单
设计阶段
- 请求拦截器是否注入 Token?跳过 Token 的接口是否通过
skipAuth标记? - 响应拦截器是否统一处理业务错误(
code !== 0)? - Token 刷新是否通过队列防止并发刷新?
- 是否统一了 API 返回类型
ApiResponse<T>?
实现阶段
- 搜索输入是否加了防抖(300ms)?
- 提交按钮是否用 loading 状态防重复点击?
- 组件卸载时是否取消正在进行的请求?
- 竞态问题是否处理(请求计数器或 AbortController)?
- 每条自动重试规则是否限定了可安全重放的方法和业务操作?
- 写操作超时后是否复用原幂等键,并能查询未决结果?
测试阶段
- 重复点击提交按钮是否只提交了一次?
- 快速切换 Tab/页面,前一个页面的请求是否被取消?
- 搜索快速输入时,是否只有最后一次结果被展示?
- Token 过期后,请求是否自动刷新后重放成功?
- 并发相同请求是否只发了一次真正请求?
- 请求失败重试是否按指数退避策略执行?
运维阶段
- 请求缓存的清理策略是否合理?
- 内存缓存是否有大小限制,防止内存溢出?
- 是否有请求监控(请求耗时、失败率、重试次数)?
十四、常见踩坑点
14.1 响应解包没有统一契约
// 一种选择:保留业务外层结构
instance.interceptors.response.use((res) => res.data)
// 调用方不得不:
const response = await api.getUser()
if (response.code === 0) {
// 业务逻辑
}
// 另一种选择:在拦截器统一检查并解包
instance.interceptors.response.use((res) => {
if (res.data.code !== 0) {
throw new Error(res.data.message)
}
return res.data.data
})
// 调用方直接获得业务数据:
const user = await api.getUser() // 直接拿到 UserInfo
两种方式都可以,关键是类型声明、成功条件和错误模型一致。若有文件下载、流式响应或不同上游格式,不要强迫所有响应套同一业务外层结构。
14.2 没有处理竞态请求
// ❌ 错误:没有竞态保护
watch(keyword, async (val) => {
const result = await api.search(val)
// 如果先发的请求后到,最终展示的是旧结果
results.value = result
})
// ✅ 正确:使用请求计数器或 AbortController
watch(keyword, async (val) => {
const requestId = ++currentRequestId
const result = await api.search(val)
if (requestId === currentRequestId) {
results.value = result
}
})
14.3 组件卸载后还在 setState
// ❌ 错误:组件卸载后更新状态(React)
useEffect(() => {
api.getUser().then((user) => {
// 如果组件已卸载,这里会报错
setUser(user)
})
}, [])
// ✅ 正确:使用清理函数标记
useEffect(() => {
let cancelled = false
api.getUser().then((user) => {
if (!cancelled) setUser(user)
})
return () => { cancelled = true }
}, [])
14.4 Token 刷新队列实现不当
// ❌ 错误:没有用队列,多个并发请求各自刷新 Token
if (error.response.status === 401) {
// 如果 10 个请求同时 401,会发起 10 次刷新请求
const newToken = await refreshTokenApi()
config.headers.Authorization = `Bearer ${newToken}`
return instance(config)
}
// ✅ 正确:使用锁 + 队列,同一时间只刷新一次
// 详见 tokenRefresh.ts 的实现
14.5 请求去重的误用
// ❌ 错误:POST 请求也用去重
// 如果两次 POST 请求内容相同(比如创建相同配置的订单),去重会让第二次"消失"
// 用户以为自己创建了两次,实际上只有一次被发送
// ✅ 正确:只对 GET 等幂等请求做去重
// 写操作使用幂等键防重,而非请求去重
14.6 缓存过期策略不合理
// ❌ 错误:缓存 TTL 设置过长且没有失效机制
// TTL: 1 hour,用户修改了个人信息,1 小时内看到的仍是旧数据
// ✅ 正确:写操作后清除相关缓存
async function updateUser(data) {
const result = await http.put('/user/info', data)
// 清除用户相关缓存
cacheManager.invalidate('/user/info')
return result
}
14.7 没有区分业务错误和 HTTP 异常
// ❌ 错误:所有错误一视同仁
catch (error) {
ElMessage.error('请求失败')
}
// ✅ 正确:区分业务错误和网络异常
catch (error) {
if (error instanceof AxiosError) {
if (error.response) {
// 服务端返回了错误响应
handleBusinessError(error.response.data)
} else if (error.request) {
// 请求发出去了但没有收到响应
ElMessage.error('网络异常,请检查网络')
}
} else {
// 代码中的错误
console.error('Unexpected error:', error)
}
}
十五、核心原则总结
- 先定义契约 — 成功、业务失败、HTTP 失败、取消和超时必须能被调用方区分。
- 拦截器只放横切逻辑 — 身份头、关联 ID 和统一错误转换适合集中处理,业务提示和页面跳转应保留上下文。
- 取消与忽略旧结果解决不同问题 — 取消节省资源;请求序号保证旧响应不会覆盖新状态,必要时可以同时使用。
- 重试先判断能否安全重放 — 再设置次数、退避、抖动和总时间预算;写操作复用同一幂等键。
- 缓存必须有所有权和失效规则 — 明确缓存键、有效期、写后失效、敏感度和最大容量。
- 前端防重改善体验 — 服务端幂等记录和数据约束负责最终正确性。
- 身份续期最多透明重放一次 — 并发请求共享刷新结果,失败后回到明确的登录状态。
- 抽象应由重复需求驱动 — 小项目可从原生
fetch和少量包装开始,不必预先实现完整网络框架。
十六、验证与参考
至少验证以下时序:两个请求乱序返回、组件卸载时仍在请求、十个并发 401、写操作超时后同键重试、写后缓存失效。测试要断言最终页面状态和服务端数据条数,不能只统计发出了几次请求。