这是开发日记系列的第 12 篇。项目进入联调阶段,前后端接口开始正式对接,最迫切的事就是把 HTTP 请求真正集成进项目里。最终我选了 axios,并且在封装、拦截、错误处理上花了不少时间。集成完成后最大的感受是:axios 不是装个包调一下接口就完事,真正的复杂度全在统一处理上。今天的日记就照着我的实操顺序,把踩过的坑、改过的方案和最后稳定运行的封装方式都记下来,给正在做类似集成的朋友一个参考。
1. 项目现状与选型思路
1.1 为什么在这个阶段才接 axios
项目是一个某跨平台管理后台,前期主要用模拟数据把页面和状态管理跑通。页面多了之后,每个模块自己写一套请求逻辑,有的用原生 fetch,有的干脆先写成假函数。真正到了后端接口联调,问题一下子全冒出来:同样的鉴权逻辑复制粘贴好几份,后端改了状态码结构,所有页面都要跟着改,请求超时没人处理,接口报错也没统一提示。与其继续打补丁,不如直接拉一个成熟的 HTTP 客户端,把网络层统一起来。
这个阶段接 axios 其实是有好处的:页面代码没有被各种请求细节污染,前端的数据流已经基本成型,接入网络层只是把过去 mock 的位置替换成真实接口调用。如果项目是从零开始的,我更建议框架搭完就把 axios 集成进去,而不是等页面写完了再回头补。我这次属于后者,所以改动面稍大,但整体可控。
1.2 为什么选 axios 而不是原生 fetch
我不否认 fetch 很现代,但团队协作和长期维护看,axios 的成熟度更有优势。最直观的几个点:
- 拦截器机制:fetch 没有拦截器,想在每个请求里注入 token、统一处理错误码,必须手动包装,代码很难复用。
- 自动 JSON 转换:fetch 拿到响应后要先
res.json(),axios 可以直接拿到处理后的数据,多语言、多种返回格式都能自动处理。 - 请求取消:fetch 通过 AbortController 也能取消,但 axios 兼容性更好,还保留了旧版 CancelToken,团队里新手不容易写错。
- 并发请求:axios 有
axios.all,虽然用Promise.all完全可以平替,但 axios 的 API 更语义化。 - 环境兼容:浏览器和 Node 环境都能跑,方便以后做服务端渲染或者接口转发层。
这里要特别说一句:fetch 不是不能用,但一旦涉及统一鉴权、统一错误提示、统一超时,你要做的工作量几乎等于自己手写一个网络请求库。既然 axios 已经被大量项目验证过,没必要重复造轮子。
2. axios 基础集成与环境配置
2.1 安装与实例化配置
安装很简单,我用 pnpm:
pnpm add axios如果你还在用 npm,npm install axios也一样。重点不是装包,而是怎么初始化一个可以被全项目复用的 axios 实例。我建议不要直接用全局的axios,而是创建独立实例,因为全局默认配置很容易被某个页面改掉,最后排查问题的时候极其痛苦。
我习惯的初始化方式:
import axios from 'axios' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 15000, headers: { 'Content-Type': 'application/json;charset=UTF-8' } })baseURL从环境变量读取,开发环境通常是/api,生产环境可能是真实的接口域名。这里有一个容易踩的坑:如果baseURL写成了绝对地址,开发环境走代理就会冲突,还会导致跨域问题;写成/api这种相对路径,可以让脚手架代理或者反向代理灵活转发。
timeout: 15000不是拍脑袋定的。之前和后端统计过接口耗时,普通接口平均响应在 2 秒以内,报表类接口最慢接近 10 秒,设置 15 秒既不会让用户等太久,也留足了缓冲。如果你项目里有大量导出文件或长时间轮询接口,这个值要重新评估,甚至单独覆盖某一个请求的超时时间。
2.2 环境变量与开发代理
我用 Vite 做前端工程化,所以环境变量和代理都放在工程配置里。先看环境变量文件:
# .env.development VITE_API_BASE_URL=/api然后再看代理配置:
export default defineConfig({ server: { proxy: { '/api': { target: 'http://xxx-backend.example.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })这里rewrite有点讲究:如果你的后端接口本身没有/api前缀,就需要把/api去掉;如果后端接口也带/api,那就不需要rewrite。我自己就吃过亏,一开始没去掉前缀,所有请求都返回 404,还以为是后端问题,后来抓包才发现是路径多了一层。
生产环境的 baseURL 一般直接配置成真实域名,跨域交给反向代理服务器处理。前端这边只需要关注网络请求本身,不需要考虑复杂的 CORS 配置。如果上线后出现跨域报错,优先查反向代理的转发规则,而不是在前端里加本地代理。
3. axios 拦截器与请求封装实战
3.1 请求拦截器:注入 token 和公共参数
axios 集成最核心的部分就是拦截器。请求拦截器主要做两件事:一是给每个请求带上鉴权信息,二是把公共参数或公共 headers 统一加上。
我的实现:
request.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } const deviceId = localStorage.getItem('deviceId') if (deviceId) { config.headers['X-Device-Id'] = deviceId } return config }, (error) => Promise.reject(error) )这里要注意:如果项目用了状态管理库,token 最好从 store 里拿,而不是每次读 localStorage。因为 localStorage 只在浏览器环境存在,以后做服务端渲染或者单元测试时,缺少window对象会直接报错。用 store 可以保证浏览器和 Node 环境都能正常运行。
还可以在请求拦截器里加统一的loading处理,但我不建议每个请求都弹 loading,会产生闪烁和体验问题。具体需求具体处理,我一般只在页面级封装里手动控制 loading,不在全局拦截器里强制加。
3.2 响应拦截器:统一剥离数据和业务码判断
响应拦截器是整个 axios 集成的灵魂。我们后端约定的响应结构是:
{ "code": 0, "message": "success", "data": {} }code为 0 表示业务成功,非 0 表示业务失败。注意这跟 HTTP 状态码是两套体系。响应拦截器的处理逻辑如下:
request.interceptors.response.use( (response) => { const res = response.data if (res.code !== 0) { // 统一的业务错误提示 console.error(`接口报错:${res.message}`) // 实际项目里这里会调用 UI 组件库的 message 方法 return Promise.reject(new Error(res.message || '服务器异常')) } return res.data }, (error) => { // HTTP 层错误处理,比如 401、403、500 if (error.response) { const { status } = error.response if (status === 401) { // 跳转登录页或者触发刷新 token 逻辑 } else if (status === 403) { // 权限不足提示 } else if (status === 500) { console.error('服务器内部错误') } } else if (error.code === 'ECONNABORTED') { console.error('请求超时') } return Promise.reject(error) } )这里最关键的一点是:在业务成功的情况下返回res.data而不是整个response。这样业务页面里拿到的就是真实数据,不用每层调接口都去解包。响应拦截器返回值的类型变化不直观,建议在项目文档里明确标注,否则后写页面的同事很容易以为拿到的还是完整 response 对象。
3.3 统一封装 API 方法
有了拦截器,还需要一个稳定的调用入口。我选择导出一个小型对象:
const http = { get(url, params, config = {}) { return request.get(url, { ...config, params }) }, post(url, data, config = {}) { return request.post(url, data, config) }, put(url, data, config = {}) { return request.put(url, data, config) }, delete(url, params, config = {}) { return request.delete(url, { ...config, params }) } } export default http实际业务里最好不要直接在这个文件里写某一个接口的名字,而是让业务模块的 API 文件再包一层。例如userApi.js:
import http from '@/utils/http' export function getUserList(params) { return http.get('/user/list', params) } export function createUser(data) { return http.post('/user', data) }这样切分的好处是:接口地址集中在业务 API 文件里,页面里变成const data = await getUserList({ page: 1 })。如果哪天后端接口路径变了,只需要改 API 文件,不用全局搜索页面代码。
3.4 并发请求与请求取消
我在集成中遇到一个非常实际的需求:页面进入时要同时拉用户信息和权限列表,两个接口没有依赖关系。处理方法:
const [userInfo, permissions] = await Promise.all([ getUserInfo(), getPermissionList() ])axios 自己的axios.all也会最终落到 Promise 上,所以用原生Promise.all就够了。但如果你需要处理“只返回最先成功的请求”这类竞态,那就要用Promise.race,注意判断结果对应的接口。
请求取消是容易被忽略的点。当用户快速切换页面,上一个页面的请求可能还在进行中,等返回时要么报错,要么乱改状态。axios 现在支持 AbortController 的写法:
const controller = new AbortController() http.get('/some/api', signal, { signal: controller.signal }) // 在路由切换等时机调用 controller.abort()更工程化的做法是在请求拦截器里给每个请求打上标记,然后在全局维护一个pendingMap,路由变化时统一abort。这个方案比较重量级,如果项目简单,可以先在每个页面用局部 AbortController 管理。集成初期我建议先把基础能力跑通,再按需做取消,不要过度设计。
4. 常见问题与排查技巧实录
4.1 跨域问题根治
跨域几乎每个前端都会遇到。开发环境最方便的解法就是脚手架代理,上面已经给过 Vite 的配置。如果代理配好了仍然报跨域,优先看浏览器的 Network 面板:请求地址是否已经指向代理地址,响应标头里有没有Access-Control-Allow-Origin。
有一个常见误判:代理配置后,接口返回 500,页面报跨域。这种情况通常不是跨域,而是后端接口自身报错了。真正跨域的报错是network error或者 CORS 错误信息,而且 Network 面板里会显示请求被浏览器拦截,状态码可能显示(failed) net::ERR_FAILED。
生产环境的跨域,靠反向代理是最好的方案。把前端和后端放在同源地址下,只是通过路径区分。千万不要依赖后端打开 CORS 允许所有来源,一是安全问题,二是复杂度反而更高。
4.2 拦截器死循环怎么避免
响应拦截器里最容易出现的一个坑是:遇到 401 后自动刷新 token,然后重新请求原接口。如果刷新 token 的请求也走同一个响应拦截器,一旦刷新 token 过期,就会再次进入 401 分支,然后再次刷新 token,形成死循环。
我的解决方案是给刷新 token 的请求单独创建一个 axios 实例,这个实例不挂载业务响应拦截器,或者至少不触发“刷新后重放”的逻辑。更稳妥一点,在原始请求的 config 上加一个内部标记:
// 响应拦截器里的 401 处理 if (status === 401 && !config._retry) { const newToken = await refreshToken() if (newToken) { config._retry = true config.headers.Authorization = `Bearer ${newToken}` return request(config) // 重新发起请求 } }_retry标记可以保证只重试一次,不会无限套娃。这个标记是自定义属性,类型上如果你用 TypeScript,在AxiosRequestConfig里需要扩展类型定义。别问我怎么知道的。
4.3 HTTP 状态码与业务 code 的区别
初学者最困惑的一点是:为什么接口明明返回 200,页面里还是走了错误分支?原因就是后端把业务错误也放在了 HTTP 200 里,用业务 code 来区分。我整理了一个速查表:
| HTTP 状态码 | 业务 code | 含义 | 处理策略 |
|---|---|---|---|
| 200 | 0 | 请求成功 | 正常返回数据 |
| 200 | 10001 | 参数错误 | 提示后端返回的 message |
| 200 | 40001 | 未登录或登录过期 | 跳登录页或静默刷新 token |
| 401 | 无业务码 | HTTP 鉴权失败 | 跳登录页 |
| 403 | 无业务码 | 权限不足 | 提示无权限 |
| 404 | 无业务码 | 接口不存在 | 检查地址,可能是代理或路径问题 |
| 500 | 无业务码 | 服务器异常 | 统一兜底提示 |
实际写代码时,要让业务 code 的判断尽可能集中在响应拦截器里,不要让每个页面都去判断code === 0。否则一旦后端调整语义,你就要满项目地改。
4.4 超时与重试的取舍
timeout不设置的话,axios 默认是 0,也就是永不超时。对于浏览器请求来说,很不友好的点在于:如果后端服务假死,用户会一直转圈,不知道是还在请求还是已经失败。所以我建议全局至少设置一个合理的超时时间。
超时后,error对象里的code通常是ECONNABORTED,可以用这个判断来做统一提示。至于要不要自动重试,我的看法是:对于 GET 类的幂等请求,可以有限重试;对于 POST 类的写操作,不要盲目重试。否则用户点击提交时因为网差超时,前端自动重发一次,后端就可能创建了两条订单。这是非常危险的。
如果实在要重试,可以写一个简单工具函数,最大重试次数限制为 2,每次重试之间间隔 1 秒。不要为了“看起来智能”而引入自动重试,在业务系统里,宁可让用户手动提交,也不要制造重复数据。
5. 集成后的工程化扩展
5.1 与状态管理联动
axios 集成到一定程度,很自然要和状态管理库配合。最常见的场景是:token 放在全局 store 里,请求拦截器每次从 store 读取,这样登录、登出时状态能实时同步,不用手动清 localStorage。
另外一个典型场景是全局接口 loading 状态。比如在某页面的父组件里统计当前在途请求的数量,当请求数大于 0 时显示全局进度条,请求数归零时隐藏。实现思路是在请求拦截器里把请求数加 1,响应拦截器里减 1,同时配合一个响应式的全局状态。这个小功能做起来不复杂,但很提升用户体验。
5.2 开发环境请求日志与接口耗时
手动去 Network 面板看请求耗时当然可以,但页面一多就容易乱。我更习惯在拦截器里记录日志,开发环境打印出来:
request.interceptors.request.use((config) => { config._startTime = Date.now() if (import.meta.env.DEV) { console.log(`[API 请求] ${config.method.toUpperCase()} ${config.url}`) } return config }) request.interceptors.response.use( (response) => { if (import.meta.env.DEV && response.config._startTime) { const cost = Date.now() - response.config._startTime console.log(`[API 响应] ${response.config.url} 耗时 ${cost}ms`) } return response } )_startTime是自己挂到 config 上的临时字段,一定要记得在日志打印后删掉,否则字段会一直带在请求配置里,虽然不影响功能,但会泄漏到后端日志中。这个方案能帮我快速定位后端慢接口,也方便做接口性能监控的雏形。
6. 最后一次调整的几点心得
这次集成 axios,前前后后改了大概三版。第一版只是简单封装了一下 get 和 post,第二版才加了拦截器和统一错误处理,第三版补上了并发取消、请求日志和超时处理。如果让我重来一遍,我会先跟后端把统一的响应结构定下来,再动手写封装,而不是边联调边改拦截器,那段时间确实比较折磨。
另外一个建议是:源码里不要直接写死接口域名和 token 存储 key,全部用环境变量和常量收敛。理由很简单,项目换环境部署时,你只需要改一处配置,而不是在几十个页面里替换字符串。
如果你也在给项目集成 axios,希望这篇日记能帮你少踩几个坑。接下来我准备把当前这个网络层扩展成支持文件上传、下载进度反馈的版本,到时候再继续更新吧。