经典 axios 拦截器 `return res.data` 为什么是反模式?——聊聊前端接口层的规范写法

0 阅读5分钟

经典 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.datatyped wrapper 显式解包
运行时实际返回裸的 T裸的 T
TS 认为的返回AxiosResponse<T>Promise<T>
泛型推导失效,需手动断言全自动推导

二、类型失真的实际后果

这不是强迫症问题,是实打实的坑:

  1. 每个 API 函数的声明类型都是谎言。写着 Promise<AxiosResponse>,实际返回 T。类型存在的意义就是描述运行时,说谎的类型比没有类型更糟。
  2. 泛型彻底失效request.get<User>(url) 里的 User 指导不了任何推导,调用方只能靠手动 as 断言保平安。
  3. 静默运行时错误。业务代码里写 res.data 访问属性,编译器不报错(类型上说得通),运行时拿到 undefined 再往下访问直接炸——这类 bug 恰恰是编译期检查本该拦住的。
  4. 封装层层传染。上层再包一层 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 会自动推导。

六、总结

一条规则贯穿到底:类型必须反映运行时。

接口层规范 = 四句话:

  1. 拦截器管横切面——token、401、网络错误,不碰返回结构
  2. typed wrapper 管解包——request<ApiResponse<T>> 先声明信封,再显式解出 T
  3. api 目录只放声明——URL + 方法 + 类型,可枚举、可定位
  4. 业务层拿到的类型永远等于运行时实际值——泛型自动推导,零 as 断言

判断一个项目的请求层写得好不好,有个一秒钟的土办法:看业务代码里是不是满屏的 as any 和手动泛型标注。是的话,八成就是拦截器里那句 return res.data 干的。