CC 咖啡猫的工作空间 Coding Space

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)
  }
}

十五、核心原则总结

  1. 先定义契约 — 成功、业务失败、HTTP 失败、取消和超时必须能被调用方区分。
  2. 拦截器只放横切逻辑 — 身份头、关联 ID 和统一错误转换适合集中处理,业务提示和页面跳转应保留上下文。
  3. 取消与忽略旧结果解决不同问题 — 取消节省资源;请求序号保证旧响应不会覆盖新状态,必要时可以同时使用。
  4. 重试先判断能否安全重放 — 再设置次数、退避、抖动和总时间预算;写操作复用同一幂等键。
  5. 缓存必须有所有权和失效规则 — 明确缓存键、有效期、写后失效、敏感度和最大容量。
  6. 前端防重改善体验 — 服务端幂等记录和数据约束负责最终正确性。
  7. 身份续期最多透明重放一次 — 并发请求共享刷新结果,失败后回到明确的登录状态。
  8. 抽象应由重复需求驱动 — 小项目可从原生 fetch 和少量包装开始,不必预先实现完整网络框架。

十六、验证与参考

至少验证以下时序:两个请求乱序返回、组件卸载时仍在请求、十个并发 401、写操作超时后同键重试、写后缓存失效。测试要断言最终页面状态和服务端数据条数,不能只统计发出了几次请求。