前言

几乎每个前端项目都离不开 HTTP 请求。

但在实际开发中,你是否遇到过这些问题:

  • ❌ 每个页面都手写 fetch/ axios.get

  • ❌ Token 过期后整个页面崩掉

  • ❌ 错误信息到处重复处理

  • ❌ 同一个请求在组件切换时没有取消,导致数据错乱

这些问题的根源只有一个:

没有统一的请求层封装。

这一篇,我们从零搭建一个企业级 Axios 封装方案,覆盖以下核心能力:

  • ✅ 请求/响应拦截器

  • ✅ Token 自动注入与刷新

  • ✅ 统一错误处理

  • ✅ 请求取消(CancelToken)

  • ✅ 与 Pinia 配合管理用户状态


一、基础封装:创建 Axios 实例

1️⃣ 安装

pnpm add axios

2️⃣ 创建实例

// utils/request.ts
import axios from 'axios'

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
})

export default request

📌 为什么要单独创建实例?

  • 避免污染全局 axios.defaults

  • 不同服务可以用不同实例(如文件上传用另一个)


二、请求拦截器:自动注入 Token

// utils/request.ts
import { useUserStore } from '@/stores/user'

request.interceptors.request.use(
  (config) => {
    const userStore = useUserStore()
    if (userStore.token) {
      config.headers!.Authorization = `Bearer ${userStore.token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

每个请求自动带上 Token,业务代码零感知


三、响应拦截器:统一处理后端返回

1️⃣ 假设后端返回格式

{
  "code": 0,
  "data": { "id": 1, "name": "Tom" },
  "message": "success"
}

2️⃣ 响应拦截器封装

request.interceptors.response.use(
  (response) => {
    const { code, data, message } = response.data

    if (code === 0) {
      return data // 直接返回业务数据
    }

    // 业务错误(如参数校验失败)
    ElMessage.error(message || '请求失败')
    return Promise.reject(new Error(message))
  },
  (error) => {
    // HTTP 错误(网络/状态码)
    if (error.response) {
      const { status } = error.response
      switch (status) {
        case 401:
          // Token 过期,触发刷新逻辑
          break
        case 403:
          ElMessage.error('无权限访问')
          break
        case 500:
          ElMessage.error('服务器异常')
          break
      }
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时')
    } else {
      ElMessage.error('网络异常')
    }
    return Promise.reject(error)
  }
)

📌 业务代码只需要关心成功数据,不需要处理错误


四、Token 自动刷新(重点)

这是面试高频 + 实战刚需

1️⃣ 核心思路

请求返回 401
  → 用 refreshToken 换新的 accessToken
  → 重试原请求
  → 刷新失败则强制登出

2️⃣ 完整实现

// utils/request.ts
import { useUserStore } from '@/stores/user'
import { refreshTokenApi } from '@/api/auth'

let isRefreshing = false
let pendingQueue: ((token: string) => void)[] = []

request.interceptors.response.use(
  (response) => response.data,
  async (error) => {
    const originalRequest = error.config
    const userStore = useUserStore()

    // 不是 401 或已经重试过了
    if (error.response?.status !== 401 || originalRequest._retry) {
      return Promise.reject(error)
    }

    originalRequest._retry = true

    // 没有 refreshToken,直接登出
    if (!userStore.refreshToken) {
      userStore.logout()
      return Promise.reject(error)
    }

    // 正在刷新,将请求加入队列
    if (isRefreshing) {
      return new Promise((resolve) => {
        pendingQueue.push((token: string) => {
          originalRequest.headers!.Authorization = `Bearer ${token}`
          resolve(request(originalRequest))
        })
      })
    }

    // 开始刷新
    isRefreshing = true

    try {
      const newToken = await refreshTokenApi(userStore.refreshToken)
      userStore.updateToken(newToken.accessToken, newToken.refreshToken)

      // 重试队列中的所有请求
      pendingQueue.forEach(cb => cb(newToken.accessToken))
      pendingQueue = []

      // 重试原请求
      originalRequest.headers!.Authorization = `Bearer ${newToken.accessToken}`
      return request(originalRequest)
    } catch (err) {
      // 刷新失败,强制登出
      userStore.logout()
      return Promise.reject(err)
    } finally {
      isRefreshing = false
    }
  }
)

📌 关键点

  • isRefreshing防止并发请求重复刷新

  • pendingQueue暂存等待的请求

  • 刷新成功后批量重试


五、请求取消(防止组件卸载后 setState)

1️⃣ 使用 AbortController

// api/user.ts
import request from '@/utils/request'

export function getUserList(params: any, signal?: AbortSignal) {
  return request.get('/users', { params, signal })
}

组件中:

<script setup>
import { onUnmounted } from 'vue'
import { getUserList } from '@/api/user'

const controller = new AbortController()

const fetchData = async () => {
  try {
    const data = await getUserList({}, controller.signal)
    console.log(data)
  } catch (e) {
    if (e.name === 'CanceledError') return
    console.error(e)
  }
}

onUnmounted(() => {
  controller.abort() // 组件卸载时取消请求
})
</script>

防止"组件已销毁但请求还在"导致的报错


六、API 模块化组织

1️⃣ 目录结构

src/
├── api/
│   ├── user.ts
│   ├── order.ts
│   ├── auth.ts
│   └── index.ts

2️⃣ 示例:user.ts

// api/user.ts
import request from '@/utils/request'

export interface User {
  id: number
  name: string
  email: string
}

export function getUserList(params: { page: number; size: number }) {
  return request.get<User[]>('/users', { params })
}

export function getUserById(id: number) {
  return request.get<User>(`/users/${id}`)
}

export function updateUser(id: number, data: Partial<User>) {
  return request.put<User>(`/users/${id}`, data)
}

export function deleteUser(id: number) {
  return request.delete(`/users/${id}`)
}

3️⃣ 统一导出

// api/index.ts
export * from './user'
export * from './order'
export * from './auth'

组件中一行导入:

import { getUserList, updateUser } from '@/api'

七、与 Pinia 配合:用户状态管理

// stores/user.ts
import { defineStore } from 'pinia'
import { loginApi, getUserInfoApi } from '@/api/auth'

export const useUserStore = defineStore('user', () => {
  const token = ref(localStorage.getItem('token') || '')
  const refreshToken = ref(localStorage.getItem('refreshToken') || '')
  const userInfo = ref(null)

  const setToken = (accessToken: string, refToken: string) => {
    token.value = accessToken
    refreshToken.value = refToken
    localStorage.setItem('token', accessToken)
    localStorage.setItem('refreshToken', refToken)
  }

  const fetchUserInfo = async () => {
    const info = await getUserInfoApi()
    userInfo.value = info
    return info.roles
  }

  const logout = () => {
    token.value = ''
    refreshToken.value = ''
    userInfo.value = null
    localStorage.clear()
    window.location.href = '/login'
  }

  return { token, refreshToken, userInfo, setToken, fetchUserInfo, logout }
})

八、环境变量配置

# .env.development
VITE_API_BASE_URL=/api

# .env.production
VITE_API_BASE_URL=https://api.example.com

Vite 中访问:

import.meta.env.VITE_API_BASE_URL

九、完整封装文件一览

src/
├── utils/
│   └── request.ts      # Axios 实例 + 拦截器 + Token 刷新
├── api/
│   ├── user.ts         # 用户相关接口
│   ├── order.ts        # 订单相关接口
│   └── index.ts        # 统一导出
├── stores/
│   └── user.ts         # 用户状态 + Token 管理
└── env files           # 环境变量

十、面试高频问答

Q1:Axios 拦截器和中间件有什么区别?

拦截器是 Axios 特有的请求/响应钩子,中间件是更通用的概念。

Q2:Token 刷新时并发请求怎么处理?

用一个标志位锁住刷新流程,其余请求放入队列,刷新完成后批量重试。

Q3:AbortController 和 CancelToken 的区别?

CancelToken 已被废弃,AbortController 是浏览器标准 API。


十一、总结(架构级)

  • Axios 实例隔离,避免全局污染

  • 拦截器统一处理 Token 和错误

  • Token 刷新用"锁 + 队列"方案

  • AbortController 防止内存泄漏

  • API 按模块组织,Pinia 管理状态


📢 下期预告

👉 第 20 篇:环境变量与跨域处理 —— Vite 的配置秘籍

更多推荐