ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue3+TS实战:Axios请求封装与拦截器设计全解析

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 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/jsonJSON 字符串直接传对象,Axios 自动 JSON.stringify
application/x-www-form-urlencodeda=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 再包一层适配器去兼容不同的后端协议。但那些都属于“业务再复杂一点以后”的事,基础打牢了,扩展只是时间问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进