前端新手问得最多的一个问题,十有八九是“请求到底怎么发”。我通常给出的答案里,都有一个固定成员:Axios。它不是黑科技,只是一个基于 Promise 的轻量 HTTP 客户端,但在Vue3 + TypeScript项目里,它几乎成了默认配置。这篇文章会从安装开始,带你发出自己的第一个请求,再逐个搞定 GET、POST、参数传递、拦截器和错误处理,最后给一个能直接落地改用的用户列表实战案例。目标是让刚接触前端请求的同学能在十分钟左右看完并跑起来,也让已经写了大量“散装请求代码”的人找到一点整理思路。
1. 先别急着敲命令:Axios到底帮你干了什么
很多新手都问过一个问题:“原生 fetch 也能发请求,为什么要学 Axios?”这句话本身没错,但你只要在真实项目里写过几个接口就会明白,业务请求从来不是“发出去”这么简单。
一次正常的请求背后,要处理 baseURL、超时时间、请求头、token、错误码、取消请求、重复点击、后端不同接口的拼接地址……如果每个页面都自己写一遍这些逻辑,代码会迅速膨胀成一团乱麻。Axios 把公共逻辑收敛到了一个统一的位置,让你在业务代码里只需要关注“要什么数据”和“拿到数据之后做什么”。
1.1 用一张表看懂 Axios 比 fetch 省事在哪
我用一张很朴素的表来对比,新手照着看会更直观:
| 需求 | 原生 fetch 的写法 | Axios 的写法 |
|---|---|---|
| 解析 JSON 数据 | 每次都要await res.json() | 响应对象默认带数据,直接访问response.data |
| 设置超时 | 需要手动配合AbortController做定时 | 创建实例时写timeout: 10000即可 |
| 全局统一携带 token | 每个请求都要自己拼 header | 用请求拦截器统一加 |
| 后端返回 5xx | 默认不会进入 reject,你得自己判断响应状态 | Axios 默认将非 2xx 视为请求失败,能统一处理 |
| 取消请求 | 手动构造 AbortController,逻辑分散 | 同一个控制器也能传入 signal,代码更集中 |
这个表格不是要证明 fetch 一无是处。实际上浏览器都有 fetch,轻功能完全可用。但项目一旦上了规模,公共逻辑的一致性比“少引入一个库”重要得多。
1.2 Promise 看不懂没关系,记住一个生活比喻
Axios 发请求返回的是 Promise。很多新手一看到 Promise 就紧张,其实可以把它想象成“去餐厅点餐”:你下单之后不用一直站在柜台等,该干嘛干嘛,等厨师做完,服务员会喊你的号码。
代码里的await就是在等那个号码叫到你。写完await axios.get('/api/users'),程序就会暂停在这里等待结果,拿到响应后再继续往下走。对于新手来说,先把这个用法记熟,比死磕 Event Loop 更有实际意义。这也是我这篇教程里唯一需要你记住的异步基础。
2. 10分钟倒计时:初始化Vue3+TS项目并装好Axios
环境准备部分看起来无聊,但很多人的“10分钟教程”就栽在这里:项目没起来,后面全是空中楼阁。所以我尽量把每一步都写精确,你照着敲就行。
假设你已经有 Node 环境,打开终端:
npm create vite@latest demo-app -- --template vue-ts cd demo-app npm install npm install axiosnpm create vite会生成一个带 TypeScript 基础的 Vue3 项目。如果你已经在用现成的 Vue3 项目,那只需要执行最后一句npm install axios,其他步骤可以跳过。
装完之后可以验证一下:
npm ls axios能打印出版本号,就说明安装成功。此时package.json的 dependencies 里多了一行"axios": "^1.x.x"。
2.1 为什么要用 Vite 起步
Vite 是目前 Vue3 生态里最主流的开发服务器,启动快,配置简单,天然支持 TypeScript。我见过不少教程还在教 webpack 手工配置,这对新手来说负担太重。所以这系列就直接以 Vite 为基线,你的实际工作项目如果也是 Vite,那这套实践能无缝迁移。
2.2 开发环境跨域:在 Vite 配置文件里配代理
前端跑在http://localhost:5173,后端接口跑在http://localhost:3000。如果你直接发请求,页面控制台经常会有一条“跨域”报错。跨域是浏览器的一种安全策略,限制了不同源之间的请求读取。不要在代码里硬碰硬,我在本地开发时基本全靠 Vite 的 server.proxy 配置。
打开项目根目录的vite.config.ts,改成下面这样:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } })这段配置的意思是:当浏览器端请求以/api开头的地址时,Vite 的开发服务器会把它转发到http://localhost:3000。浏览器看到的始终是同一个开发服务器,所以不会触发跨域。
注意一个细节:改完vite.config.ts后,必须重启一次开发服务器,配置才会生效。我看到太多人改完配置不重启,然后花半小时找跨域问题,最后发现只是个低级失误。
3. 第一次请求:GET、POST、query和body的取舍
环境通了,接下来就可以真正发出一个请求。我先从最简单的 GET 开始,再讲 POST,最后把参数传法说清楚。
3.1 第一个 GET 请求:先看清 response 里面装了什么
在任意组件里写一个异步函数:
import axios from 'axios' async function getUsers() { const response = await axios.get('/api/users') console.log(response.data) }很多新手第一次打印response,会以为自己拿到的就是后端返回的数据。其实不是。Axios 返回的是一个响应对象,里面包含data、status、statusText、headers、config这些字段。真正的接口数据在response.data里。
所以口诀很简单:发请求用 axios,取数据用 .data。
如果后端返回的是这样一个 JSON:
[ { "id": 1, "name": "张三" }, { "id": 2, "name": "李四" } ]那response.data就是一个数组,可以直接在 Vue 页面里v-for遍历。
3.2 POST 请求:默认就是 JSON 格式
接下来是提交数据,最常见的是登录和三方创建操作:
import axios from 'axios' async function login() { const response = await axios.post('/api/login', { username: 'demo', password: '123456' }) console.log(response.data) }Axios 在post的第二个参数传入对象时,会自动把对象序列化为 JSON,并设置Content-Type: application/json。所以只要后端文档里写着“接收 JSON”,你就放心用这个写法。
如果后端要求的是表单格式,直接用对象传参就不行了。此时可以改用URLSearchParams来构造表单数据:
const form = new URLSearchParams() form.append('username', 'demo') form.append('password', '123456') await axios.post('/api/login', form)这种场景在文件上传、老项目接口里比较常见,新项目一般还是 JSON 为主。知道有这么个切换方式就够了。
3.3 query 参数和 body 参数不要混淆
有的接口需要带查询参数,比如分页。很多人会直接拼字符串:
axios.get('/api/users?page=1&size=20')这种方式能跑,但参数一多就不好维护。更推荐用params配置:
axios.get('/api/users', { params: { page: 1, size: 20 } })Axios 会帮你拼成/api/users?page=1&size=20。代码可读性好,而且参数是结构化对象,不容易拼错。
这里的关键区分是:GET 查询条件走params,POST 提交内容走请求体。前者出现在 URL 后面,后者出现在网络请求的 payload 里。对应到后端语言里,一个是 query string,一个是 body,不要混着来。
4. Vue3+TS实战案例:把用户列表从 mock 数据换成真实接口
基础语法讲完,我们来做一个真正能落地的实战改造。场景很简单:一个后台管理系统的用户列表页。
我在很多团队里看到的问题是,组件里直接写axios.get,页面一多,URL 又杂又乱,改一个后端地址都要全局搜索。所以实战案例里,我会把请求抽成一个独立的 API 层,让组件只负责 UI 和状态。
4.1 项目目录规划
先给你看一眼最终的目录结构:
src/ ├─ api/ │ ├─ http.ts │ └─ user.ts ├─ types/ │ └─ user.ts └─ views/ └─ UserList.vuehttp.ts负责创建 Axios 实例和拦截器,user.ts负责用户相关的具体请求函数,types目录放类型定义,views里是页面组件。这样分层之后,组件不需要关心请求细节,API 层也不需要关心 UI 长什么样。
4.2 先写一个共享的 http 实例
创建src/api/http.ts:
import axios from 'axios' const http = axios.create({ baseURL: '/api', timeout: 10000 }) http.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.set('Authorization', `Bearer ${token}`) } return config }) http.interceptors.response.use( response => response, error => { const message = error.response?.data?.message || '请求失败,请稍后再试' console.error(message) return Promise.reject(error) } ) export default http这里的baseURL配合前面 Vite 的代理配置,所有接口地址就不用写全路径,统一写/users这种相对路径即可。timeout表示 10 秒内没响应就超时。请求拦截器负责把 token 塞进 header,这样业务代码里就不用每个请求都手动带一遍。响应拦截器在请求失败时统一打印错误信息,避免每个页面写一遍重复代码。
如果你觉得 token 这个逻辑短期内用不上,可以先去掉,优先把基础结构跑通。这个拦截器是一个很标准的模板,后面需要时往往都是直接把这套搬过去改。
4.3 给用户数据补充类型定义
创建src/types/user.ts:
export interface User { id: number name: string email: string }为什么用 TypeScript 还要单独定义类型?因为组件里拿到User[]之后,编辑器就能帮你自动补全user.name、user.email,写错字段也会立刻标红。这个体验在项目大了之后非常重要。
4.4 写用户模块的请求函数
创建src/api/user.ts:
import http from './http' import type { User } from '../types/user' export const fetchUserList = () => http.get<User[]>('/users')这里用到了 Axios 的泛型参数。http.get<User[]>的意思是:请求返回的data字段类型是User[]。于是组件里解构出来的数据不会是一个any,而是有完整类型提示的数组。
如果你还不熟悉泛型,可以先把它理解为“给请求结果打标签”:告诉 TypeScript,这个接口回来的是 User 类型数组,请用这个类型帮我做校验。
4.5 在 Vue3 组件中接入请求
创建src/views/UserList.vue:
<script setup lang="ts"> import { onMounted, ref } from 'vue' import { fetchUserList } from '../api/user' import type { User } from '../types/user' const users = ref<User[]>([]) const loading = ref(false) const errorMessage = ref('') async function loadUsers() { loading.value = true errorMessage.value = '' try { const { data } = await fetchUserList() users.value = data } catch { errorMessage.value = '数据加载失败,请稍后重试' } finally { loading.value = false } } onMounted(loadUsers) </script> <template> <section> <button :disabled="loading" @click="loadUsers">刷新</button> <p v-if="loading">加载中</p> <p v-else-if="errorMessage">{{ errorMessage }}</p> <ul v-else> <li v-for="user in users" :key="user.id"> {{ user.name }} - {{ user.email }} </li> </ul> </section> </template>我来拆解这个组件的几个关键点。
ref<User[]>([])把用户列表声明成响应式数组,初始值是空数组,类型是User[]。在模板里,v-for循环时会有明确的 item 类型提示,不会像 JS 原型那样拿到一堆隐式 any。
const { data } = await fetchUserList()这一行的data,因为泛型标注,会被推断为User[],不需要再手动as User[]强转。这也是新手最容易忽略的好处之一:类型安全不是给别人看的,是给自己省debug时间的。
loading和errorMessage属于页面 UI 状态。请求开始之前设为true,结束后在finally里恢复;出错时在catch里写错误提示。这算是前端页面请求的一种标准姿态,你可以直接抄。
4.6 跑起来后的效果
浏览器访问页面,应该能看到列表逐条渲染出来,点击“刷新”按钮会重新请求一次接口。控制台干净,没有跨域报错,TypeScript 也没有红波浪线。到这一步,你已经把 Axios 完整接入到 Vue3+TS 项目里了。
5. 十个新手九个会踩的坑:状态码、业务码、超时、取消和代理
基础案例跑通之后,真正考验项目的其实是各种边界情况。我这里挑五个高频坑展开讲,每一个都值得你记到自己的“前端防翻车清单”里。
5.1 HTTP 状态码 200 不代表业务成功
后端接口有一种很常见的设计:不管操作成功还是失败,HTTP 状态码都返回 200,但会在响应体里放一个业务码:
{ "code": 500, "message": "库存不足", "data": null }这种情况下,Axios 默认不会进入catch,因为在它眼里 200 就是成功。如果你只是简单await然后取data,页面会显示一个空数据,但根本不知道业务层面已经失败了。
针对这种设计,要么在响应拦截器里统一判断业务码,要么在请求函数里手动检查。我比较推荐后者,逻辑更直接:
const response = await http.post<ApiResult>('/api/order', payload) if (response.data.code !== 0) { throw new Error(response.data.message) }这里的ApiResult是后端约定的响应包装结构,字段一般就是code、message、data这三件套。你和后端同事沟通时要先确认,到底用 HTTP 状态码表达结果,还是用业务码表达结果。两套机制混着用,前端代码里全是补丁判断。
5.2 超时设置:别让用户一直转圈
在http.ts里设置的timeout: 10000,是指从请求发出开始,如果 10 秒内没有收到响应,Axios 就会抛错。它用起来很简单,但要注意:超时时间不是越长越好。设太久,用户卡住没反应;设太短,弱网环境反而误杀正常请求。普通后台管理系统设在 10 到 15 秒比较舒适,实时性要求高的场景可以单独再覆盖。
5.3 重复点击造成重复请求
按钮没有做加载状态保护时,用户快速点两下“刷新”,就会发出两次同样的请求。这既是资源浪费,也有可能造成页面上数据互相覆盖。
最直接的解决办法是在 UI 上禁用按钮,也就是实战案例里那个:disabled="loading"。但更稳妥的方案是在请求层支持取消:
let controller: AbortController | null = null async function loadUsers() { controller?.abort() controller = new AbortController() try { const { data } = await fetchUserList(controller.signal) users.value = data } catch (error) { if (axios.isCancel(error)) return errorMessage.value = '加载失败' } }AbortController是浏览器原生的取消能力。把它的signal传给 Axios 请求,然后在发新请求之前调用controller.abort(),之前那个还没完成的请求就会被直接中断。abort引发的错误需要单独判断,否则每次切换路由时控制台都会刷一片红色错误信息,看着很慌,其实无害。
5.4 代理配置的常见死角
第一个死角是请求路径写了完整地址,比如axios.get('http://localhost:3000/api/users')。这么做浏览器会认为你在跨域请求,Vite 代理又因为路径没有/api前缀而无法接管。正确写法是让请求路径保持/api/users,其他交给代理。
第二个死角是忘记配changeOrigin: true。部分后端服务会对请求来源做域名校验,如果没有这个配置,服务端可能拒绝请求。我一般会默认打开这个选项,省得多想。
第三个死角是改了vite.config.ts没重启开发服务器。Vite 的配置文件不是热更新覆盖的,必须重启才能让新代理生效。每次改配置之后,我都习惯看一眼终端日志,如果能看到 Vite 自动重启信息,那才算真正生效。
5.5 后端返回的非 JSON 数据
请求一个接口时,如果后端返回的是纯文本、HTML 错误页或者网关报错,Axios 默认会尝试解析成 JSON,解析失败后会抛一个 “Unexpected token” 类型的错误。这种情况通常说明服务端没有正常返回数据,要优先检查接口地址、状态码和网关配置,而不是在浏览器里纠结 Axios 的解析逻辑。
6. 从“能跑”到“好用”:请求抽层的几条维护意见
案例已经跑通了,但你不可能以后每天都把它当成一个 Demo 看。当项目里有几十个接口时,请求层写的是否规范,会直接影响整个团队的开发效率。这里分享几条我在真实后台项目里沉淀下来的习惯,你可以按需吸收。
6.1 请求函数和组件分离,命名直接体现业务动作
不要在 Vue 组件里写一长串http.get('/users')。我习惯在api目录下建模块,并按业务动作命名函数:
export const fetchUserList = () => http.get<User[]>('/users') export const createUser = (data: UserPayload) => http.post<User>('/users', data) export const updateUser = (id: number, data: UserPayload) => http.put(`/users/${id}`, data) export const deleteUser = (id: number) => http.delete(`/users/${id}`)组件里只出现await fetchUserList()、await updateUser(id, form),看起来就像在读一句话。后端如果改了接口地址,也只需要改api/user.ts一处,全项目自动生效。这个习惯对维护者极其友好。
6.2 和后端约定统一响应格式
我给项目里推荐过很多次下面的泛型结构,它能让 TypeScript 的类型约束贯穿整个请求链:
export interface ApiResult<T> { code: number message: string data: T } export const fetchUserList = () => http.get<ApiResult<User[]>>('/users')拿到结果之后,业务代码再处理response.data.data。有人会觉得这层多出来的包裹很麻烦,但统一格式的好处是,你可以在一个地方处理所有业务码,也可以在组件里精确知道每一层数据的类型。如果团队正在从纯 JS 切换到 TS,这个约束建议越早定越好。
6.3 错误提示统一收口,别散落在页面里
页面里最常见的坏味道是这样的:每个组件都对错误状态做一套自己的alert,五六个页面就有五六个不同文案。我更建议在相关请求函数或拦截器里抛出一个标准化错误,组件只负责接收一个错误状态并决定是否展示。
当然,统一不等于一刀切。登录页面可能希望弱化报错细节,后台管理页面可能希望把完整的技术信息打到控制台。这都属于业务决策,但只要请求层收口了,后续改起来就非常简单。
6.4 给请求层留一个追踪入口
我在实际开发中发现,线上问题最难查的就是“用户点了某个按钮,但后端说没收到请求”。这种问题往往是网络层丢包、代理过期、重复点击等复杂原因叠加导致的。在发出请求的钩子里打印一条日志,带上请求方法和地址,排查问题时就能直接给后端同事一个可检索的 key,而不是双方各猜各的。
这段日志代码一般写在请求拦截器里,加上环境判断,只在本地开发时开启。虽然这逻辑只有几行,但它几乎是我每次搭请求层的必备动作,碰到的疑难问题越多,越觉得它有用。
Axios 在 Vue3 + TypeScript 项目里,属于那种“学会一次就能用很多年”的基础能力。我最后还想分享一个个人习惯:拿到任何一份接口文档,先不要急着写页面,先把api目录里的请求函数全部建好,再继续做 UI。这个顺序看起来绕了一点,但项目越到后面,越能体会到请求层稳定的价值。希望这篇教程不只是帮你发出第一个请求,更能让你往后维护代码时少被请求相关的问题折腾。