1. 为什么在项目里集成 axios,而不是直接裸用 fetch
这一期开发日记,轮到网络层了。项目的前端界面已经搭得七七八八,组件拆分、状态管理都跑通了,但页面上的数据还全是写死的 mock。从这一期开始,要把真实的后端接口接进来,而接接口的第一步,就是先把 HTTP 请求客户端集成好。
axios 是我在这个项目里选定的方案。说实话,这是一个不太需要纠结的选型——它几乎成了前端项目里发请求的默认答案。但“默认答案”不等于“拿来就用”,这期日记我会把从安装到封装再到踩坑的完整过程都记录下来,重点讲清楚为什么这么封装、拦截器到底解决了什么问题、以及几个实际开发中非常容易翻车的细节。如果你也在做一个需要对接后端接口的前端项目,这一期应该能帮你少走不少弯路。
先回答一个经常被问的问题:为什么不用浏览器自带的 fetch?
| 对比维度 | fetch | axios |
|---|---|---|
| 拦截器 | 无原生支持,需要自己包装 | 请求/响应拦截器开箱即用 |
| 请求取消 | 需要配合 AbortController,写法繁琐 | 原生支持 CancelToken 和 AbortSignal |
| 超时设置 | 需要自己封装 AbortController | 直接配置 timeout 字段 |
| 错误处理 | HTTP 错误需要手动判断 response.ok | 统一走 catch,错误信息更规范 |
| 请求/响应数据转换 | 手动 JSON.stringify 和 res.json() | 自动序列化与反序列化 |
| 浏览器兼容 | 需要 polyfill | 底层走 XMLHttpRequest,兼容性更好 |
fetch 本身并不差,但 axios 做了一层非常成熟的封装,把日常开发里高频出现的需求都提前解决了。尤其是在处理拦截器、统一错误提示、取消请求这些场景时,能省下大量重复代码。对于一个要长期迭代的项目来说,选 axios 意味着网络层的基础设施可以快速稳定下来,后续只需要不断往封装里加业务逻辑就行。
说句实在话,在真正动手写封装代码之前,我对 axios 的理解也只停留在“一个发请求的库”这个层面。等真正把它集成进项目才发现,拦截器设计得好不好,直接决定后端返回错误时前端要写多少重复的弹窗提示和跳转逻辑。这一期的重点,就是把这块内容掰开揉碎讲清楚。
2. 集成前的环境准备与基础安装
2.1 安装依赖与项目结构说明
项目当前的技术栈是 Vue 3 + Vite + Pinia,包管理器用的是 pnpm。axios 的安装非常简单,一条命令搞定:
pnpm add axios@^1.6.0我之所以显式指定版本,是因为 axios 1.x 和 0.x 在 API 上有细微差异,虽然大部分用法兼容,但遇到问题去查资料时,版本不统一会很痛苦。建议新项目直接用 1.x,老项目保持原有大版本,不要混升。
安装完成后,在 src 目录下新建了一个 utils 文件夹,准备专门放请求相关的模块。这个项目里我采用了业界比较常见的分层方式:
src/ ├── api/ │ ├── modules/ │ │ ├── user.js │ │ └── article.js │ └── index.js ├── utils/ │ └── request.js- utils/request.js:网络层核心,负责创建 axios 实例、配置拦截器、导出统一的请求方法
- api/modules/:按照业务模块划分接口,每个文件只放某一类接口的调用函数
- api/index.js:统一导出所有模块,方便业务代码引用
这种结构的好处是:业务页面永远不直接接触 axios,只调用 api/modules 里封装好的函数。以后后端接口地址变了,只需要改 api 层,页面代码完全不用动。模块之间职责清晰,排查问题时也能快速定位。
2.2 环境变量与基础配置
axios 实例创建时,有几个配置项是需要认真设置的:
- baseURL:所有请求的公共前缀,应该从环境变量读取,而不是写死
- timeout:请求超时时间,需要根据业务场景权衡
- withCredentials:是否携带跨域 Cookie
Vite 项目天然支持环境变量,我在项目根目录创建了三个文件:
.env.development .env.production .env.test每个文件里都只放一个核心变量:
# .env.development VITE_API_BASE_URL=/api# .env.production VITE_API_BASE_URL=https://api.example.com开发环境用 /api 这个相对路径,配合 Vite 的代理能力把请求转发到后端服务,这样可以在本地开发时绕开跨域问题。生产环境则直接指向真实的 API 域名。这个区分非常重要——如果开发时直接写后端的完整地址,几乎必然会撞上跨域报错,后面 4.1 节会细说。
3. 核心封装:从裸调用到统一请求层
3.1 创建 axios 实例与环境变量接入
很多项目刚开始用 axios 时,都是哪里需要就在哪里import axios from 'axios'然后直接发请求。这种写法在项目小的时候没毛病,但一旦接口变多,问题就来了:
- 每个页面都要重复设置 token、处理错误、考虑超时
- 后端改了错误码规范,几十个文件要跟着改
- 无法统一统计接口调用情况,排查问题全靠手动加日志
项目里封装请求层,本质上是把这堆重复劳动提前做好,让业务代码专注于“发什么请求、拿到数据后做什么”,而“怎么发、发失败了怎么处理”全部由请求层兜底。
我创建的 utils/request.js 内容如下:
import axios from 'axios' // 1. 创建 axios 实例,应用公共配置 const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, withCredentials: false }) // 2. 请求拦截器:在请求发出去之前做一些统一处理 service.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => { return Promise.reject(error) } ) // 3. 响应拦截器:拿到响应后统一处理 service.interceptors.response.use( (response) => { return response.data }, (error) => { return Promise.reject(error) } ) export default service这段代码看起来简单,但每个环节背后都有讲究。先看 baseURL:它从import.meta.env.VITE_API_BASE_URL读取,所以不同的构建环境会自动加载不同的.env文件,开发、测试、生产环境请求的地址不同,但代码不需要改一行。
timeout 设置的是 10 秒。这个数值不是拍脑袋定的,而是参照后端接口的 P95 响应时间(也就是 95% 的请求都能在 10 秒内返回)来定的。如果后端接口逻辑比较复杂,可以调大到 15 秒或 20 秒,但要小心:超时时间太长,用户会以为页面卡死了;太短,则会出现大量误报的请求失败。建议根据实际接口表现动态调整。
响应拦截器里直接return response.data,这一步的实际意义非常大。如果不做这个处理,业务代码里拿到的永远是完整的 AxiosResponse 对象,代码要写成const data = res.data.data,裹了好几层,不优雅。拦截器直接剥掉外层,让后续代码拿到的是“后端真正返回的数据”,符合直觉,也减少重复劳动。
3.2 请求拦截器:让每个请求自动携带认证信息
请求拦截器的核心职责,是在请求发出前统一做“预处理”。最常见的场景就是加 token。
在项目里,用户登录成功后,后端会返回一个 JWT 格式的 token,前端把它存到 localStorage 里。如果没有请求拦截器,每个需要登录态的接口调用都要手动写:
const res = await axios.get('/api/articles', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } })这样一个接口写一遍,整个项目就有几十处重复代码。更麻烦的是,万一某一天后端要求把 token 从 Header 改成 Cookie,或者从 Bearer 改成别的格式,所有页面都要跟着改。
当连锁反应出现时才会意识到统一封装的价值:请求拦截器相当于一个“关口”,所有请求都会从这里经过,想给请求头加公共字段、想给 GET 参数加上时间戳防止缓存,只需要在这里改一处,全局生效。
service.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } // 统一注入客户端标识 config.headers['X-Client-Type'] = 'web' // GET 请求默认加时间戳,避免浏览器缓存 if (config.method === 'get') { config.params = { ...config.params, _t: Date.now() } } return config }, (error) => { return Promise.reject(error) } )加X-Client-Type这个 Header,是为了方便后端识别请求来源。如果项目同时有 Web 端、小程序端、移动端,后端经常会根据这个字段做日志区分或者权限控制,前端统一在拦截器里注入,是最省事的方式。
给 GET 请求加时间戳参数,是我实际开发中踩过坑之后加上的。曾经有一次,后端更新了数据,但前端因为浏览器缓存一直拿到旧数据,排查了半天才发现是 HTTP 缓存在作祟。加上_t=Date.now()之后,每次请求的 URL 都不同,缓存问题迎刃而解。
3.3 响应拦截器:统一处理 HTTP 错误与业务错误
响应拦截器是 axios 集成的重头戏,也是体现工程能力的地方。网络请求的错误分两种:HTTP 层错误(404、500 等)和业务层错误(后端返回 code = 1001 表示未登录)。这两种错误如果不区分处理,业务代码会变得非常混乱。
我先定义了业务状态的约定。假设后端返回的数据格式统一为:
{ "code": 0, "message": "success", "data": { } }code 为 0 表示成功,非 0 表示业务失败。基于这个约定,响应拦截器可以这样写:
service.interceptors.response.use( (response) => { const res = response.data // 二进制数据直接放行 if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') { return response } // 约定:code === 0 表示业务成功 if (res.code === 0) { return res.data } // 业务错误:根据 code 统一弹提示 if (res.code === 401) { handleTokenExpired() } else if (res.code === 403) { ElMessage.error('没有权限访问该资源') } else { ElMessage.error(res.message || '请求失败') } return Promise.reject(new Error(res.message || 'Request Error')) }, (error) => { // HTTP 层错误处理 if (error.response) { const status = error.response.status switch (status) { case 401: handleTokenExpired() break case 404: ElMessage.error('请求的资源不存在') break case 500: ElMessage.error('服务器内部错误') break default: ElMessage.error(`请求错误:${status}`) } } else if (error.code === 'ECONNABORTED') { ElMessage.error('请求超时,请稍后重试') } else if (!window.navigator.onLine) { ElMessage.error('网络异常,请检查网络连接') } return Promise.reject(error) } )这个拦截器有四个设计细节值得说明:
第一,业务成功时直接返回res.data,而不是整个响应体。这样一来,业务代码调用接口时的体验是“直接拿到后端数据”,非常干净。
第二,业务错误和 HTTP 错误分开处理。HTTP 错误由 axios 内部的机制触发,通常会进入第二个回调;业务错误则在后端返回 200、但 code 非 0 时触发,进入第一个回调的逻辑判断。两边都能给出对应的用户提示。
第三,文件下载场景要提前放行。下载文件时后端返回的是二进制流,这类响应没有 JSON 结构,不能走业务码判断逻辑,所以通过responseType检测直接放行。
第四,401 的处理不能只弹个提示就完事。当 token 失效时,通常需要跳转登录页、清空本地状态、并且暂停后续请求。我封装了一个handleTokenExpired函数:
let isRedirecting = false function handleTokenExpired() { localStorage.removeItem('token') if (!isRedirecting) { isRedirecting = true ElMessage.warning('登录状态已过期,请重新登录') // 用一个固定的路由路径,避免和登录页逻辑冲突 window.location.href = '/login' } }isRedirecting这个标记很关键。如果多个接口同时返回 401,每个都会触发一次跳转,页面会重复刷新多次。加了标记后,只有第一个 401 响应会执行跳转逻辑,后续的自动忽略。
4. 实际开发中遇到的问题与排查实录
4.1 本地联调阶段的跨域问题
跨域是前端开发里最经典的坑,几乎每个做前后端分离的项目都会遇到。axios 本身只是一个 HTTP 客户端,浏览器跨域限制是它的天然阻碍。
项目开发模式下,前端跑在http://localhost:5173,后端接口在http://10.0.0.15:8080,两边端口不同,就构成了跨域请求。如果不做任何处理,浏览器会拦截请求——准确地说,请求其实发出去了,后端也处理了,但浏览器因为响应头里没有Access-Control-Allow-Origin,就把响应拦截了,控制台报一个 CORS 错误。
以前碰到这种情况,多数人的第一反应是去找后端改跨域配置。改后端当然也是一种方案,但前端其实可以完全绕开这个问题。Vite 的 dev server 提供了代理能力,只需要在vite.config.js里配置:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://10.0.0.15:8080', changeOrigin: true, // 后端接口没有 /api 前缀,就需要重写路径 rewrite: (path) => path.replace(/^\/api/, '') } } } })这个配置的核心思想是:浏览器请求的还是同一个源(localhost:5173),Vite 在中间充当了“中转站”,把请求转发到真正的后端地址。因为代理请求是服务器到服务器,不经过浏览器,所以不存在跨域限制。
这里有一个容易踩的细节:rewrite要不要写,取决于后端接口有没有/api前缀。如果后端原本就是/api/user/list,那就不需要 rewrite;如果后端是/user/list,就需要把/api去掉。我在项目中后端的路径本身包含了/api,所以代理配置里直接透传。
4.2 token 过期引发的 401 死循环
这是一个非常经典的 axios 集成问题,也是排查时间最长的一个。
现象:token 过期后,某个页面连续发起了多个请求,每个请求都返回 401,每个 401 都触发一次handleTokenExpired,页面开始疯狂跳转,甚至出现登录页和业务页来回跳的“抽搐”现象。
根因分析:第一个 401 到来时,代码里把 token 从 localStorage 清掉了,这是对的。但问题是,如果在清理 token 之前,已经有其他请求在排队,它们请求头里还带着旧的 token,于是也返回 401,也执行一遍清理和跳转逻辑。多个跳转叠加,就乱了。
解决方案就是我前面提到的isRedirecting标记。这个标记在跳转前判断一次,跳转后置为 true,等页面刷新后自然重置。这个方案好在简单可靠,不需要复杂的队列管理机制。
不过这只解决了“跳转乱”的问题。还有一个更为隐蔽的问题:如果 token 过期后,用户跳转到了登录页,而登录接口本身又需要携带 token,就会陷入“登录请求也返回 401”的怪圈。解决办法是:登录接口不携带 token,或者对登录接口单独做豁免。我在请求拦截器里加了判断:
if (token && config.url !== '/auth/login') { config.headers.Authorization = `Bearer ${token}` }这样既保证了业务接口的认证需求,又不会把登录接口卡死。这个细节很容易被忽略,但一旦出现,排查成本极高。
4.3 并发请求竞态与重复提交问题
项目的页面里有个筛选区域:用户切换筛选条件时,会触发一个列表查询。但接口响应时间不稳定,如果用户快速切换两次条件,就可能出现“先发的请求后返回”的情况,结果页面显示的是旧条件的数据。
这类问题在并发请求场景下非常典型。修复方案有两种:
方案一:请求竞态处理,用“最新的请求才生效”的策略:
let currentRequestId = 0 async function fetchList(params) { const requestId = ++currentRequestId const res = await api.getList(params) // 如果请求返回时不再是最新的请求,直接丢弃 if (requestId !== currentRequestId) return list.value = res.list }方案二:防抖 + 取消旧请求。axios 支持 AbortController 来取消请求。每发起一个新请求,先把上一次请求取消掉:
let controller = null async function search(params) { if (controller) { controller.abort() } controller = new AbortController() const res = await api.getList(params, { signal: controller.signal }) list.value = res.list }这两种方案各有适用场景。方案一适合“请求结果展示”的场景,实现简单,不需要额外依赖;方案二适合“请求本身占用后端资源”的场景,比如上传文件,取消后能立刻释放带宽。在实际项目中,我建议把这两种思路都掌握,按场景选择。
另外,提交按钮的二次点击问题也是这个范畴。比如用户提交表单时快速点了两次,就会产生两条重复的订单。我习惯在请求状态里加一个全局的请求锁:
let submitting = false async function submitForm(data) { if (submitting) return submitting = true try { await api.submit(data) ElMessage.success('提交成功') } finally { submitting = false } }这种做法虽然简单,但在防重复提交上非常有效。
4.4 TypeScript 项目里的类型问题
如果用 TypeScript 写前端,axios 在拦截器里的类型处理是一个绕不开的细节。
默认情况下,service.get('/api/user')返回的是AxiosResponse<any>类型。我在响应拦截器里直接return res.data之后,业务代码拿到的类型依然是AxiosResponse<any>,这并不是理想的结果——你明明想直接拿到业务数据,但类型系统还站在“整个响应体”的视角。
解决这个问题,一般有两种思路。
思路一:封装一个泛型函数接口:
interface ApiResponse<T = any> { code: number message: string data: T } export function get<T>(url: string, params?: object): Promise<T> { return service.get(url, { params }) }思路二:扩展 axios 模块的类型声明:
declare module 'axios' { export interface AxiosInstance { request<T = any>(config: AxiosRequestConfig): Promise<T> get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> } }这个类型声明只是根据业务约定覆盖了 axios 官方类型,让拦截器处理的响应类型透传到业务代码中。实际使用时这样写:
const userInfo = await api.getUserInfo<User>('/api/user') console.log(userInfo.name) // 这里就有类型提示了TypeScript 的类型问题不是 axios 集成的核心功能,但如果不处理,写着写着就会被any吞掉所有类型信息,那种在编辑器里因为类型报错而不敢动代码的体验,相信大家都体会过。我建议项目上线前就把这类基础类型问题解决掉,越拖越累。
4.5 文件下载与 Blob 流处理中的陷阱
项目里有一个导出数据的按钮:前端请求/api/export,后端返回一个 Excel 文件流,前端需要触发浏览器下载。
最初直接写了常规接口调用,发现下载下来的文件打不开,内容全是乱码。排查后发现是两个问题叠加:
第一个问题是响应拦截器照常做了res.data处理,把 Blob 对象给传丢了。下载请求必须绕过业务码判断逻辑,直接返回完整的response对象。这个在 3.3 节里已经提到,responseType为 blob 时放行。
第二个问题是请求时需要设置responseType: 'blob',这一步很容易漏。F12 看 Network,接口返回的明明是二进制流,但因为前端没声明期望的响应类型,axios 会尝试当作字符串解析,导致拿到大段乱码文本。
正确的下载封装长这样:
export async function downloadFile(url, params, filename) { const res = await service.get(url, { params, responseType: 'blob' }) // 从响应头里读取文件名,如果后端没给就用默认名 const disposition = res.headers['content-disposition'] let fileName = filename if (disposition) { const match = disposition.match(/filename=(.*)/) if (match) { fileName = decodeURIComponent(match[1]) } } const blob = new Blob([res.data]) const a = document.createElement('a') a.href = URL.createObjectURL(blob) a.download = fileName a.click() URL.revokeObjectURL(a.href) }下载文件时的文件名中文经常会出现 URL 编码问题,所以这里用decodeURIComponent处理了一下。这是我在实际项目中踩过的小坑。
5. 错误处理与异常规范速查
集成 axios 过程中,我把遇到的典型问题整理成了一张表格,方便团队同学排查时快速定位。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 控制台报 CORS error | 跨域请求被浏览器拦截 | 确认是否走了 Vite 代理;检查后端是否返回了正确的 CORS 响应头 |
| 请求超时 | timeout 设置过短或后端响应慢 | 先用后端日志确认接口耗时;再决定是否调整 timeout 值 |
| 401 反复跳转 | 多个并发请求同时感知到 token 失效 | 用跳转标记防止重复跳转;检查登录请求是否也被带上了 token |
| 下载文件乱码 | 缺少 responseType 或拦截器吞掉了 Blob | 检查请求是否设置了responseType: 'blob';确认拦截器对 Blob 放行 |
| 本地数据正常、线上报 404 | 环境变量 baseURL 配置错误 | 检查.env.production中VITE_API_BASE_URL是否配置正确 |
| 页面一直转圈,无报错 | 请求被浏览器缓存或后端无响应 | 检查 GET 请求是否加了时间戳参数,后台确认接口是否有日志输出 |
| 请求发送成功但页面拿不到数据 | 后端返回结构不符合约定 | 打印响应数据,确认 code 字段位置;和后端对齐数据结构 |
这张表虽然简单,但涵盖了我这次集成过程中 80% 的问题。遇到报错时不要急着改代码,先判断是 HTTP 层错误还是业务层错误,再按上面表格对应排查。
另外有一点经验:axios 的默认值非常值得花至少半小时通读一遍文档。很多人只知道get和post,但params和data的区别、transformRequest和transformResponse的执行时机、validateStatus的默认行为,这些都是坑点密集的地方。尤其容易搞混的是params是拼在 URL 上的查询参数,而data是请求题里的内容,写反了后端很容易解析不到参数。
6. 进一步封装:让请求层适配项目长期演进
集成 axios 这件事,只看“能发请求”这个层面,其实半天就能做完。能不能把网络层沉淀为项目的基础设施,取决于后续的封装深度。集成完成后,我又顺手做了三件事,推荐给同样在做长期项目的朋友参考。
6.1 按业务模块拆分 API,并统一导出
把接口按模块拆分文件,需要明确每个文件只做一件事:
// api/modules/user.js import service from '@/utils/request' export function login(data) { return service.post('/auth/login', data) } export function getUserInfo() { return service.get('/user/info') }// api/modules/article.js import service from '@/utils/request' export function getArticleList(params) { return service.get('/article/list', params) } export function createArticle(data) { return service.post('/article/create', data) }统一从 api/index.js 导出后,业务页面引入时会非常舒服:
import { login, getUserInfo } from '@/api'模块化的 API 层让接口的维护变得极其直观。找接口就像查字典一样,按业务模块去对应文件里找就行,不会出现几十个接口堆在一个文件里、翻半天找不到目标的情况。
6.2 封装业务可复用的请求流程
有些业务场景会在很多页面中出现。比如“分页获取列表”这个操作,几乎每个列表页都要写一遍:请求参数带上 page 和 pageSize、判断响应是否为空、处理 loading 状态。
这类代码虽然没有技术难度,但写多了非常烦躁。我在 api 层之上封装了一个通用方法:
// api/modules/list.js export async function fetchPaginatedList(apiFunc, params) { const res = await apiFunc(params) return { list: res.list || [], total: res.total || 0, page: params.page, pageSize: params.pageSize } }这样业务页面只需要关心展示,不需要重复处理数据结构。这个封装在列表页上的收益非常明显,几乎所有列表页的代码量都减少了三成。
6.3 错误上报与日志记录
生产环境里,用户说“页面坏了”,但开发同学在自己的电脑上复现不出来,这时候日志就变得至关重要。
在响应拦截器的错误分支里,加了一行简单的上报:
if (error.config && !error.config.silent) { reportError({ url: error.config.url, method: error.config.method, status: error.response?.status, message: error.message, time: new Date().toISOString() }) }silent配置项的作用是让某些不希望上报的接口(比如埋点上报、轮询接口)可以静默失败,避免日志被无意义的错误刷屏。这个思想来源于实际经验——曾经有一次轮询接口请求频繁失败,把日志表直接打满了。
日志上报的收益是随着时间累积的。一开始它只是“记录错误”,但当线上问题需要回溯时,这些日志就是排查的第一手证据。对于刚集成 axios 的项目,我建议至少把url、status、message、time这四个字段记录下来,已经足够覆盖大多数排查场景。
写在最后
这期开发日记从 axios 安装、实例创建、拦截器设计,讲到了跨域、401 死循环、并发竞态、文件下载等一堆实际问题。回头看来,axios 集成这件事本身不难,难的是在集成的过程中把网络层的边界和规范定清楚,让后续每个接口的联调都不需要重复走弯路。
我个人在集成完这套请求层之后最明显的感觉是:页面代码清爽了很多。以前写一个列表页,光请求逻辑就要十几行,还常常有状态判断漏掉;现在调用一个封装好的接口函数,传参数、拿数据、处理异常,三步就完事。这种体感上的变化,不是靠某一个技巧达成的,而是整个请求层设计合理之后自然形成的。
最后再分享一个小技巧:axios 集成完别急着写业务接口。先花几分钟用一段简单的测试脚本把所有拦截器的链路跑通——发一个成功请求、发一个带错误码的请求、发一个网络错误的请求,确认三种场景都会走到预设的处理分支,然后再开始正式写业务接口。这一步能帮你把集成的问题和业务的问题隔离开,排查起来会轻松得多。