Axios 完整封装合集(鉴权 + 重复拦截 + Loading + 缓存 + 统一错误 + 请求重试|全代码逐行注释)

69 阅读20分钟

技术选项:

Axios + TypeScript + Element Plus(Loading / 消息提示),适配 Vue3 + Vite 项目

背景

项目存在多处重复业务逻辑:每个接口重复写 Loading、重复携带 Token、按钮频繁点击产生重复请求、接口错误分散处理、列表查询接口频繁请求相同参数浪费服务端资源。

为统一前端网络请求规范、减少页面冗余代码、提升接口交互体验,需要基于 Axios 做完整二次封装,一次性实现请求头鉴权、重复请求拦截、全局 Loading、内存接口缓存、统一错误码处理、请求重试六大核心能力,支撑 B 端后台、C 端活动页、Taro 小程序多业务场景复用。

一、基础目录结构

src
├─ utils
│  ├─ loading.ts    # 全局Loading计数器工具(逐行注释)
│  └─ request.ts    # Axios核心封装(鉴权/去重/loading/缓存/报错/重试全功能)
└─ api              # 业务接口调用层示例
   └─ goods.ts

二、全局 Loading 管理工具 utils/loading.ts

// 引入Element Plus全屏Loading组件
import { ElLoading } from 'element-plus'

// 并发请求计数器,解决多接口同时请求时Loading反复闪烁
let loadingCount = 0
// 存储Loading实例,用于后续关闭弹窗
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null

/**
 * 打开全局Loading弹窗
 * 只有计数器从0变为1时才创建弹窗,并发请求只显示一次加载
 */
export const showLoading = () => {
  // 每发起一次请求,计数+1
  loadingCount++
  // 无正在执行的请求,初始化Loading遮罩
  if (loadingCount === 1) {
    loadingInstance = ElLoading.service({
      lock: true, // 锁定页面滚动,防止操作干扰
      text: '数据加载中,请稍候...', // 加载提示文字
      background: 'rgba(0, 0, 0, 0.55)' // 遮罩透明度
    })
  }
}

/**
 * 关闭全局Loading弹窗
 * 所有请求执行完毕后再销毁弹窗,避免中途消失
 */
export const hideLoading = () => {
  // 单个请求完成,计数-1
  loadingCount--
  // 所有请求全部结束
  if (loadingCount <= 0) {
    // 重置计数器,防止负数异常
    loadingCount = 0
    // 关闭加载弹窗
    loadingInstance?.close()
    // 清空实例释放内存
    loadingInstance = null
  }
}

三、主封装文件 utils/request.ts(全功能 + 逐行注释)

// 导入axios核心库、类型、取消请求类型
import axios, { AxiosRequestConfig, AxiosResponse, CancelTokenSource } from 'axios'
// 导入全局消息提示组件
import { ElMessage } from 'element-plus'
// 导入Loading开关工具
import { showLoading, hideLoading } from './loading'

// 扩展axios内置类型,追加自定义字段
declare module 'axios' {
  interface InternalAxiosRequestConfig {
    noCache?: boolean
    cacheTime?: number
    retryCount?: number
    retryDelay?: number
    retryNum?: number
  }
}

// ====================== 全局基础常量配置 ======================
// 环境变量读取后端基础域名
const BASE_URL = import.meta.env.VITE_API_BASE_URL
// 请求超时时间15秒
const TIME_OUT = 15000
// GET接口默认缓存有效期5分钟(毫秒)
const DEFAULT_CACHE_DURATION = 5 * 60 * 1000
// 请求重试:默认最大重试2次
const DEFAULT_RETRY_COUNT = 2
// 请求重试:每次重试间隔1000毫秒
const DEFAULT_RETRY_DELAY = 1000

// ====================== 全局内存存储容器 ======================
/**
 * pendingRequestMap:存储正在执行的请求,用于拦截重复请求
 * key:请求唯一标识字符串
 * value:axios取消请求源对象
 */
const pendingRequestMap = new Map<string, CancelTokenSource>()
/**
 * apiCacheMap:GET接口内存缓存池
 * key:请求唯一标识
 * value:缓存数据data + 缓存过期时间戳expireTime
 */
const apiCacheMap = new Map<string, { data: any; expireTime: number }>()

// ====================== 通用工具函数 ======================
/**
 * 移除pending请求在pendingRequestMap中的记录
 */
const removePendingRequest = (config: AxiosRequestConfig, key?: string) => {
  const reqKey = key || generateRequestKey(config);
  pendingRequestMap.delete(reqKey);
};

/**
 * 根据请求配置生成全局唯一key,区分不同接口、不同参数
 * @param config axios请求配置对象
 * @returns 拼接后的唯一标识字符串
 */
function generateRequestKey(config: AxiosRequestConfig): string {
  // 解构核心请求信息
  const { method, url, params, data } = config
  // 方法+地址+URL参数+请求体参数拼接,参数转字符串消除对象地址差异
  return [
    method,
    url,
    JSON.stringify(params ?? {}),
    JSON.stringify(data ?? {})
  ].join('&')
}

/**
 * 取消同地址同参数的未完成请求,防止重复请求
 * @param config 当前请求配置
 */
function removeSamePendingRequest(config: AxiosRequestConfig) {
  // 生成当前请求唯一key
  const reqKey = generateRequestKey(config)
  // 判断是否存在正在执行的相同请求
  if (pendingRequestMap.has(reqKey)) {
    // 获取取消源
    const source = pendingRequestMap.get(reqKey)
    // 主动取消上一次重复请求,附带提示文案
    source?.cancel('重复请求,已自动取消上一次请求')
    // 清除Map中已取消的请求记录
    removePendingRequest(condif, reqKey)
  }
}

/**
 * 清空指定接口的全部缓存,新增/编辑/删除业务后调用刷新列表
 * @param targetUrl 需要清除缓存的接口地址
 */
export function clearApiCacheByUrl(targetUrl: string) {
  // 遍历缓存池所有缓存key
  for (const [key] of apiCacheMap) {
    // 匹配接口地址则删除缓存
    if (key.includes(targetUrl)) {
      apiCacheMap.delete(key)
    }
  }
}

/**
 * 延时等待工具函数,用于重试间隔
 * @param ms 等待毫秒数
 * @returns Promise
 */
function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms))
}

// ====================== 创建Axios实例,统一基础配置 ======================
const service = axios.create({
  baseURL: BASE_URL, // 统一基础接口域名
  timeout: TIME_OUT, // 统一超时时间
  headers: {
    'Content-Type': 'application/json;charset=UTF-8' // 默认JSON请求体
  }
})

// ====================== 请求拦截器:请求发送前统一处理 ======================
service.interceptors.request.use(
  (config: InternalAxiosRequestConfig<AxiosRequestConfig>) => {
    // 1. 重复请求拦截:先取消同参数未完成请求
    removeSamePendingRequest(config)
    
    // 创建取消令牌源,用于主动中断当前请求
    // 取消源对象,里面包含两件东西
    // cancelSource.token:令牌(token) ,交给 axios 请求配置 config.cancelToken,告诉 axios:这个请求可以被取消
    // cancelSource.cancel('提示文字'):取消函数,调用它就可以终止正在 pending 的请求
    const cancelSource = axios.CancelToken.source()
    
    // 将取消令牌挂载到当前请求配置
    // 把令牌挂载到本次请求的配置上,axios 内部会监听这个令牌。
    // 当外部执行 cancelSource.cancel(),axios 就会立刻中断这个请求。
    config.cancelToken = cancelSource.token
    
    // 生成当前请求唯一标识
    const reqKey = generateRequestKey(config)
    // 将取消源存入Map,标记该请求处于pending状态
    pendingRequestMap.set(reqKey, cancelSource)

    // 2. 请求头统一鉴权:自动携带登录Token
    // 从本地存储读取登录凭证
    const token = localStorage.getItem('token')
    // Token存在则挂载请求头,采用行业标准Bearer鉴权格式
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }

    // 3. GET接口缓存逻辑:默认开启缓存,配置noCache则关闭缓存
    // 判断当前请求方式是否为GET
    const isGetMethod = config.method?.toLowerCase() === 'get'
    // GET请求且未手动关闭缓存,执行缓存命中逻辑
    if (isGetMethod && !config.noCache) {
      // 根据key读取缓存数据
      const cacheInfo = apiCacheMap.get(reqKey)
      // 获取当前时间戳
      const nowTime = Date.now()
      // 缓存存在且未过期,抛出缓存数据,跳过网络请求
      if (cacheInfo && cacheInfo.expireTime > nowTime) {
        // 打断请求,抛出一个假错误,在响应拦截器中做处理
        return Promise.reject({
          isCacheHit: true, // 自定义标识:命中缓存
          cacheData: cacheInfo.data // 缓存业务数据
        })
      }
    }

    // 4. 全局Loading控制:配置hideLoading则不展示加载弹窗
    if (!config.hideLoading) {
      showLoading()
    }

    // 返回处理完成的配置,发起网络请求
    return config
  },
  // 请求拦截阶段异常,直接抛出错误
  (err: AxiosError) => Promise.reject(err)
)

// ====================== 响应拦截器:接口返回后统一处理(含重试逻辑) ======================
service.interceptors.response.use(
  // 请求成功回调
  (response: AxiosResponse) => {
    // 获取当前请求原始配置
    const config = response.config
    // 生成请求唯一key
    const reqKey = generateRequestKey(config)
    // 请求完成,移除pendingMap中的记录
    pendingRequestMap.delete(reqKey)
    // 关闭全局Loading弹窗
    hideLoading()

    // 取出后端返回业务数据
    const res = response.data
    // 5. GET接口数据存入内存缓存
    const isGetMethod = config.method?.toLowerCase() === 'get'
    if (isGetMethod && !config.noCache) {
      // 优先使用接口自定义缓存时长,无配置则使用全局默认值
      const cacheTime = config.cacheTime || DEFAULT_CACHE_DURATION
      // 写入缓存池,记录数据与过期时间
      apiCacheMap.set(reqKey, {
        data: res,
        expireTime: Date.now() + cacheTime
      })
    }

    // 6. 统一业务状态码处理(约定code=200为业务成功)
    if (res.code === 200) {
      // 业务正常,直接返回数据给调用页面
      return res
    } else {
      // 业务逻辑失败,弹出后端返回提示文案
      ElMessage.error(res.msg || '业务请求失败')
      // 抛出异常,供页面catch捕获
      return Promise.reject(res)
    }
  },
  // 请求失败回调(网络异常、5xx、4xx、超时、取消请求、缓存命中全部走这里)
  async (error) => {
    // 无论成功失败,统一关闭Loading
    hideLoading()
    
    // 清除pendingRequestMap记录
    if (error.config) {
      removePendingRequest(error.config)
    }

    // 分支1:捕获缓存命中标识,直接返回缓存数据,不报错、不重试
    if (error.isCacheHit) {
      return Promise.resolve(error.cacheData)
    }

    // 分支2:手动取消的重复请求,静默处理,不弹窗、不重试
    if (axios.isCancel(error)) {
      return Promise.reject('cancel')
    }

    // 分支3:请求重试核心逻辑
    // 获取当前请求配置,无配置则无法重试,直接走错误提示
    const config = error.config
    if (!config) {
      const status = error.response?.status
      switch (status) {
        case 401:
          ElMessage.error('登录身份已失效,请重新登录')
          localStorage.removeItem('token')
          location.href = '/login'
          break
        case 403:
          ElMessage.error('当前账号无访问权限')
          break
        case 404:
          ElMessage.error('请求接口地址不存在')
          break
        case 500:
          ElMessage.error('服务器内部异常,请稍后重试')
          break
        default:
          ElMessage.error(error.message || '网络异常,请检查网络连接')
      }
      return Promise.reject(error)
    }

    // 初始化当前请求重试计数,不存在则赋值0
    if (config.retryNum === undefined) {
      config.retryNum = 0
    }
    // 读取接口自定义最大重试次数,无配置使用全局默认
    const maxRetry = config.retryCount || DEFAULT_RETRY_COUNT
    // 读取接口自定义重试间隔,无配置使用全局默认
    const delayTime = config.retryDelay || DEFAULT_RETRY_DELAY

    // 判断是否满足重试条件:仅5xx服务端错误、请求超时允许重试
    const isServerErr = error.response && error.response.status >= 500
    const isTimeoutErr = error.message.includes('timeout')
    // 非可重试错误:4xx、权限、登录失效、接口不存在,直接报错不重试
    if (!isServerErr && !isTimeoutErr) {
      const status = error.response?.status
      switch (status) {
        case 401:
          ElMessage.error('登录身份已失效,请重新登录')
          localStorage.removeItem('token')
          location.href = '/login'
          break
        case 403:
          ElMessage.error('当前账号无访问权限')
          break
        case 404:
          ElMessage.error('请求接口地址不存在')
          break
        case 500:
          ElMessage.error('服务器内部异常,请稍后重试')
          break
        default:
          ElMessage.error(error.message || '网络异常,请检查网络连接')
      }
      return Promise.reject(error)
    }

    // 判断是否达到最大重试次数,达到上限终止重试
    if (config.retryNum >= maxRetry) {
      ElMessage.error(`请求失败,已重试${maxRetry}次,请稍后再试`)
      return Promise.reject(error)
    }

    // 未达上限,重试计数+1
    config.retryNum += 1
    // 控制台打印重试日志,方便开发调试
    console.log(`接口${config.url}异常,第${config.retryNum}次重试,${delayTime}ms后发起`)
    
    // 等待指定间隔时间
    // 让整个重试流程卡在这里,等够你配置的重试间隔(比如 1000ms),再继续往下走,不立刻重新发请求
    // 用await的原因:
    // 你的错误回调本身是 async 函数,加 await 才能把「等待间隔」这个异步过程 纳入当前请求的 Promise 链
    // 如果不用 await,直接写 setTimeout(() => instance(config), delayTime) ,整个重试过程会直接脱离 Promise 链路,用户的业务请求会一直卡在 pending 状态,永远收不到重试结果
    await delay(delayTime)
    
    // 递归重新发起当前请求
    // 拿着原来的请求配置,重新发一次一模一样的请求,并且把新请求的 Promise 接力到原来的请求链上
    return service(config)
  }
)

// 导出封装完成的axios实例,全局业务接口统一调用
export default service

⚠️ 请求重试重点逻辑解释

// 等待指定间隔时间
await delay(delayTime);
// 递归重新发起当前请求
return instance(config);

这两行是整套「自动重试」逻辑的核心收尾步骤,结合之前写的拦截器完整逻辑逐行拆透,包括为什么要这么写、不这么写会出什么 bug:

  • 先定位上下文

这两行在重试流程里的位置是:

接口因为 5xx / 超时失败 → 判断满足重试条件 → 还没到最大重试次数 → 重试计数 + 1 → 打印重试日志 → 就到红框这两行,完成「等待→重新发请求」的最后动作

逐行拆解

1. await delay(delayTime);
这行在做什么

之前写过一个极简的工具函数:

// delay本质就是把setTimeout包装成Promise,实现"强制等N毫秒"
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))

这行代码的作用是:让整个重试流程卡在这里,等够你配置的重试间隔(比如 1000ms),再继续往下走,不立刻重新发请求。

为什么一定要等,不能立刻重试?

这是自动重试设计的核心常识,不是为了写代码好看:

  1. 触发重试的场景是服务端 5xx、请求超时 —— 这类错误 90% 是瞬时抖动(比如服务刚重启、网关临时流量打满),刚失败就立刻重试,大概率还是一样的结果;隔几百毫秒到 1 秒再发,成功率会高非常多。
  2. 防止把已经故障的服务打垮:如果接口一挂就无限秒级重试,一秒钟能给已经崩了的服务发几十次请求,反而加重服务故障。
  3. 用await的原因:错误回调本身是async函数,加 await 才能把「等待间隔」这个异步过程纳入当前请求的 Promise 链—— 如果不用 await,直接写setTimeout(() => instance(config), delayTime),整个重试过程会直接脱离 Promise 链路,用户的业务请求会一直卡在 pending 状态,永远收不到重试结果。

2. return instance(config);
这行在做什么
  • instance就是自己封装的、带了所有请求 / 响应拦截器的 axios 实例,不是原生 axios。
  • config就是这次失败请求的完整配置(URL、请求方法、参数、请求头、存在 config 上的重试计数)。
  • 这行本质就是:拿着原来的请求配置,重新发一次一模一样的请求,并且把新请求的 Promise 接力到原来的请求链上。
最关键的作用:让用户全程 "无感" 重试

在业务代码里调用接口的时候是这么写的:

instance.get('/user/list')
  .then(data => console.log('拿到数据了', data))
  .catch(err => console.log('最终失败了', err))

用户从始至终都只在等最开始那一次请求的结果 ——

  1. 第一次请求失败进重试分支,你不直接 reject 这个错误,反而return instance(config)发起第二次请求;
  2. 第二次请求如果成功了,成功的结果会顺着 Promise 链一路传回去,用户的.then()直接拿到正常数据,完全感知不到中间失败过、重试过;
  3. 第二次又失败,就再进错误回调、再计数、再等待、再发起第三次…… 直到真的超过最大重试次数,才真正把错误 reject 给用户的.catch()。

如果这里你不 return instance(config),直接 return 个普通值(比如return config、return Promise.resolve()),那重试请求根本不会被发出去,业务代码会把请求配置对象当成接口数据,完全乱套。


两行组合起来的完整真实流程

举个实际跑的例子(你的配置:最大重试 3 次,间隔 1000ms):

  1. 页面调get('/user/list'),发起第一次请求;
  2. 服务端临时挂了,返回 500,进响应错误回调;
  3. 判断是 5xx 错误,满足重试条件,现在重试计数 = 0,没到 3 次上限;
  4. 重试计数 + 1 变成 1,控制台打印「接口 /user/list 异常,第 1 次重试,1000ms 后发起」;
  5. 等待 1000ms(就是await delay(delayTime)那步);
  6. 重新发起第二次请求(return instance(config));
  7. 第二次请求成功 → 直接把用户列表数据返回给页面,用户啥也不知道,页面正常渲染;
  8. 假设第二次也挂了 → 再走一遍流程:计数变 2,再等 1 秒,发第三次;
  9. 第三次还挂 → 超过最大重试次数,直接 reject 错误给页面,弹「请求失败,已重试 3 次」的提示。

这两行写法最容易踩的 3 个坑

  1. 不 await delay,直接用 setTimeout 重试 会导致重试过程脱离 Promise 链:错误回调立刻执行完了,用户的请求永远卡在 pending,页面一直转圈,重试的结果直接丢了,因为 setTimeout 的异步流程和原来的 Promise 链没关系。
  2. return 的时候不重新走 instance 拦截器 如果你用原生axios(config)发重试请求,你之前写的 token 注入、loading、缓存、重复请求拦截的逻辑全部失效 —— 因为重试的请求绕过了你自己封装的拦截器。
  3. 忘写 return 如果只写instance(config)不写return,那重试请求确实会发,但是结果传不回去,用户的业务代码在第一次请求失败后就直接走失败分支了,等重试的成功结果根本接不住。

一句话总结

这两行就是自动重试逻辑的 "灵魂":

await delay(间隔) 做退避等待,不暴力重试; return instance(config) 把重试请求接力回原来的 Promise 链,让用户无感知拿到最终结果,完全不用关心中间重试了几次。


四、业务接口调用示例 api/goods.ts

// 导入封装好的请求实例、清空缓存工具
import request, { clearApiCacheByUrl } from '@/utils/request'

/**
 * 获取商品列表(GET查询,开启缓存、开启重试)
 * @param params 列表筛选查询参数
 * @returns Promise列表数据
 */
export function getGoodsList(params: any) {
  return request({
    url: '/goods/list', // 接口地址
    method: 'get', // 查询类GET请求,支持缓存、重试
    params, // URL拼接参数
    cacheTime: 3 * 60 * 1000, // 自定义缓存3分钟
    retryCount: 3, // 最大重试3次
    retryDelay: 1500 // 每次重试间隔1.5秒
  })
}

/**
 * 获取商品详情(GET查询,关闭缓存、隐藏loading)
 * @param id 商品唯一ID
 */
export function getGoodsDetail(id: number) {
  return request({
    url: '/goods/detail',
    method: 'get',
    params: { id },
    noCache: true, // 关闭缓存,实时拉取最新数据
    hideLoading: true, // 轻量查询不展示加载遮罩
    retryCount: 2
  })
}

/**
 * 新增商品(POST提交,不缓存、关闭重试,防止重复创建数据)
 * @param data 表单提交参数
 */
export function addGoods(data: any) {
  return request({
    url: '/goods/add',
    method: 'post',
    data, // 请求体参数
    retryCount: 0 // 关闭重试,避免重复提交
  }).then(res => {
    // 新增完成,清空商品列表缓存,页面刷新最新数据
    clearApiCacheByUrl('/goods/list')
    return res
  })
}

五、六大核心功能实现思路拆解(面试口述版)

1. 请求头统一鉴权

  1. 在请求拦截器读取本地存储的 token;
  2. 统一挂载到请求头 headers.Authorization,格式 Bearer token(采用行业通用 Bearer 格式);
  3. 所有接口自动携带,无需每个页面单独处理;
  4. 响应拦截捕获 401 鉴权失效,自动清空 token 并跳转登录,全项目无需每个页面单独处理登录失效。。

2. 重复请求拦截

  1. 通过 method+url+参数 生成唯一请求标识;
  2. 使用 Map 存储所有正在 pending 的请求 CancelToken;
  3. 发起新请求前检查 Map,存在相同标识则取消上一条请求;
  4. 请求成功 / 失败后清理 Map 内对应记录,防止内存溢出。

解决场景:搜索框频繁输入、提交按钮重复点击、筛选条件快速切换。

3. 全局 Loading 计数机制

采用数字计数机制,并发多个接口只会打开一次 Loading:

  • 发起请求 count+1,count=1 时弹出加载框;
  • 请求结束 count-1,count<=0 关闭加载框;
  • 支持单个接口配置 hideLoading: true 跳过加载动画,适配高频轻量查询接口。

4. GET 接口内存缓存

  1. 默认仅对 GET 查询接口生效,POST/PUT/DELETE 不缓存;
  2. 可配置 noCache 强制实时请求,cacheTime 自定义缓存过期时间;
  3. 缓存存在内存中,页面刷新自动清空;
  4. 提供工具方法 clearApiCacheByUrl,新增、编辑、删除后主动清空列表缓存,保证数据实时性。

为什么默认只缓存 GET 请求,POST/PUT/DELETE 不缓存

4.1 HTTP 语义层面规范

HTTP 标准定义请求方法语义:

  1. GET:只读查询操作,不会修改服务端任何数据,多次请求返回数据一致,天然适合缓存;
  2. POST/PUT/DELETE:写操作,用于新增、修改、删除数据库资源,每次调用都会改变后端数据,缓存无意义。
4.2 若给 POST/PUT/DELETE 开启缓存,存在的严重弊端
弊端 1:数据严重不一致(业务致命问题)

假设新增商品 POST 接口开启缓存: 用户提交表单新增商品,接口成功写入数据库,但前端读取内存缓存旧列表,页面长期不展示新增数据,必须手动刷新页面才能看到。 PUT/DELETE 更新删除数据同理,缓存会一直保留旧数据,页面和数据库状态脱节。

弊端 2:引发脏数据、业务逻辑错乱
  1. 删除接口缓存存在旧数据,页面依旧展示已删除条目;
  2. 修改接口缓存未更新,编辑后页面还是原始数据,用户误以为修改未生效;
  3. 多用户协同场景下,缓存隔离不同用户操作,数据完全失真。
弊端 3:缓存清理成本极高

GET 列表仅需新增 / 编辑后清空对应接口缓存; 如果 POST/PUT 全部缓存,每一个写接口都要手动遍历清理所有关联页面缓存,维护代码量翻倍,极易遗漏。

弊端 4:存在重复提交风险

POST 表单提交接口缓存命中时,会直接返回缓存成功结果,不会真实向后端发送请求: 用户重复点击提交按钮,前端直接读取缓存,后端未收到新增请求,但页面提示操作成功,导致用户操作丢失。

弊端 5:内存资源浪费

写接口调用频次低、无复用价值,全部存入缓存会持续占用浏览器内存,大量无用缓存堆积造成页面卡顿。

4.3 特殊场景允许 POST 缓存(极少使用,需手动开启 noCache=false)

仅适用于只读型 POST 查询接口(后端设计不规范,用 POST 传大量查询参数做筛选),使用时必须满足两点:

  1. 该接口纯查询,无任何新增、修改、删除逻辑;
  2. 每次数据变更后,主动调用 clearApiCacheByUrl 清空对应缓存。

5. 统一错误码分层处理

四层错误分支区分处理,逻辑解耦:

  1. 缓存命中:直接返回缓存数据,不走报错逻辑;
  2. 主动取消重复请求:静默拒绝,不弹窗干扰用户;
  3. HTTP 状态码错误:401/403/404/500 分别给出对应提示与处理逻辑;
  4. 后端业务码错误:code≠200 统一弹出后端返回提示文案。

6.请求重试

6.1 背景

线上经常出现网络抖动、服务器瞬时 502/503、接口超时导致页面白屏、查询失败,用户需要手动刷新页面体验很差,所以在 Axios 请求层统一封装自动重试能力,不用每个页面单独写重试逻辑。

6.2 整体设计思路

  1. 限定重试触发条件,不盲目重试

    • 只允许两种异常触发重试:服务端 500/502/503/504 服务异常、timeout 请求超时; 以下场景直接拒绝重试:401 登录失效、403 无权限、404 接口不存在、手动取消重复请求、业务码报错。 原因:4xx 是客户端参数 / 权限问题,重试也不会成功,没必要浪费请求。
  2. 扩展 Axios 配置,支持全局默认 + 单接口自定义

    • 定义全局默认:最大重试 2 次,间隔 1s; 给每个请求 config 拓展三个自定义属性:

    • retryCount:单接口自定义最大重试次数,优先级高于全局;
    • retryDelay:自定义重试延时;
    • retryNum:挂载在 config 上,独立记录当前接口重试次数,接口之间互不干扰。
  3. 延时重试,防止服务器雪崩

    • 不用立刻重发,通过 setTimeout 延时一段时间再请求; 如果大量用户同时接口报错,瞬时大量重试会压垮后端,延时可以分散请求峰值。
  4. 递归重发请求

    • 未达到重试上限时,等待延迟时间后,直接把原始 config 丢给 axios 实例重新发起请求。
  5. 区分读写接口,规避业务风险

    • GET 查询列表、统计页面:默认开启重试;
    • POST/DELETE/ 表单提交:强制建议关闭重试(retryCount:0),网络异常多次重试会导致重复新增、重复删除,产生脏数据。

6.3 面试问题

精简版口述回答

项目里我在 Axios 响应拦截器实现了接口自动重试,只针对 5xx 服务异常和超时两种场景重试,4xx、手动取消、登录失效都不会重试。 通过在请求 config 挂载 retryNum 单独记录每个接口重试次数,支持全局默认重试次数,也能单接口自定义次数和延时。 重试前会延时等待,避免瞬间大量请求压垮后端;同时区分读写接口,POST 提交类接口关闭重试,防止重复提交脏数据。整个拓展低侵入,完全兼容之前封装的 token 鉴权、重复请求拦截、全局 Loading 和接口缓存逻辑。

追问 1:为什么 4xx 错误不做重试?

4xx 代表客户端侧问题:401 登录过期、403 权限不足、404 地址错误、参数校验失败,问题根源在前端 / 用户身份,重复请求不会改变结果,只会产生无效网络请求,浪费带宽和服务器资源。

追问 2:POST 提交接口为什么要关闭重试?

POST 是写操作,作用是新增 / 修改数据。如果网络超时触发重试,后端会收到多条相同提交请求,造成重复创建订单、重复表单、重复数据库记录,引发严重业务脏数据,所以提交类接口必须手动关闭重试。

追问 3:重试为什么要加延迟,不能立刻重发?
  1. 瞬时 502/503 一般是服务器瞬间重启、网关抖动,等待 1 秒大概率恢复;
  2. 大量用户同一时间接口报错,立刻重试会瞬间放大请求量,造成服务器雪崩;延时可以分散请求流量。
追问 4:多个并发接口重试,计数器会互相干扰吗?

不会。我是把 retryNum 挂载在每个接口独立的 config 对象上,每个请求配置独立保存重试次数,接口之间完全隔离,互不影响。

追问 5:如果重试期间用户切换页面,会有什么问题?怎么解决?

重试过程中请求还处于 pending 状态,页面销毁后请求依旧会完成并弹窗报错。 优化方案(可补充):在路由离开时调用取消请求方法,清除所有 pending 中的请求,页面销毁中断所有重试逻辑。

追问 6:重试和你之前写的重复请求拦截冲突吗?

不冲突。每次重试发起时,依旧会执行重复请求拦截逻辑,先取消上一次未完成的同参数请求,再发起重试,不会出现多条相同请求同时 pending。

追问 7:缓存命中的时候会走重试逻辑吗?

不会,代码里最先判断缓存命中标识,直接返回缓存数据,直接终止错误流程,不会进入重试分支。