先说结论:在一个正经的前端项目里,axios 必须再做一层封装,这不是过度设计,是刚需。我见过太多项目,每个页面直接 axios.get,token 从 localStorage 里取,错误处理用 try/catch 包一层就完事,loading 状态每个组件自己维护,等到后端一改返回结构,全站几十个页面跟着报错,改到怀疑人生。这篇文章把我这几年做 axios 封装沉淀下来的思路和代码完整放出来,从设计决策到核心实现,再到流式输出、多域名这些真实场景的适配,适合正在做项目基建、或者被重复请求代码折磨到想重构的团队参考。
1. 别再到处写 axios.get 了:封装层要解决的三个真实痛点
我接手过不少"能用但难维护"的项目,这类项目有一个共同特征:axios 以最原始的方式散落在业务代码里。表面上看每个请求都正常,实际上埋了不少雷。
1.1 痛点一:通用逻辑没有收敛,一个改动牵动全站
最常见的例子是登录态失效。需求很简单:后端接口返回 401,前端跳转登录页并清空用户信息。没做封装的项目怎么处理?在每个用到 axios 的页面里分别写:
// 业务代码 A try { const res = await axios.get('/api/user/info') // ... } catch (err) { if (err.response && err.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } } // 业务代码 B try { const res = await axios.post('/api/order/create', data) } catch (err) { if (err.response && err.response.status === 401) { localStorage.removeItem('token') window.location.href = '/login' } }两个页面逻辑一模一样,复制粘贴了十遍。后来要求从removeItem换成clear,或者跳转前加一个"登录已过期"的提示,就得全局搜索401字符串,挨个改。漏改一处,那边就会出现"明明没登录却还在旧页面停留"的 bug。这还只是登录态一项,再加上 token 注入、错误提示、接口耗时上报、重复请求拦截,每项都散落在各处,项目规模一大就是灾难。
1.2 痛点二:后端返回结构说变就变,前端毫无招架之力
很多团队的前后端接口约定是{ code, message, data }包一层。可实际开发中,总会有个别接口不按约定来:有的直接返回数组,有的用success代替code,有的错误信息塞在msg而不是message。没封装的时候,每个接口的调用方都要自己判断数据结构,比如:
// 接口 A:正常包裹 const res = await axios.get('/api/list') if (res.data.code === 200) { setList(res.data.data) } // 接口 B:直接返回数组 const res = await axios.get('/api/options') setList(res.data) // res.data 本身就是数组同一个组件里存在两种取值方式,新同事接手根本分不清。封装层的作用就是把"解包"这件事统一收口:正常情况下拦截器帮你把res.data.data取出来,个别特殊接口通过配置跳过解包。业务代码里永远只面对干净的数据,后端结构变了,改一处封装就行,页面不用动。
1.3 痛点三:重复代码挤占业务逻辑,可读性被严重稀释
一个没封装的请求方法里,可能混着 loading 开启关闭、错误消息弹出、登录态校验、token 拼接这几件事。业务逻辑只占一小段。比如:
const getDetail = async () => { setLoading(true) try { const token = localStorage.getItem('token') const res = await axios.get('/api/detail', { headers: { Authorization: `Bearer ${token}` } }) if (res.data.code === 401) { message.error('登录已过期') location.href = '/login' return } if (res.data.code !== 200) { message.error(res.data.message) return } setDetail(res.data.data) } catch (e) { message.error('网络异常') } finally { setLoading(false) } }这段代码里真正跟业务相关的只有setDetail(res.data.data)这一行,其他全是基础设施噪音。十个页面就要写十遍,而且每个页面的写法还略有出入:有人用Alert,有人用toast,有人在 catch 里判断 401,有人在业务里判断 401。做一层封装后,这个函数可以瘦身成:
const getDetail = async () => { const detail = await get('/api/detail') setDetail(detail) }loading 交给调用方按需开启,token 注入、登录态失效、错误提示全由封装层接管。代码量减少的不只是几行,而是整个团队的心智负担。
2. 封装前的设计决策:边界、扩展、类型三件事想清楚再动手
很多人一上来就写代码,封装到一半发现"这个拦截器里要不要处理 loading""特殊接口怎么跳过解包"都没想清楚,最后越写越乱。我的经验是,动手前先花半小时把边界定明白。
2.1 想清楚封装层的职责边界
一句话版本:请求层只做请求,不做业务判断。
登录态失效跳转算不算业务?我觉得算"基础能力",因为它和 token 注入是配套的,属于认证体系的统一逻辑。但"用户点击按钮后需要弹出什么提示"这种就算业务,封装层不要猜。
我的边界划分是这样的:
| 归属 | 具体内容 | 理由 |
|---|---|---|
| 封装层处理 | token 注入、超时设置、取消请求、响应解包、HTTP 状态码判断、401 跳登录、统一错误提示 | 所有接口通用,收敛后避免重复 |
| 调用方处理 | 业务状态码判断(如 code 10001 表示余额不足)、局部 loading、业务数据格式化 | 不同接口语义不同,封装层不该硬编码 |
| 可配置项 | 是否显示错误提示、是否跳过 token、是否禁止重复请求、是否跳过解包 | 允许个例通过配置绕过通用逻辑 |
比如有的项目喜欢在封装里判断code === 10086就跳出"活动未开始",我建议别这么做。这个逻辑可能这期活动用得上,下期活动就废了,写在封装层里就成了死代码,还得小心翼翼别影响其他接口。
2.2 想清楚未来半年的扩展方向
封装层不是写一次就完事的,它是项目的"地基",后面所有网络能力都会往这里加。我踩过的一个大坑是早期封装只考虑了常规 JSON 请求,结果后来项目要对接大模型的流式输出(SSE),要在 axios 基础上做实时渲染支持;再后来又遇到 uniapp H5 要指向两个不同域名。因为一开始没留扩展位,这两次都把封装层推翻重写了一遍。
所以设计时至少预留这么几个能力点:
- 取消请求:用
AbortController而不是老的cancelToken,因为新版 axios 已经弃用 cancelToken,而 abort 是 Web 标准能力。 - 自定义适配器:如果有一天要从 axios 换到 fetch,或者要在小程序环境跑,适配器层要能平滑替换。
- 扩展配置字段:除了 axios 自带的
config,增加自定义字段,比如skipAuth、silent、repeat这些业务相关的开关。 - 数据流模式:不只是 JSON 解包,还要支持文本流、二进制流,SSE 场景下能拿到原始 response 而不是被拦截器提前消费掉。
这些扩展点决定了封装层的骨架长什么样。我后面会逐个展示代码。
2.3 想清楚类型体系怎么搭
前端项目用到 TypeScript 的话,封装层的类型设计直接决定团队的使用体验。最理想的状态是业务代码里调用get<T>时能直接拿到T类型的返回值,而且code !== 200的情况在类型层面根本不会出现——因为拦截器已经把它拦掉了,函数要么返回T,要么抛异常。
我习惯定义这样几个类型:
// 后端统一返回结构 interface ApiResponse<T> { code: number message: string data: T } // 扩展配置,挂在 axios config 上的自定义字段 interface RequestOptions { skipAuth?: boolean // 跳过 token 注入 silent?: boolean // 不弹统一错误提示 skipRepeat?: boolean // 允许重复请求 raw?: boolean // 返回原始 response,不自动解包 } // 业务方法的返回类型:要么成功返回 T,要么抛异常 type ApiResult<T> = Promise<T>这里最容易翻车的地方是:很多人的request<T>函数返回类型写成了Promise<ApiResponse<T>>,结果业务代码里每处都要.data.data。既然做了封装,就要把类型也"解包"干净,让业务代码只跟T打交道。
提示:
raw选项是给流式请求、文件下载、上传进度这类场景准备的,因为它们的"返回数据"可能不是 JSON,统一解包反而会破坏原始响应。这个设计在后面 SSE 场景会真正派上用场。
3. 从零搭一个够用三年的 axios 封装核心
设计定了,开始动手。下面这个封装是我基于实践精简出来的版本,直接复制到项目里就能跑,但更建议你对照注释改一遍,理解每一行为什么存在。
3.1 创建实例并配置通用默认项
// request/index.ts import axios from 'axios' import type { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, withCredentials: false, })withCredentials建议显式声明。有些团队后端用了 Cookie 做会话保持,你这里不配置,浏览器跨域时不一定带 Cookie,排查起来非常隐蔽。另外baseURL一定要从环境变量读取,千万别写死。我在下一章会专门讲多域名场景,环境变量是这一切的基础。
如果项目是 uniapp 或者其他跨端框架,axios 可能不走浏览器的 XMLHttpRequest,这时候要留意环境的适配性。纯浏览器项目、H5 项目大多直接能用。
3.2 请求拦截器:token 注入、签名、时间戳
service.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 自定义配置从 config 里取 const options = (config as any).options as RequestOptions | undefined if (!options?.skipAuth) { const token = getToken() if (token) { config.headers.Authorization = `Bearer ${token}` } } // 防缓存:GET 请求加时间戳 if (config.method === 'get') { config.params = { _t: Date.now(), ...config.params, } } return config }, (error) => Promise.reject(error) )这里有两个容易被忽略的细节:
第一个,axios 的拦截器类型在请求拦截阶段是InternalAxiosRequestConfig,此时headers已经是一个AxiosHeaders实例,直接config.headers.Authorization = xxx没问题,但如果是老版本的 @types/axios,可能需要config.headers.set('Authorization', xxx),升级 axios 版本后要注意这个差异。
第二个,自定义配置options不建议直接塞在config上污染类型,可以用declare module扩展AxiosRequestConfig,这样 TypeScript 类型也干净:
declare module 'axios' { export interface AxiosRequestConfig { options?: RequestOptions } }3.3 响应拦截器:统一解包、错误码映射
这是封装层最核心的部分,也是业务代码体验好坏的关键。
service.interceptors.response.use( (response: AxiosResponse) => { const config = response.config const options = (config as any).options as RequestOptions | undefined // 需要原始响应体的场景:SSE、下载、二进制流 if (options?.raw) { return response } const res = response.data as ApiResponse<any> // 约定的成功码 if (res.code === 200) { return res.data } if (res.code === 401) { handleTokenExpired() } if (!options?.silent) { showErrorMessage(res.message || '请求失败') } return Promise.reject(new Error(res.message || `业务错误码: ${res.code}`)) }, (error) => { // 取消请求时不弹错误 if (axios.isCancel(error) || error.name === 'AbortError' || error.code === 'ERR_CANCELED') { return Promise.reject(error) } const config = error.config as any if (!config?.options?.silent) { showErrorMessage(error.message || '网络异常') } return Promise.reject(error) } )我特别强调取消请求的判断。axios 老版本配合cancelToken时,取消后会走到 error 分支;新版用AbortController,取消后 error 的code可能变成ERR_CANCELED或名字变成AbortError。如果你没在这些判断里放行,用户取消一个上传任务,前端会弹出"网络异常",体验非常糟糕。
handleTokenExpired里我做了个简单的锁防止多次跳转:
let isRedirecting = false function handleTokenExpired() { if (isRedirecting) return isRedirecting = true clearToken() window.location.href = '/login' }3.4 取消请求的正确姿势:AbortController 与重复请求拦截
前面提到AbortController,这里展开细说。新版 axios 支持把signal传给请求配置,这就是取消请求的标准通道:
let abortController: AbortController | null = null export function cancelRequest() { if (abortController) { abortController.abort() abortController = null } } // 需要支持取消的请求 export function getWithAbort(url: string, params?: object) { abortController = new AbortController() return request.get(url, { params, signal: abortController.signal, }) }这个能力在"搜索框防抖+取消旧请求"的场景很实用:用户输入关键词,发一个请求,如果上一个还没返回,直接 abort 掉,避免旧响应覆盖新结果。
在响应拦截器里已经处理了AbortError,所以取消时不会误弹错误提示。
重复请求拦截也在这个环节做。我维护一个pendingMap,以请求方法和 URL 拼接作为 key,如果同一 key 有进行中的请求,就直接把新请求 abort 掉或者复用之前的 Promise。这里只给一个简单的思路,真正实现时注意:只拦截"自动触发"的请求(比如鼠标连点),轮询类请求要加skipRepeat: true放行。
3.5 对外暴露的业务方法:get、post、put、delete
封装层最终给人的是几个直观方法:
export function get<T>(url: string, params?: object, options?: RequestOptions): Promise<T> { return service.get(url, { params, options }) as Promise<T> } export function post<T>(url: string, data?: object, options?: RequestOptions): Promise<T> { return service.post(url, data, { options }) as Promise<T> }这里有个类型细节:因为响应拦截器已经返回res.data,但 axios 的类型定义认为service.get返回Promise<AxiosResponse<T>>,所以需要做一次类型断言。更稳妥的做法是给 service 实例自定义类型,但实际开发中as Promise<T>已经够用,团队里约定好即可。
4. 从"轮询"到"流式":用封装的 AbortController 支撑 SSE 实时渲染
最近两年,大模型相关的 AI 应用越来越普及,前端最常遇到的新需求就是:把大模型通过流式接口吐出来的文本实时渲染到页面上。这个场景如果还用"请求完成后一次性处理"的思路,根本没法看到"打字机"效果。你要做的是边接收边渲染。
我最初接手这个需求时,第一时间想到的是用EventSource或者第三方 SSE 库。但很快发现项目里很多鉴权逻辑已经在 axios 封装里写好了,再引入一套 SSE 连接器,等于让请求体系分裂成两套。后来我尝试在 axios 封装层直接把流式能力纳入进来,效果很不错。
4.1 为什么 SSE 和普通请求不能共用同一套解包逻辑
常规请求的响应拦截器会自动取出res.data.data,但 SSE 请求返回的是text/event-stream格式,整个响应体是一个持续不断的文本流,不能用 JSON 解包。所以需要前面提到的raw: true配置:
export function fetchStream(url: string, params?: object, options?: RequestOptions) { return service.post(url, params, { options: { ...options, raw: true }, responseType: 'text', // 不设置的话,axios 默认尝试按 JSON 解析 }) as Promise<AxiosResponse> }responseType: 'text'是关键。如果不设置,浏览器拿到text/event-stream时,axios 内部按 JSON 解析会直接抛错。
4.2 用 async generator 把流式响应改造成可迭代结构
拿到原始 response 之后,不能直接给组件用。因为 fetch/axios 拿到的流是 ReadableStream,业务组件不可能每次都自己处理getReader()、TextDecoder这些细节。我封装了一个异步迭代函数:
async function* streamToAsyncIterable(response: AxiosResponse) { const reader = response.data.getReader() const decoder = new TextDecoder() let buffer = '' try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // SSE 数据以换行分隔,把完整的事件行解析出来 const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { const trimmed = line.trim() if (trimmed.startsWith('data:')) { const payload = trimmed.slice(5).trim() if (payload !== '[DONE]') { yield payload } } } } } finally { reader.releaseLock() } }这里有个容易漏的点:decoder.decode(value, { stream: true })必须传 stream 参数,否则多字节字符(尤其是中文)在流边界处可能被截断,导致乱码。我第一次实现的时候没传,AI 回答里的中文每几个字就出现一个乱码字符,排查了半天才发现是解码器状态没保持。
4.3 取消流式请求:为什么必须用 AbortController 而不只是 reader.cancel()
流式请求通常有个中断按钮。用户觉得 AI 回答太啰嗦,想让它停下来。这时候你可以调reader.cancel()关闭流,但更优雅的方式是直接 abort 底层的 HTTP 请求,彻底释放连接。
这就是之前封装里留好AbortController的价值:
export class StreamTask { private controller: AbortController | null = null start(url: string, params: object, onMessage: (text: string) => void): Promise<void> { this.controller = new AbortController() return new Promise(async (resolve, reject) => { try { const response = await service.post(url, params, { options: { raw: true }, responseType: 'text', signal: this.controller.signal, }) const decoder = new TextDecoder() const reader = response.data.getReader() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() || '' for (const line of lines) { const trimmed = line.trim() if (trimmed.startsWith('data:')) { const payload = trimmed.slice(5).trim() if (payload !== '[DONE]') { const data = JSON.parse(payload) onMessage(data?.content || '') } } } } resolve() } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') { // 用户主动取消,不算错误 resolve() return } reject(err) } finally { this.controller = null } }) } cancel() { this.controller?.abort() } }注意 catch 里的处理。用户主动 abort 会触发 AbortError,但这不是故障,不应该把异常抛给业务层。这个分支在响应拦截器里已经放过一次了,在流式处理的 catch 里还要再放过一次,因为它已经是业务 Promise 的层面了。
这个模式是我在真实项目里沉淀下来的,后来接 AI 对话、AI 写作、流式报表都直接复用,不需要再写第二套流处理逻辑。
5. uniapp H5 多域名场景:把域名解析从业务代码里剥出去
有个读者问过我一个场景:他们的应用是用 uniapp 写的 H5,业务接口和老系统接口存在于两个完全不同的域名,需要在封装层支持"某些请求走 A 域名、另一些请求走 B 域名"。他一开始的做法是在业务代码里写完整的 URL,代码里全是https://a.example.com/xxx、https://b.example.com/xxx,后来域名切换或新增环境,全部要重新打包。
其实 axios 封装天然支持多域名,只是很多人只会用单一的baseURL。我把方案拆成两步。
5.1 在扩展配置里增加 baseURL 覆盖字段
前面RequestOptions里加一个baseURL:
interface RequestOptions { // ... baseURL?: string }然后定义域名表:
const DOMAINS = { main: import.meta.env.VITE_API_BASE_URL, cdn: import.meta.env.VITE_CDN_URL, old: import.meta.env.VITE_OLD_API_URL, } as const export type DomainKey = keyof typeof DOMAINS请求拦截器里根据配置覆盖 baseURL:
// 请求拦截器里 if (options?.baseURL) { config.baseURL = options.baseURL }调用的时候:
const data = await get('/api/user/info', undefined, { baseURL: DOMAINS.old })核心设计思想是:业务代码不感知"哪个接口走哪个域名",它只需要传一个语义化的 key。域名变了,改环境变量;新增环境,重新构建都不用动代码。业务代码里出现的是DOMAINS.old这种语义化的名称,而不是 URL 正文。
5.2 对 uniapp H5 的额外提醒
uniapp H5 编译到浏览器环境时,如果你的页面部署在https://app.example.com/,但接口在https://api.example.com/,浏览器会发起跨域请求。如果后端没开 CORS,请求会被浏览器拦掉,但 uniapp 开发者工具里可能因为代理配置看起来一切正常,真机预览就挂。这跟 axios 封装关系不大,但团队排查时经常误以为是封装层的问题。建议在封装层暴露一个VITE_USE_PROXY之类的开关,开发环境走 vite 代理,生产环境用真实域名,这样本地调试不会被跨域困扰。
5.3 多域名下的 token 策略
多域名存在另一个容易被忽略的问题:token 是只给主域名用,还是几个域名都共用?如果老系统接口用独立的 token,你的注入逻辑就要区分。我在封装里加了第二个配置项:
if (options?.baseURL === DOMAINS.old) { const oldToken = getToken('old') if (oldToken) { config.headers.Authorization = `Bearer ${oldToken}` } }这个场景比想象中常见。老系统往往用一套独立的认证体系,硬塞主系统的 token 过去只会得到 401。像这样按 baseURL 区分 token 来源,既不用改老系统代码,也不影响新接口的认证。
6. 回顾:封装层最容易翻车的几个细节
最后把我这几年实际踩过、以及在帮别人 review 代码时见过的典型问题集中列一下。这些问题都不是"不报错",而是"表面正常,坑在后面"的类型。
6.1 请求拦截器里用了 async/await,但没有错误兜底
有些人的 token 是异步刷新的,于是在请求拦截器里写:
service.interceptors.request.use(async (config) => { const token = await refreshToken() config.headers.Authorization = `Bearer ${token}` return config })这个写法本身没问题,但refreshToken如果抛异常,整个请求链会直接中断,而且你没有任何提示。建议在拦截器外面包一层 try/catch,或者把 token 刷新逻辑放到请求层之外去管理,不要让拦截器承担太重的异步逻辑。
6.2 统一错误弹窗做成同步 setTimeout,导致组件卸载后 setState
响应拦截器失败后,有的团队会顺手全局弹一个 message。如果业务页面此时已经卸载,而这个 message 库的实现里又带了一些对 DOM 状态的回调,可能出现 React 的setState on unmounted component警告。选 message 库的时候多留个心眼,antd 的 message 已经处理了这种情况,但一些轻量库不一定。稳妥做法是:封装层只管console.error或者触发一个统一事件,让最顶层的 Provider 去渲染提示,不要在拦截器里直接操作 UI 组件。
6.3 泛型用得太粗糙,业务层拿到 any
我在前面强调过,封装层要把类型"解包"干净。实际操作里经常看到这样的代码:
export const get = (url: string) => service.get(url)返回值类型是Promise<AxiosResponse<any>>,业务层拿到的全是 any。这不是封装,这是脱裤子放屁。封装的一大价值就是类型内聚——业务代码应该因为封装而拿到更精确的类型,不是更模糊的类型。
6.4 把 loading 也写死在封装层
关于 loading,很多团队纠结要不要在封装层统一开启。我的建议是:不要全局开,而是在组件里局部控制。原因很简单:一个页面可能有三个并发请求,全局 loading 会把整个页面遮罩住,用户根本没法操作;局部 loading 只影响各自的按钮或区域,体验更可控。
封装层能做的,是暴露一个useLoadingRequest之类的 hook,把"请求 + loading + 错误处理"组合成一套声明式 API:
function useRequest<T>(fn: () => Promise<T>) { const [loading, setLoading] = useState(false) const run = useCallback(async () => { setLoading(true) try { return await fn() } finally { setLoading(false) } }, [fn]) return { loading, run } }这样把"开关 loading"这个动作从业务代码里剥离出来,但不把 loading 的渲染策略绑死。每个组件自己决定是显示按钮 loading 还是整页 loading。
6.5 错误信息里的技术噪音
后端返回的message可能是"系统繁忙"这种给用户看的,也可能是Cannot read property 'id' of undefined这种给开发看的。封装层把message直接弹出去,用户可能看到一串英文堆栈,非常劝退。建议加一个基础判断:请求成功但业务失败时,优先展示后端 message;HTTP 层错误且没有后端 message 时,根据 status code 映射一个友好提示:
const STATUS_MESSAGE = { 400: '请求参数错误', 401: '登录已过期,请重新登录', 403: '没有权限访问该资源', 404: '请求的资源不存在', 500: '服务器开小差了,稍后重试', 502: '网关错误,请稍后重试', 503: '服务暂时不可用', }这既不是过度封装,也不会让用户看到无意义的报错文本。
6.6 别忘了给流式请求的 abort 场景做类型守卫
我在第 4 章提到的是 DOMException 判断,但不同浏览器、不同版本下,fetch/axios 抛出的异常名可能是AbortError,也可能在 axios 里被包成CanceledError。判断条件建议多写几个分支,别只判断一个。这个细节我曾经在 Safari 上踩过,Chrome 里正常取消,Safari 里却弹了错误提示,就是因为err.name在不同内核里表现并不完全一致。
写到这里,你可能会觉得封装 axios 不过就是一个拦截器加几个方法,没什么技术含量。但真正拉开差距的,是面对真实业务场景时的取舍:什么时候该让封装层兜底,什么时候该把控制权还给业务层;类型是收敛还是发散;取消请求、流式响应、多域名这些边缘情况有没有提前预留位置。我在项目里沉淀下来的这套方案,前后经历过两个完整的业务周期,每次新增需求都能在不动主结构的前提下扩展,这比写出一段炫技的代码重要得多。如果你正打算重构项目里的请求层,不妨按这个思路先划分边界,再动手写拦截器,至少会让你少走不少弯路。