经典 axios 拦截器 return res.data 为什么是反模式?——聊聊前端接口层的规范写法
起因:看到一篇讲"删光项目里的 try-catch"的文章,用响应拦截器
return res.data统一剥数据。评论区有条高赞吐槽:"经典 axios 拦截器直接 return res.data 破坏返回类型,看到这就已经不用看了。"
这条评论说的到底是什么?接口层到底该怎么写才算规范?这篇文章一次讲清楚。
一、那条评论在说什么
先看 axios 对外承诺的类型契约:
request.get<T>(url): Promise<AxiosResponse<T>>
// AxiosResponse<T> = { data: T, status: number, headers: any, ... }
也就是说,TS 认为 await request.get(url) 拿到的是一个完整的响应对象,要取业务数据得写 res.data。
而很多人为了省事,在响应拦截器里直接做"剥壳":
service.interceptors.response.use((response) => {
const res = response.data
if (res.code !== 0) {
ElMessage.error(res.message)
return Promise.reject(new Error(res.message))
}
return res.data // ⚠️ 问题就出在这一行
})
拦截器把返回值换成了剥掉信封的裸业务数据。于是运行时和 TS 类型之间出现了断裂:
- TS 说的:你拿到的是
AxiosResponse,数据在res.data里 - 运行时实际:你拿到的已经是业务数据本身,再取
res.data就是undefined
这个偷梁换柱发生在 TS 完全看不见的地方——拦截器回调的签名是 (response: AxiosResponse) => any,那个 any 意味着拦截器返回什么,类型系统一概不知。
用图对比一下两种写法在类型链路上的差别:
拦截器 return res.data | typed wrapper 显式解包 | |
|---|---|---|
| 运行时实际返回 | 裸的 T | 裸的 T |
| TS 认为的返回 | AxiosResponse<T> ✕ | Promise<T> ✓ |
| 泛型推导 | 失效,需手动断言 | 全自动推导 |
二、类型失真的实际后果
这不是强迫症问题,是实打实的坑:
- 每个 API 函数的声明类型都是谎言。写着
Promise<AxiosResponse>,实际返回T。类型存在的意义就是描述运行时,说谎的类型比没有类型更糟。 - 泛型彻底失效。
request.get<User>(url)里的User指导不了任何推导,调用方只能靠手动as断言保平安。 - 静默运行时错误。业务代码里写
res.data访问属性,编译器不报错(类型上说得通),运行时拿到undefined再往下访问直接炸——这类 bug 恰恰是编译期检查本该拦住的。 - 封装层层传染。上层再包一层
safeRequest之类的工具,全都要跟着手动传泛型、手动断言,类型债越欠越多。
一个很有说服力的旁证:那篇文章自己的 "TypeScript 支持" 段落,代码里被迫写成了 safeRequest<User>(userApi.getUser(1))——需要手动标注 <User>。如果 getUser 的类型是真实的,T 应该自动推导出来,根本不用标。必须手动传泛型,就是类型链路断裂的直接症状。
所以评论者"看到这就不用看了"逻辑很完整:类型地基是歪的,后面的封装写得再花哨也是在歪地基上盖楼。
三、规范写法:分层 + 谁解包谁声明类型
核心原则一句话:谁改变返回值,谁负责把类型说清楚。
标准的接口层分四层,各管各的事:
1. 统一响应信封类型
// src/types/api.ts
export interface ApiResponse<T> {
code: number
message: string
data: T
}
2. 拦截器:只管横切面,不动返回结构
// src/utils/request.ts
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
service.interceptors.request.use((config) => {
const token = getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 响应拦截器只处理"不该到业务层"的错误:401 踢登录、网络异常等
service.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401) {
redirectToLogin()
}
return Promise.reject(error)
})
注意:拦截器不 return 业务数据。拦截器在类型系统里是"隐形"的,在这里改返回值,必然造成类型失真。
3. typed wrapper:解包发生在这里,且类型说真话
// src/utils/http.ts
async function http<T>(config: AxiosRequestConfig): Promise<T> {
// 先告诉 TS:响应信封长这样
const res = await service.request<ApiResponse<T>>(config)
// 业务错误(code !== 0)在这里收敛处理
if (res.data.code !== 0) {
ElMessage.error(res.data.message)
throw new Error(res.data.message)
}
// 从 ApiResponse<T> 显式解出 T —— 类型全程一致
return res.data.data
}
关键在 service.request<ApiResponse<T>>(config) 这一行:先用泛型告诉 TS 响应的真实形状,再显式解包。解包动作被一个带真实类型签名的函数承认了,类型链路全程闭合。
4. API 层:薄薄一层,只放声明
// src/api/user.ts
export const userApi = {
getUser: (id: number) => http<User>({ url: `/user/${id}`, method: 'GET' }),
updateUser: (data: Partial<User>) => http<void>({ url: '/user', method: 'PUT', data }),
}
5. 业务层:零标注、零断言
const user = await userApi.getUser(1)
// 类型就是 User,IDE 补全直接给 .name / .phone
// 如果哪天后端改了字段,这里编译期就能发现
分层职责速查表
| 层 | 职责 | 禁止事项 |
|---|---|---|
| 拦截器 | token 注入、401/超时等全局错误 | 返回业务数据(改返回结构) |
http<T>() | 解包信封、业务错误收敛、类型声明 | 无 |
api/* | 接口清单:URL + 方法 + 类型 | 写业务逻辑 |
| 业务层 | 拿数据、渲染 | 手动断言、手动传泛型 |
四、如果你就是想要"拦截器剥好"的爽快感
有的项目就是喜欢拦截器直接剥好、调用方拿裸数据的简洁。可以,但要自己重新声明一份与运行时一致的实例类型:
interface HttpClient {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>
}
// request.ts 导出时用 as unknown as HttpClient 收口
export const request = service as unknown as HttpClient
这样至少 TS 和运行时对上了号,调用方 request.get<User>(url) 拿到的类型是真实的 Promise<User>。代价是丢掉了 AxiosResponse 里的 status/headers 等信息——真需要时再单独暴露一个 requestRaw 即可。
但说实话,这属于打补丁。要么牺牲 AxiosResponse 信息,要么维护两套实例,不如第三节的分层干净。
五、顺带评价那篇文章的 safeRequest
safeRequest 的 [err, data] 元组模式本身没问题,这是 Go 风格的错误处理,配合 TS 元组类型 Promise<[Error | null, T | null]> 是自洽的,适合"任何请求失败都不能炸掉页面"的场景:
export async function safeRequest<T>(
promise: Promise<T>
): Promise<[Error | null, T | null]> {
try {
const data = await promise
return [null, data]
} catch (err) {
return [err as Error, null]
}
}
但它的正确定位是调用侧的容错糖,应该建立在类型正确的 http<T>() 之上,而不是用来掩盖解包类型的断裂。那篇文章恰好反着来:先在拦截器里把类型搞坏,再在 safeRequest 里要求用户手动补泛型——用第二个封装去擦第一个封装的屁股。
顺带一提:如果 http<T>() 类型链路是通的,safeRequest(userApi.getUser(1)) 根本不需要手写 <User>,T 会自动推导。
六、总结
一条规则贯穿到底:类型必须反映运行时。
接口层规范 = 四句话:
- 拦截器管横切面——token、401、网络错误,不碰返回结构
- typed wrapper 管解包——
request<ApiResponse<T>>先声明信封,再显式解出T - api 目录只放声明——URL + 方法 + 类型,可枚举、可定位
- 业务层拿到的类型永远等于运行时实际值——泛型自动推导,零
as断言
判断一个项目的请求层写得好不好,有个一秒钟的土办法:看业务代码里是不是满屏的 as any 和手动泛型标注。是的话,八成就是拦截器里那句 return res.data 干的。