Vue3+TS实战:Axios请求封装与拦截器设计全解析
前端开发里有一件事几乎逃不掉发请求。拉列表、提交表单、上传文件、下载报表本质上都是在和 HTTP 打交道。而如果让我给刚入门前端的人推荐一个请求库大概率还是 Axios。这篇文章就基于 Vue3 TypeScript 的组合从最基础的 GET/POST 讲到实例与拦截器封装再到文件上传、取消请求和完整实战案例全程不绕弯子。适合刚学会 Vue 语法、正打算写第一个真实页面的新手也适合已经写过不少组件、但想系统整理请求代码的老朋友。我可以很负责任地说Axios 不是你唯一的选择但它一定是让你少踩坑的选择。只要把下面的内容过一遍再对照自己的项目改一改你就能拥有一套足够支撑中小型前端项目的请求层。1. 为什么是 Axios一个老前端的选择理由1.1 在 fetch 面前Axios 赢在哪现在的浏览器原生提供了 fetch语法看起来也很简单fetch(/api/users) .then((res) res.json()) .then((data) console.log(data));那为什么我仍然推荐 Axios因为真实项目不是只发一个请求这么简单。fetch 进入工程化场景后你会很快遇到几个绕不开的实际问题请求超时fetch 默认没有超时控制你需要配合 AbortController 自己模拟一套。请求取消虽然现代浏览器支持 signal但团队协作时的封装成本和心智负担都不低。请求进度上传文件想看进度条fetch 的 stream 写法明显比 XHR 的现成回调更啰嗦。统一错误处理HTTP 404、500、断网fetch 只在网络层报错业务状态码你得自己判断。Cookie 携带跨域场景下要手动处理 credentials容易漏。Axios 底层基于 XMLHttpRequest 封装浏览器兼容性好同时又提供了 Promise 接口。上面这些痛点它要么内置、要么用很简单的配置就能解决。所以与其自己封装 fetch 造轮子不如站在 Axios 的肩膀上把业务逻辑写清楚。1.2 适配器、实例、拦截器三个核心设计一次看懂Axios 有三个设计我认为最值得理解理解了它们后面写封装就不会糊涂。第一个是适配器adapter。Axios 在浏览器端使用 XHR在 Node.js 端使用内置的 http 模块对外却暴露完全一致的 API。这意味着同一个请求模块既能跑在浏览器也能跑在服务端做数据抓取或接口测试代码不用改。第二个是实例instance。你可以通过axios.create()创建出多个拥有独立配置的请求对象。一个项目里可能有用户端接口和管理端接口它们的 baseURL、超时时间、鉴权方式都不一样。一个全局 axios 根本管不过来实例就是解决这个问题的。第三个是拦截器interceptor。它像一道流水线每个请求在发出前和响应在返回后都可以插入统一的处理逻辑。我打个比方拦截器就像是快递站的分拣员包裹发出去之前帮你贴上正确的面单包裹收回来之后帮你拆开放进对应的抽屉。这样业务代码里就不需要到处重复写鉴权、解析、报错逻辑了。1.3 给 TypeScript 用户的额外加分项TypeScript 越来越普及这是 Axios 相对很多轻量请求库的明显优势。它本身由 TypeScript 编写类型定义完整。你可以这样定义一个返回结构interface ApiResponseT { code: number; message: string; data: T; }之后在封装函数上挂上泛型调用处的智能提示和类型检查就都活了。这一点在实战部分会有完整展示也是我推荐 Axios 给 TS 项目的核心原因——类型安全本身就是开发效率的一部分。2. 环境准备5 分钟跑通 Vue3 TS 项目里的第一个请求2.1 初始化 Vite 工程并安装 Axios假设你本地已经有 Node.js 环境。打开终端执行以下命令创建一个带 TypeScript 的 Vue 工程pnpm create vite my-project --template vue-ts cd my-project pnpm install pnpm add axios这里我用的是 pnpm你用 npm 或者 yarn 也一样命令差别不大。Vite 是目前 Vue3 项目的社区主流脚手架开发和构建速度都比传统方案快很多。装完之后打开src/main.ts你不需要做任何额外注册Axios 就是一个普通库在哪个文件引入就在哪个文件使用。2.2 第一个最简 GET 请求验证链路先写一个最基础的真实请求不要理会任何封装。在任意组件或脚本里写import axios from axios; axios .get(https://jsonplaceholder.typicode.com/users) .then((response) { console.log(response.data); }) .catch((error) { console.error(error); });这一段代码跑通之后你的环境就算验证完毕了。注意这里的地址是一个公开的占位接口实际项目里一定要替换成你自己的后端地址。你会在控制台看到 Axios 的响应结构{ data: [...], status: 200, statusText: OK, headers: {...}, config: {...} }刚上手时最容易犯的错是把response本身当成业务数据。Axios 已经把所有响应内容放进了response.data如果后端再包一层业务结构那业务数据就是response.data.data。这里我建议你先把“响应对象”和“业务数据”分清楚后面封装时才不会乱。2.3 基础配置项baseURL、timeout、headers、params直接调用axios.get(url)只是冰山一角。真实项目最常用的几个基础配置我列一下axios.get(/users, { baseURL: https://api.example.com, timeout: 10000, headers: { Content-Type: application/json, }, params: { page: 1, pageSize: 20, }, });这里每一项都有它存在的理由baseURL所有请求的公共前缀。之后接口地址都只写/users这种相对路径后端起变化时只改一处。timeout超过多少毫秒就判定为超时。我建议移动端 10 秒、管理端 15 秒左右具体按业务调整。headers请求头。注意 GET 请求一般不需要手动设置 Content-Type浏览器会处理POST 按后端的约定来。params会自动把对象序列化成查询字符串中文和特殊字符也会帮你做 URL 编码。这些都是小细节但拼起来就是一天的调试时间。你可以把当前这个小节当作“查字典”后面哪一项不明白了再回来看。3. 请求方法全梳理GET / POST / PUT / DELETE 与并发请求3.1 GET 怎么带参数params 与路径拼接的取舍GET 请求最常见的使用场景就是拉列表、查详情。参数有两种写法。第一种是通过params传对象axios.get(/users, { params: { page: 1, pageSize: 10, keyword: 张, }, });Axios 会把params自动序列化为?page1pageSize10keyword%E5%BC%A0并且对中文做编码这是我最推荐的写法。你不需要自己拼字符串也不容易漏掉encodeURIComponent。第二种是把参数拼进 URLaxios.get(/users/${id});这种适合路径参数比如/users/123表示获取 id 为 123 的用户。语义更清晰RESTful 风格的后端也更喜欢这种写法。但如果是 query 类型的过滤条件建议还是回到params。3.2 POST 提交的 3 种 Content-Type 场景POST 请求的写法本身很简单axios.post( /login, { username: admin, password: 123456, }, { headers: { Content-Type: application/json, }, } );真正容易出问题的是请求体的格式。后端接口通常有三种约定Content-Type请求体格式Axios 传值方式application/jsonJSON 字符串直接传对象Axios 自动 JSON.stringifyapplication/x-www-form-urlencodeda1b2传 URLSearchParams 或 qs 序列化后的字符串multipart/form-data二进制表单传 FormData 对象大多数现代后端都支持 JSON所以第一行最常用。但有些人会在“表单提交”这个环节卡住明明后端要求 urlencoded你传了对象过去后端就是读不到字段。解决方法是把值转换成URLSearchParams实例再传。PUT 和 DELETE 与 POST 类似区别只是语义和 URL 用法。PUT 通常用来做整体更新PATCH 做局部更新DELETE 用路径参数指定资源 id。参数、请求体、请求头的配置方式完全一致。3.3 并发请求几秒钟发出多个请求页面同时需要用户信息和配置项两个接口互不依赖串行等待会白白浪费一倍时间。这时可以用Promise.all并发执行const [userRes, configRes] await Promise.all([ axios.get(/user/profile), axios.get(/config/list), ]);Axios 还提供了axios.all但在新版里它本质上就是Promise.all的别名。我习惯直接写Promise.all因为大家更熟悉可读性也更好。有一点要特别说明并发请求和并行请求不一样。HTTP 层实际会同时发出多个网络请求但在页面交互上你需要等所有请求完成再渲染。遇到“页面骨架已经出来、数据还在等”的情况可以考虑先渲染局部依赖的数据而不是全部用Promise.all锁死。3.4 文件上传下载与进度事件的实战写法文件上传是 Axios 好用的另一个点。HTML 里的 input 拿到文件后构造 FormData 再 POSTconst formData new FormData(); formData.append(file, fileInput.files[0]); axios.post(/upload, formData, { onUploadProgress: (event) { if (event.total) { const percent Math.round((event.loaded * 100) / event.total); console.log(上传进度${percent}%); } }, });这里有个关键细节不要手动设置Content-Type。浏览器会在使用 FormData 时自动生成带 boundary 的 multipart 头你一旦手动指定反而容易让后端解析失败。实际上我们项目里出过好几次这种问题后面排查全是有人画蛇添足加了请求头。下载文件时一般配合responseType: blobaxios.get(/download/report, { responseType: blob }).then((response) { const url URL.createObjectURL(response.data); const link document.createElement(a); link.href url; link.download reports.xlsx; link.click(); URL.revokeObjectURL(url); });下载和上传在简单场景里就是这两段代码真正要花心思的是加进度条 UI 和错误重试那部分属于业务设计了。4. 进阶封装创建 Axios 实例与拦截器设计4.1 为什么项目里不要直接用全局 Axios刚上手时直接用全局axios.get没问题但项目一大你就会发现代码里到处是重复的请求头、重复的错误处理出问题时改一处要翻遍全项目。更关键的是全局 axios 只有一个配置没法同时应对多个不同配置的后端服务。合理的做法是创建自己的请求实例。每个实例相互独立配置互不影响import axios from axios; const http axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, });之后模块内的所有请求都用http而不是全局axios。这样换环境时只需要改环境变量后端地址变了也不会波及业务代码。4.2 请求拦截器自动加 Token 与公共请求头实例创建好后第一件值得做的事是请求拦截器。它会在每个请求发出前执行适合统一做鉴权http.interceptors.request.use( (config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) { return Promise.reject(error); } );这里的config.headers在新版本 Axios 里可能是AxiosHeaders类型但在拦截器里直接赋值Authorization通常没有类型问题。加了这段之后所有请求都会自动带上 token业务代码里再也不用手动拼请求头。一个小的实操心得token 别放内存变量刷新页面就没了。放 localStorage 或 cookie 都可以关键是要和服务端的鉴权机制匹配。如果接口返回 401 就代表登录过期这正好引到响应拦截器。4.3 响应拦截器统一解包、业务码判断与 401 兜底响应拦截器是另一条重要的流水线。两个职责一是把后端返回的通用结构整理好二是把异常统一拦截。先约定一个常见的后端响应结构interface ApiResponseT { code: number; message: string; data: T; }响应拦截器可以这样写http.interceptors.response.use( (response) { const res response.data as ApiResponseunknown; if (res.code ! 0) { // 业务级错误比如 code 为 10001 表示参数错误 return Promise.reject(new Error(res.message)); } return response; }, (error) { if (error.response?.status 401) { // 跳登录页、清除本地登录态 localStorage.removeItem(token); } return Promise.reject(error); } );这里有一个很常见的争论拦截器里要不要直接return response.data我的建议是在响应拦截器里把response原样返回解包放到请求封装函数里做。原因很简单——响应拦截器返回的结果会直接干扰 TypeScript 类型推导很多人写了return response.data之后发现调用处拿到的res.data变成了undefined就是因为解了两次包。更好的做法是封装一个带泛型的请求函数把解包逻辑控制在单一位置。完整的api/client.ts可以长这样import axios from axios; import type { AxiosRequestConfig } from axios; export interface ApiResponseT { code: number; message: string; data: T; } const http axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, }); http.interceptors.request.use((config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); http.interceptors.response.use( (response) response, (error) { if (error.response?.status 401) { localStorage.removeItem(token); } return Promise.reject(error); } ); export function requestT(config: AxiosRequestConfig): PromiseApiResponseT { return http.requestApiResponseT(config).then((res) res.data); } export default http;以后业务 API 层统一从request函数走类型是干净的报错处理也是统一的。5. Vue3 TS 实战封装一个完整的用户管理模块5.1 设计 API 层用接口定义请求参数和返回类型假设我们现在写一个典型的用户管理页面展示用户列表、支持翻页、可以删除用户。第一步不是写组件而是先定义数据结构和 API 方法。新建src/api/user.tsimport { request } from ./client; export interface User { id: number; name: string; email: string; role: admin | user; status: active | disabled; } export interface PageResultT { list: T[]; total: number; } export interface UserListParams { page: number; pageSize: number; keyword?: string; } export function getUserList(params: UserListParams) { return requestPageResultUser({ url: /users, method: get, params, }); } export function deleteUser(id: number) { return requestnull({ url: /users/${id}, method: delete, }); }这一步很多人会图省事跳过直接写 any最后类型污染整个项目。其实花五分钟把接口定义清楚后面每个调用处都有自动补全收益非常明显。这就是 TypeScript 的价值所在——类型先行的设计让错误在编译期暴露而不是运行期。5.2 封装请求逻辑组合式函数与 loading 状态管理Vue3 的组合式函数非常适合承接请求逻辑。新建src/composables/useUserList.tsimport { ref } from vue; import { getUserList, deleteUser, type User } from /api/user; export function useUserList() { const list refUser[]([]); const total ref(0); const loading ref(false); const error ref(); async function load(page 1, pageSize 10) { loading.value true; error.value ; try { const res await getUserList({ page, pageSize }); list.value res.data.list; total.value res.data.total; } catch (err) { error.value err instanceof Error ? err.message : 请求失败; } finally { loading.value false; } } async function remove(id: number) { await deleteUser(id); // 删除成功后刷新当前页列表 await load(); } return { list, total, loading, error, load, remove, }; }在这个组合式函数里我做了三件事把 loading 状态收进函数内部、把错误信息统一存储、把删除后的刷新逻辑一起收进来。这样组件层面就不用关心数据是怎么来的只需要调用load()和remove()。这也是把请求逻辑和视图解耦的直接办法。5.3 在组件中调用完整的页面代码与渲染逻辑最后是UserList.vuescript setup langts import { onMounted } from vue; import { useUserList } from /composables/useUserList; const { list, total, loading, error, load, remove } useUserList(); onMounted(() { load(); }); /script template div p v-ifloading加载中.../p p v-else-iferror错误{{ error }}/p table v-else thead tr th姓名/th th邮箱/th th角色/th th操作/th /tr /thead tbody tr v-foruser in list :keyuser.id td{{ user.name }}/td td{{ user.email }}/td td{{ user.role }}/td td button clickremove(user.id)删除/button /td /tr /tbody /table p共 {{ total }} 条数据/p /div /template这样一个最小但完整的“请求到渲染”闭环就完成了。你会发现组件代码非常薄没有手动管理请求状态没有到处 try/catch所有逻辑都被组合式函数收走了。这是工程化之后最直观的提升——页面越写越薄逻辑越聚越拢。如果你要再加分页组件只需要把page、pageSize传入load()再把total绑定到分页器的总条数即可。整体扩展成本很低。6. 进阶场景文件上传、取消请求与前端错误处理6.1 文件上传FormData onUploadProgress 的完整做法上传文件时除了 3.4 里提到的基础写法真实项目还经常需要处理两个问题多文件列表和上传进度 UI。一个比较稳的上传函数可以这样组织export function uploadFile(file: File, onProgress?: (percent: number) void) { const formData new FormData(); formData.append(file, file); return request{ url: string }({ url: /upload, method: post, data: formData, onUploadProgress: (event) { if (event.total onProgress) { const percent Math.round((event.loaded * 100) / event.total); onProgress(percent); } }, }); }组件里传入一个onProgress回调就能更新进度条。这里有个经验进度事件不一定保证event.total永远存在特别是某些代理环境下total可能为 0代码里要做判空否则会出现NaN%的怪异显示。上传失败的场景也要提前考虑。很多后端返回错误时不会返回文件流而是返回 JSON这时如果你把responseType设置成别的格式可能会出现解析问题。建议上传接口用默认 JSON 响应下载才设置blob。6.2 取消请求组件卸载时避免发疯似的报错前端开发里有一个经典问题用户已经切换了页面但上一个页面的请求还在运行等响应回来时组件已经卸载控制台刷出一排警告。解决办法是主动取消请求。Axios 新版本推荐用AbortControllerconst controller new AbortController(); axios.get(/users, { signal: controller.signal, }); // 需要取消时 controller.abort();在 Vue 组件里配合生命周期使用script setup langts import { onMounted, onUnmounted } from vue; const controller new AbortController(); onMounted(() { loadUserList({ signal: controller.signal }); }); onUnmounted(() { controller.abort(); }); /script调用了abort()之后对应请求会进入错误分支error.code通常是ERR_CANCELED。在错误处理里可以据此过滤掉取消操作不弹错误提示。这个机制的本质是“前端不再等待响应”而不是“让后端停止处理”。理解这一点很重要因为有些后端任务已经执行了一半前端取消了后端该处理完还是会处理完。如果业务对一致性要求高需要后端配合做幂等或任务状态查询而不是只在前端取消。6.3 错误处理分级超时、断网、业务错误怎么区分真实项目里你会遇到几种不同层级的错误混在一起处理会特别痛苦。我习惯把它们分成三类。错误类型判断方式处理建议网络未连接 / 请求未发出!error.response error.code ERR_NETWORK提示“网络异常请检查链接”请求超时error.code ECONNABORTED提示“请求超时”可选做重试HTTP 状态码错误error.response.status为 404/500 等按状态码给具体提示业务错误状态码 200 但code非 0提示后端返回的 message建议在响应拦截器里做归类而不是在每个请求里重复判断。这个分类表贴在团队文档里也很有用至少能让新人少走弯路。7. 常见问题与避坑清单我踩过的坑7.1 baseURL 与 url 斜杠拼接导致 404axios.create({ baseURL: https://api.example.com/api/, // 末尾有斜杠 }); axios.get(/users); // url 以斜杠开头这时实际请求的 URL 可能是https://api.example.com/api//users或者被拼接成异常地址。我的习惯是baseURL末尾不加斜杠url开头也尽量统一不加斜杠比如baseURL: https://api.example.com/api请求写成get(/users)。规则一旦统一这种 404 基本不会再来。7.2 响应拦截器里解包导致调用处数据丢失前面提到过拦截器return response.data封装函数再then((res) res.data)最终拿到的是undefined。排查方法很简单在调用处打console.log(res)看结构。如果发现自己写了两层解包记住一个原则解包只做一次要么在拦截器要么在业务封装函数不要两头都做。7.3 TypeScript 的类型总是 any很多人封装时偷懒写了axios.get(/users)就直接用res.dataTypeScript 推断不出类型后期一改后端字段整个项目就报警。解法就是设计一个泛型请求函数参考上面的requestT。要改数据结构时只改接口定义报错会精准地指到受影响的位置。7.4 上传文件时手动设置 Content-Type这个坑是真实践过的上传时多写了一句headers: { Content-Type: application/json }后端一直收不到文件。原因是 FormData 需要 multipart 头而且里面的 boundary 是浏览器生成的手动改掉就让整个请求体失去边界标识。正确做法是传 FormData 时不要设置 Content-Type让浏览器自动处理。如果全局拦截器里强制写了统一请求头在拦截器里加一个判断看到config.data是 FormData 就跳过。7.5 GET 参数包含中文或特殊字符手拼 URL 时最容易翻车axios.get(/users?keyword${keyword}); // 如果 keyword 含中文或 符号Axios 的params会自动调用 URL 编码手动拼字符串就得自己用encodeURIComponent否则你会发现搜索“红 蓝”时数据全乱了。我的建议就是所有 query 参数一律走params绝不手动拼字符串。7.6 重复提交与请求竞态用户连续点击删除或提交按钮会产生两个相同请求。如果后端没有幂等设计可能产生重复数据。我常用的处理有几种提交按钮加 loading 状态请求期间禁用点击。用请求层面的取消新请求发出时取消同一个 key 的旧请求。在组合式函数里维护一个标志位请求完成前直接 return。这三种做法不冲突可以叠加使用。简单的页面用按钮 loading 就够了复杂列表页建议加上取消机制体验会更顺。7.7 跨域问题从哪查起前端遇到跨域时浏览器控制台会报 CORS 错误。开发环境下最常见的解法是在 Vite 配置里加代理转发// vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: https://api.example.com, changeOrigin: true, }, }, }, });原理是让 Vite 开发服务器把/api开头的请求转发到目标服务器因为服务器到服务器没有跨域限制。这样前端代码里请求地址可以写成相对路径/api/xxx既避开跨域也方便后续换环境。生产环境就交给服务端的反向代理方案具体配置看部署团队的习惯。这算是我早期排查跨域问题时的第一排查项先确认请求是不是走了代理、代理指向对不对再看目标服务有没有正确的 CORS 响应头。很多时候问题不再前端而在后端没允许来源。8. 写在最后的几点经验我自己在项目里踩过不少次坑之后慢慢形成了一个工作习惯拿到接口文档第一件事不是写页面而是先把 API 层和类型定义写清楚然后再进入组件开发。页面写起来就只关注交互和数据展示请求细节完全不用碰。这套流程放在小项目里可能有点“重”但放到多人协作或长期维护的项目里省下的调试时间绝对超过这点前期投入。另外如果你刚开始接触 Axios建议把“请求函数封装”和“拦截器设计”分开理解。请求函数负责解决“每个接口怎么发”拦截器负责解决“所有请求都要做的公共事”。两者混在一起代码会很快失控。先跑通最简单的再逐步加上 token、错误提示、取消机制每一步都能看到效果这才是平滑的学习路径。这个用法后续还能继续扩展的方向也不少比如在响应拦截器里做全屏 loading、根据业务码自动跳转、给上传组件封装断点续传概念、甚至可以基于 axios 再包一层适配器去兼容不同的后端协议。但那些都属于“业务再复杂一点以后”的事基础打牢了扩展只是时间问题。