Axios 封装:前端的 RestTemplate + 拦截器
为什么要封装,不直接用 axios.get()
直接用 axios.get('/api/xxx') 能跑,但项目大了会撞上这几件事:
- 每次都要手写
baseURL、超时、Authorization头 - 后端统一用
{ code, data, message }包了一层,每个调用都要res.data.data剥两层 - Token 过期要全局跳登录,总不能每个页面写一遍
- TS 里返回类型总是
any,IDE 补全全废
封装的本质:把"每个请求都一样的事"下沉到一个地方,页面里只关心业务数据。
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.create()返回一个独立实例,拦截器、默认配置互不干扰- 多个后端就多个实例:
http、aiHttp、fileUploadHttp,别共用 baseURL走环境变量,开发 / 预发 / 生产自然切换(类比application-dev.yml)
反例:全项目都用全局 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。
常见能做的事:
- 注入 Authorization / traceId
- GET 请求自动加
_t=${Date.now()}防缓存 - 根据当前语言加
Accept-Language - 提交前把 camelCase 转成 snake_case(如果后端是 Python 风格)
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>
好处:
- 接口改字段时,
Ctrl+点跳到定义,一处改完所有调用方都报错 - 统一的
{api}/{method}命名,读代码不用猜 - Mock / 测试时可以整模块替换
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:data 和 params 别搞混
GET的 query string:paramsPOST/PUT的请求体:data
// ❌ 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:循环依赖——router 和 http 互相 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()])
小练习
- 新建
src/utils/http.ts,创建一个aiHttp实例指向/edge - 加响应拦截器:成功剥壳、429 提示 "太快了"、超时提示 "超时"
- 新建
src/api/ai.ts,导出aiApi.ask(question) - 在某个页面调一下(暂时没有真实 Edge 代理,可以用
httpbin.org/post临时替代,观察拦截器行为) - 思考:
aiApi.ask和useAiChatStore.send(上一篇 Pinia 笔记里提过)该谁调谁?
答案:store 的 action 调 api 函数。api 负责"一次 HTTP 请求怎么发",store 负责"整段业务流程(打 loading、追加消息、错误处理)"。分层对上了 Controller → Service → RestTemplate 的结构。