Axios 封装:前端的 RestTemplate + 拦截器

为什么要封装,不直接用 axios.get()

直接用 axios.get('/api/xxx') 能跑,但项目大了会撞上这几件事:

封装的本质:把"每个请求都一样的事"下沉到一个地方,页面里只关心业务数据。

Java 对照

Spring Axios 封装
RestTemplate / WebClient bean axios.create() 产出的实例
ClientHttpRequestInterceptor axios.interceptors.request
ResponseErrorHandler axios.interceptors.response 的错误分支
@Valid / 统一异常处理器 响应拦截器里 throw + 全局 UI 提示
Feign 按服务拆接口 src/api/*.ts 按模块拆接口

一句话:axios 实例 = 一个 HTTP 客户端 Bean,拦截器 = Filter


1. 创建实例:一个 baseURL 一个实例

// src/utils/http.ts
import axios from 'axios'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
  timeout: 10_000,
  headers: {
    'Content-Type': 'application/json',
  },
})

要点

反例:全项目都用全局 axios,有天接了个外部 API,一加拦截器把内部 API 也拦了。


2. 请求拦截器:统一塞 Token、统一加时间戳

http.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error),
)

Java 对照:Spring 的 ClientHttpRequestInterceptor——在请求发出前统一加 Header。

常见能做的事:


3. 响应拦截器:剥壳 + 错误分发

假设后端约定:

{ "code": 0, "data": { ... }, "message": "ok" }
// code=0 成功,非 0 失败
import { ElMessage } from 'element-plus'
import router from '@/router'

http.interceptors.response.use(
  (response) => {
    const body = response.data

    // 文件下载等非标准返回直接放过
    if (response.config.responseType === 'blob') return response

    if (body.code === 0) {
      // ⭐ 成功:直接把 data 当作 Promise 的 resolve 值,调用方不用再剥
      return body.data
    }

    // 业务错误:弹提示 + reject,让调用方的 catch 有机会处理
    ElMessage.error(body.message ?? '请求失败')
    return Promise.reject(new Error(body.message ?? `业务错误 code=${body.code}`))
  },
  (error) => {
    // 网络错误 / HTTP 非 2xx
    if (error.response) {
      const status = error.response.status
      if (status === 401) {
        ElMessage.error('登录已过期,请重新登录')
        localStorage.removeItem('token')
        router.push('/login')
      } else if (status >= 500) {
        ElMessage.error('服务器开小差了')
      } else {
        ElMessage.error(`请求失败:${status}`)
      }
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时')
    } else {
      ElMessage.error('网络异常')
    }
    return Promise.reject(error)
  },
)

要点 1:为什么在成功分支里 return body.data

这是最值得学的技巧。调用方原本要写:

const res = await http.get('/user/me')
console.log(res.data.data.name)   // axios 的 data 壳 + 后端的 data 壳,剥两层

拦截器里 return body.data 之后:

const user = await http.get('/user/me')
console.log(user.name)   // 直接就是业务数据

类比:Spring 里写 Controller,@RestController 帮你把返回值序列化成 JSON,你不用手动 new ResponseEntity<>(...)。这里是反过来——让调用方直接拿到数据,不用手动剥响应体。

要点 2:为什么业务错误也要 reject

很多人图省事,业务错误只弹 toast 就 return。问题是:调用方 await http.post(...) 之后的代码还会继续执行,以为请求成功了。

reject 让调用方的 try/catch 能拦住后续逻辑:

try {
  await userApi.changePassword(old, new_)
  router.push('/login')    // ✅ 只有真正成功才跳
} catch {
  // 错误提示拦截器已经弹了,这里不用再做什么
}

4. TypeScript:让返回值有类型

拦截器里 return body.data 之后,Axios 自带的 AxiosResponse<T> 类型就不准了——实际返回的是 T,但 TS 还以为是 AxiosResponse<T>

方案:封一层带类型的 request 函数。

// src/utils/http.ts
import type { AxiosRequestConfig } from 'axios'

// 注意:Promise<T> 而不是 Promise<AxiosResponse<T>>
// 因为拦截器已经把壳剥掉了
export function request<T = unknown>(config: AxiosRequestConfig): Promise<T> {
  return http.request(config) as Promise<T>
}

// 常用方法快捷方式
export const get = <T = unknown>(url: string, config?: AxiosRequestConfig) =>
  request<T>({ ...config, method: 'GET', url })

export const post = <T = unknown, D = unknown>(
  url: string,
  data?: D,
  config?: AxiosRequestConfig,
) => request<T>({ ...config, method: 'POST', url, data })

调用

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

const user = await get<User>('/user/me')
//    ^? User        —— IDE 补全起飞

5. 按模块拆 API:类比 Feign

Feign 的做法是按"下游服务"拆 @FeignClient。前端对应:按"后端模块"拆 src/api/*.ts

// src/api/user.ts
import { get, post } from '@/utils/http'
import type { User, LoginParams } from '@/types/user'

export const userApi = {
  login: (params: LoginParams) => post<{ token: string }>('/auth/login', params),
  getCurrent: () => get<User>('/user/me'),
  logout: () => post('/auth/logout'),
}
// src/api/note.ts
import { get } from '@/utils/http'
import type { Note } from '@/types/note'

export const noteApi = {
  list: () => get<Note[]>('/notes'),
  detail: (slug: string) => get<Note>(`/notes/${slug}`),
}

组件里

<script setup lang="ts">
import { userApi } from '@/api/user'

const user = await userApi.getCurrent()
</script>

好处


6. 请求取消:组件卸载别再回写状态

用户点"搜索",500 毫秒后又点一次——第一个请求的响应回来时,组件可能已经卸载,回写 state 会报 warning,甚至污染下一个页面的数据。

现代写法:AbortController(和 fetch 一样)

import { onBeforeUnmount, ref } from 'vue'

const controller = new AbortController()

const notes = ref<Note[]>([])
noteApi.list({ signal: controller.signal }).then((data) => {
  notes.value = data
})

onBeforeUnmount(() => {
  controller.abort()   // 卸载时取消所有挂起的请求
})

注意 noteApi.list 要接受 config 透传。上面第 5 节的示例为了简洁没带,实际项目里把 (config?) 加上。

Java 对照WebClient.timeout(...) / 响应式流取消。

搜索防抖:取消前一次

let controller: AbortController | null = null

async function search(keyword: string) {
  controller?.abort()           // 取消上一次
  controller = new AbortController()

  try {
    return await noteApi.search({ keyword, signal: controller.signal })
  } catch (e) {
    if (axios.isCancel(e)) return    // 被我们主动取消,忽略
    throw e
  }
}

要点axios.isCancel(err) 判断是不是"我们主动取消的",不要把它当错误上报。


7. 并发:Promise.all 就够

Axios 有个 axios.all,但其实就是 Promise.all 的别名。直接用原生:

const [user, notes] = await Promise.all([
  userApi.getCurrent(),
  noteApi.list(),
])

想"任一成功即可"Promise.race 想"全部跑完,不管成败"Promise.allSettled


8. 文件上传与进度

export function uploadAvatar(file: File, onProgress?: (percent: number) => void) {
  const form = new FormData()
  form.append('file', file)

  return post<{ url: string }>('/upload/avatar', form, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (e) => {
      if (e.total) onProgress?.(Math.round((e.loaded * 100) / e.total))
    },
  })
}

注意FormData 会让浏览器自动加 boundary,但我们在创建实例时强制了 Content-Type: application/json。对上传接口要单独覆盖这个 Header(或者删掉)。


9. 在 in4vue 里怎么落地

当前产品还是纯静态 + Edge Function,短期内没有传统后端,但 AI 问答要调 Edge 代理,逻辑就是 HTTP 请求。

// src/utils/http.ts(简版:给 AI 用)
import axios from 'axios'

export const aiHttp = axios.create({
  baseURL: import.meta.env.VITE_AI_PROXY_URL ?? '/edge',
  timeout: 30_000,   // AI 流式可能慢,放宽到 30 秒
})

aiHttp.interceptors.response.use(
  (res) => res.data,
  (err) => {
    if (err.code === 'ECONNABORTED') throw new Error('AI 响应超时,请重试')
    if (err.response?.status === 429) throw new Error('请求太快了,缓一下')
    throw err
  },
)
// src/api/ai.ts
import { aiHttp } from '@/utils/http'

export interface AiAskResp {
  answer: string
  tokens: number
}

export const aiApi = {
  ask: (question: string, context?: string) =>
    aiHttp.post<AiAskResp, AiAskResp>('/ask', { question, context }),
}

注意那个奇怪的 <AiAskResp, AiAskResp>:Axios 的 post 泛型签名是 post<T, R = AxiosResponse<T>>,因为我们在拦截器里把响应体剥成 T 了,所以要显式把 R 也改成 T。这个 API 封装的副作用以后会反复遇到,记一下就行。

后续接入 Spring Boot 用户系统时:再建一个 http 实例走 /api,这两个实例各管各的,互不影响。


10. 常见坑

坑 1:拦截器里抛错没加 Promise.reject

http.interceptors.response.use((res) => {
  if (res.data.code !== 0) {
    throw new Error(res.data.message)   // 这行抛出来的确会被 catch 到
  }
  return res.data.data
})

这种其实也 OK,axios 会自动转 reject。但显式 return Promise.reject(...) 更清楚,推荐统一风格。

坑 2:dataparams 别搞混

// ❌ GET 请求写 data,后端收不到
get('/notes', { data: { keyword: 'vue' } })

// ✅
get('/notes', { params: { keyword: 'vue' } })

这个错误每个前端至少犯过一次。

坑 3:表单提交要不要 qs.stringify

application/x-www-form-urlencoded 格式的接口,直接传 { a: 1, b: 2 } axios 会当 JSON 发。要么:

import qs from 'qs'
post('/x', qs.stringify({ a: 1, b: 2 }), {
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})

要么用 URLSearchParams(浏览器内置,不用装 qs):

const params = new URLSearchParams({ a: '1', b: '2' })
post('/x', params)   // axios 自动识别,会加对 Content-Type

多数新项目用 JSON 就够,这个坑主要出现在对接老系统时。

坑 4:循环依赖——routerhttp 互相 import

响应拦截器里 router.push('/login'),而 router 文件又 import { http } 做路由守卫里的接口调用。启动直接报 undefined is not a function

解法:延迟 import。

http.interceptors.response.use(undefined, async (error) => {
  if (error.response?.status === 401) {
    const { default: router } = await import('@/router')
    router.push('/login')
  }
  return Promise.reject(error)
})

速查表

// 实例
const http = axios.create({ baseURL, timeout, headers })

// 请求拦截器
http.interceptors.request.use(
  (config) => { /* 加 token */; return config },
  (err) => Promise.reject(err),
)

// 响应拦截器
http.interceptors.response.use(
  (res) => res.data.data,        // 剥壳
  (err) => { /* 全局错误 */; return Promise.reject(err) },
)

// 类型安全
const user = await get<User>('/user/me')

// 取消
const ctrl = new AbortController()
get('/xxx', { signal: ctrl.signal })
ctrl.abort()

// 并发
const [a, b] = await Promise.all([apiA(), apiB()])

小练习

  1. 新建 src/utils/http.ts,创建一个 aiHttp 实例指向 /edge
  2. 加响应拦截器:成功剥壳、429 提示 "太快了"、超时提示 "超时"
  3. 新建 src/api/ai.ts,导出 aiApi.ask(question)
  4. 在某个页面调一下(暂时没有真实 Edge 代理,可以用 httpbin.org/post 临时替代,观察拦截器行为)
  5. 思考:aiApi.askuseAiChatStore.send(上一篇 Pinia 笔记里提过)该谁调谁?

答案:store 的 action 调 api 函数。api 负责"一次 HTTP 请求怎么发",store 负责"整段业务流程(打 loading、追加消息、错误处理)"。分层对上了 Controller → Service → RestTemplate 的结构。


延伸阅读