前端开发里有一件事几乎逃不掉:发请求。拉列表、提交表单、上传文件、下载报表,本质上都是在和 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 ApiResponse<T> { 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自动序列化为?page=1&pageSize=10&keyword=%E5%BC%A0,并且对中文做编码,这是我最推荐的写法。你不需要自己拼字符串,也不容易漏掉encodeURIComponent。
第二种是把参数拼进 URL:
axios.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/json | JSON 字符串 | 直接传对象,Axios 自动 JSON.stringify |
| application/x-www-form-urlencoded | a=1&b=2 | 传 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 再 POST:
const 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: 'blob':
axios.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 ApiResponse<T> { code: number; message: string; data: T; }响应拦截器可以这样写:
http.interceptors.response.use( (response) => { const res = response.data as ApiResponse<unknown>; 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 ApiResponse<T> { 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 request<T>(config: AxiosRequestConfig): Promise<ApiResponse<T>> { return http.request<ApiResponse<T>>(config).then((res) => res.data); } export default http;以后业务 API 层统一从request函数走,类型是干净的,报错处理也是统一的。
5. Vue3 + TS 实战:封装一个完整的用户管理模块
5.1 设计 API 层:用接口定义请求参数和返回类型
假设我们现在写一个典型的用户管理页面:展示用户列表、支持翻页、可以删除用户。第一步不是写组件,而是先定义数据结构和 API 方法。
新建src/api/user.ts:
import { request } from './client'; export interface User { id: number; name: string; email: string; role: 'admin' | 'user'; status: 'active' | 'disabled'; } export interface PageResult<T> { list: T[]; total: number; } export interface UserListParams { page: number; pageSize: number; keyword?: string; } export function getUserList(params: UserListParams) { return request<PageResult<User>>({ url: '/users', method: 'get', params, }); } export function deleteUser(id: number) { return request<null>({ url: `/users/${id}`, method: 'delete', }); }这一步很多人会图省事跳过,直接写 any,最后类型污染整个项目。其实花五分钟把接口定义清楚,后面每个调用处都有自动补全,收益非常明显。这就是 TypeScript 的价值所在——类型先行的设计,让错误在编译期暴露而不是运行期。
5.2 封装请求逻辑:组合式函数与 loading 状态管理
Vue3 的组合式函数非常适合承接请求逻辑。新建src/composables/useUserList.ts:
import { ref } from 'vue'; import { getUserList, deleteUser, type User } from '@/api/user'; export function useUserList() { const list = ref<User[]>([]); 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.vue:
<script setup lang="ts"> import { onMounted } from 'vue'; import { useUserList } from '@/composables/useUserList'; const { list, total, loading, error, load, remove } = useUserList(); onMounted(() => { load(); }); </script> <template> <div> <p v-if="loading">加载中...</p> <p v-else-if="error">错误:{{ error }}</p> <table v-else> <thead> <tr> <th>姓名</th> <th>邮箱</th> <th>角色</th> <th>操作</th> </tr> </thead> <tbody> <tr v-for="user in list" :key="user.id"> <td>{{ user.name }}</td> <td>{{ user.email }}</td> <td>{{ user.role }}</td> <td> <button @click="remove(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 新版本推荐用AbortController:
const controller = new AbortController(); axios.get('/users', { signal: controller.signal, }); // 需要取消时 controller.abort();在 Vue 组件里配合生命周期使用:
<script setup lang="ts"> 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 斜杠拼接导致 404
axios.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.data,TypeScript 推断不出类型,后期一改后端字段整个项目就报警。解法就是设计一个泛型请求函数,参考上面的request<T>。要改数据结构时只改接口定义,报错会精准地指到受影响的位置。
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 再包一层适配器去兼容不同的后端协议。但那些都属于“业务再复杂一点以后”的事,基础打牢了,扩展只是时间问题。