Skip to content

Axios 请求层 ​

项目刚开始时,在组件里直接 axios.get(...) 很方便。页面多了以后,问题就来了:

  • 接口返回 HTTP 200,但实际是“余额不足”,页面却显示“操作成功”;
  • 一个请求失败,同一句错误提示弹了三次;
  • 登录过期的瞬间,页面上五个请求同时去刷新 token,用户被直接踢回登录页;
  • 网络很差时,页面一直转圈,永远不会超时。

这些问题的解法都一样:把与网络相关的通用规则收进一个请求层,组件只关心业务。

读完这篇你会

  • 搭建“组件 → 业务接口 → 请求客户端”三层结构
  • 用拦截器统一处理登录态、业务错误和错误提示
  • 实现“并发请求只刷新一次 token”的单飞(single-flight)逻辑

1. 原理:拦截器是请求的“前台” ​

所有请求进出都要经过前台:出去的请求,前台给它贴上身份标签(token)、编号;回来的响应,前台先拆包、检查有没有问题,再交给具体的人。

text
组件:  “加载用户资料”
   ↓ 调用
业务接口 api/user.js:  getUser(id)
   ↓ 调用
请求客户端 api/http.js:  请求拦截器(加 token)→ 发送 → 响应拦截器(拆包、统一错误)
   ↓
服务端

组件不需要知道接口地址、超时时间、请求头、状态码;业务接口只描述“有哪些资源和操作”;网络规则集中在请求客户端里。将来换登录方式、换错误格式,只需要改一个文件。

2. 创建唯一的请求客户端 ​

js
// src/api/http.js
import axios from 'axios'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 由部署环境注入,不写死在代码里
  timeout: 15_000 // Axios 默认是 0,也就是永不超时
})

http.interceptors.request.use((config) => {
  const token = getAccessToken()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})
js
// src/api/user.js —— 业务接口只表达“做什么”
import { http } from './http'

export const getUser = (id) => http.get(`/users/${encodeURIComponent(id)}`)
export const updateUser = (id, payload) => http.patch(`/users/${encodeURIComponent(id)}`, payload)

组件里只处理自己的状态:

js
async function loadProfile() {
  loading.value = true
  try {
    profile.value = await getUser(route.params.id)
  } catch (error) {
    errorMessage.value = error.message
  } finally {
    loading.value = false
  }
}

3. 坑一:接口返回 200,页面却显示成功 ​

现象:用户余额不足,点击支付后页面提示“支付成功”,实际上什么都没发生。

原因:后端约定所有响应都返回 HTTP 200,用响应体里的 code 表示业务结果:

json
{ "code": 40021, "message": "余额不足", "data": null }

Axios 只根据 HTTP 状态码判断成功与否,200 就走成功分支。组件拿到这个对象,以为请求成功了。

修复:在响应拦截器里检查业务码,不成功就转成错误抛出:

js
http.interceptors.response.use((response) => {
  const { code, message, data } = response.data
  if (code !== 0) {
    // 带上 config,后面的拦截器才知道这个请求的配置(比如是否静默)
    return Promise.reject(
      new ApiError(message, { code, status: response.status, config: response.config })
    )
  }
  return data // 组件直接拿到 data,不用再写 res.data.data
})
js
class ApiError extends Error {
  constructor(message, { code, status, fields, config } = {}) {
    super(message)
    this.name = 'ApiError'
    this.code = code     // 业务码
    this.status = status // HTTP 状态码
    this.fields = fields // 表单字段级错误
    this.config = config // 发起请求时的配置
  }
}

怎么避免再犯:和后端约定统一的响应格式,并且只在请求层判断一次“成功”的标准。

4. 坑二:同一个错误提示弹了三次 ​

现象:保存失败时,屏幕上叠了三个一模一样的“网络异常”提示。

原因:响应拦截器弹了一次;组件的 catch 里又弹了一次;全局的错误处理再弹一次。每一层都觉得“错误应该让用户知道”。

修复:约定只在一个地方提示。常见做法是:拦截器负责默认提示,个别需要自己处理的请求通过配置项关闭它:

js
// 最后注册的响应拦截器:统一整理错误并提示
http.interceptors.response.use(undefined, (error) => {
  if (axios.isCancel(error)) return Promise.reject(error) // 主动取消不提示,见坑四
  const appError = error instanceof ApiError ? error : normalizeError(error)
  if (!appError.config?.silent) toast.error(appError.message)
  return Promise.reject(appError) // 仍然抛出,让调用方决定后续流程
})

// 需要自定义处理的请求
await http.post('/coupons/check', payload, { silent: true })

组件的 catch 里只处理流程(比如停止加载、恢复按钮),不再重复弹提示。

5. 坑三:token 过期时,五个请求同时去刷新 ​

现象:用户停留一段时间后再操作,页面同时发出了五个请求。它们都收到 401,于是都去调用“刷新 token”接口。如果后端的刷新 token 是一次性的,第一次刷新成功后旧的刷新 token 就作废了,后面四次全部失败,用户被踢回登录页。

原因:每个请求各自处理 401,彼此不知道别人已经在刷新了。

修复:让刷新动作同一时间只执行一次,其他请求等待同一个结果,这叫单飞(single-flight):

js
let refreshing = null // 正在进行的刷新任务

function refreshTokenOnce() {
  if (!refreshing) {
    refreshing = api
      .refreshToken()
      .then(({ accessToken }) => setAccessToken(accessToken))
      .finally(() => {
        refreshing = null // 无论成功失败,结束后允许下一次刷新
      })
  }
  return refreshing // 所有调用者拿到的是同一个 Promise
}

http.interceptors.response.use(undefined, async (error) => {
  const { config, response } = error
  if (response?.status === 401 && !config._retried && !config.url.includes('/auth/refresh')) {
    config._retried = true // 防止重试后又 401,陷入死循环
    try {
      await refreshTokenOnce()
      return http(config) // 用新 token 重新发送原请求
    } catch {
      redirectToLogin()
    }
  }
  return Promise.reject(error)
})

三个关键点:共享同一个刷新 Promise;每个请求只重试一次;刷新接口本身的 401 不再触发刷新。

注意拦截器的注册顺序。响应拦截器按注册顺序依次执行,上面几段代码要按“业务码检查 → 刷新 token → 统一提示”的顺序注册:刷新 token 的拦截器需要拿到 Axios 的原始错误(带 config 和 response),而统一提示必须放在最后,否则 401 还没来得及刷新,用户就先看到了一个错误提示。

6. 坑四:取消请求也弹出了“请求失败” ​

现象:用户快速切换标签页,屏幕上不断弹出“请求失败”。

原因:切换时取消了上一个页面的请求,取消也会让 Promise 进入失败分支,被当作普通错误提示了。

修复:Axios 支持 AbortController,取消时识别出来,安静地结束:

js
const controller = new AbortController()
http.get('/reports', { signal: controller.signal })
controller.abort()

// 拦截器或 catch 里
if (axios.isCancel(error)) return // 主动取消,不提示

7. 坑五:失败自动重试,结果重复扣款 ​

现象:为了提高成功率,给所有请求加了“失败自动重试 3 次”。结果网络超时后,用户被扣了两次钱。

原因:超时不代表服务器没处理。请求可能已经成功执行,只是响应在回来的路上丢了。对“创建订单”“支付”这类操作,重试就意味着重复执行。

修复:只对重复执行没有副作用的请求自动重试(如 GET 查询);写操作要重试,必须配合后端的幂等机制(同一个请求 ID 只处理一次),详见 事务、幂等与补偿。

8. 不同错误,给用户不同的出路 ​

情况页面应该怎么做
网络断开、超时、5xx提示可以重试,保留用户已填写的内容
401 未登录或登录过期统一刷新登录态,失败则跳转登录页
403 无权限明确说明没有权限,而不是“请求失败”
404 资源不存在显示“内容不存在或已删除”
422 / 业务校验失败把错误显示到对应的表单字段上
js
function normalizeError(error) {
  const status = error.response?.status
  const body = error.response?.data
  return new ApiError(body?.message || '网络连接异常,请稍后重试', {
    status,
    code: body?.code,
    fields: body?.fields,
    config: error.config
  })
}

排查思路 ​

  1. 先看 Network 面板:请求有没有发出、发了几次、带没带 token、响应体里的 code 是什么。
  2. 重复提示:在提示函数里打断点,看 Call Stack 是从哪几层调用的。
  3. 刷新 token 的问题:在 Network 面板筛选刷新接口,看一次 401 之后它被调用了几次。
  4. 拦截器的执行顺序:Axios 的请求拦截器后注册的先执行,响应拦截器按注册顺序执行。多个拦截器互相影响时,先确认它们的顺序。
  5. 给每个请求加一个请求 ID(放在请求头里),前后端日志能按它对上号。

检查清单 ​

  • [ ] 全项目只有一个请求客户端,设置了超时
  • [ ] 业务错误(200 + 错误码)在拦截器里转成异常
  • [ ] 错误提示只在一个地方弹出,可按请求关闭
  • [ ] 并发 401 只刷新一次 token,每个请求最多重试一次
  • [ ] 主动取消的请求不提示错误
  • [ ] 只对查询类请求自动重试,写操作依赖后端幂等
  • [ ] 接口地址、token、测试账号不写进代码仓库

延伸阅读 ​

为复用而记录,为理解而整理。