前端开发里,发起一个 HTTP 请求这件事,说简单也简单,说容易翻车也是真的容易翻车。我见过太多刚入行的朋友,用着用着就把 GET 请求写成了 POST,或者把请求头加到了错误的位置,又或者拿到一个undefined对着屏幕发呆半小时。axios这个库本身并不复杂,但它的细节足够多,多到如果你只是"照着抄一段能跑的代码",那迟早会在某个边界场景里栽跟头。这篇内容就是想把axios里最常用、也最容易出问题的几块——GET 请求、POST 请求、请求头参数的添加——掰开揉碎讲清楚,让刚接触的朋友能真正理解每一步在干什么,而不是机械复制。不管你是刚学前端、正在做第一个练手项目,还是已经写过一些页面但总在请求这块卡壳,下面这些内容应该都能帮你把地基打扎实。
1. 先把 axios 这件事的定位想明白
1.1 它到底解决了什么问题
在浏览器里发请求,原生方案是XMLHttpRequest,后来有了fetch。那为什么大家还普遍用axios?这不是跟风,而是它确实填了几个很实在的坑。XMLHttpRequest的写法繁琐,回调嵌套深,状态码判断、超时处理、请求取消都得自己手写一大堆样板代码;fetch虽然基于 Promise 干净了不少,但它有几个反直觉的设计——比如它只在网络层面出错时才 reject,遇到 404、500 这种 HTTP 错误状态码它依然算 resolve,你得自己判断response.ok;再比如它默认不带 cookie,超时控制也不直接。
axios在这些地方做了统一封装:HTTP 状态码非 2xx 会自动进入 catch,请求和响应都能挂拦截器,超时可以直接配,请求取消有AbortController和它自己的CancelToken方案,JSON 数据的序列化和反序列化自动完成。说白了,它把"发请求"这件琐事里那些重复的、容易忘的、容易写错的部分都替你兜住了。
我个人的判断是:如果你的项目只是偶尔发一两个请求,fetch完全够用;但只要项目里请求数量上来了,需要统一处理错误、统一加 token、统一做 loading 状态,axios的拦截器机制能省下大量重复代码。这就是选型的核心逻辑——不是它"更流行",而是它的抽象层次刚好匹配中大型项目的需求。
1.2 安装与最基础的引入方式
安装没什么好说的,包管理器一行命令:
npm install axios如果你是在没有构建工具的纯静态页面里用,也可以直接引 CDN 的 UMD 版本,挂到全局的axios变量上。不过现在绝大多数场景都是模块化开发,所以下面统一按 ES Module 的方式来写:
import axios from 'axios';这里有个新手常踩的小坑:有些人装了axios之后在浏览器控制台直接敲axios,发现报未定义,就以为没装好。其实模块化环境里axios是模块作用域内的变量,不会挂到window上,你得在代码文件里 import 才能用。这个认知差异会让不少人白白折腾半天。
1.3 一个请求从发出到拿到数据的完整链路
在动手写具体代码前,我建议先把这条链路在脑子里过一遍,后面所有细节都能挂到这条链上。一次axios请求大致经历:配置合并(默认配置 + 实例配置 + 单次请求配置)→ 请求拦截器依次执行 → 适配器根据环境选择 XHR 或 http 模块真正发出去 → 响应拦截器依次执行 → 返回 Promise。理解了这个顺序,你就能明白为什么"请求头加错地方"会失效——因为拦截器的执行顺序和配置的合并优先级是有明确规则的,加错层级自然不生效。
提示:配置的优先级从低到高依次是:库的默认配置 < 实例默认配置 < 单次请求配置。请求拦截器里改的是最终发出去的配置,所以它优先级最高,但也最容易被滥用。
2. GET 请求:看起来最简单,坑却不少
2.1 最朴素的写法与参数传递的两种姿势
GET 请求最常见的用途就是"查数据"。最基础的写法:
axios.get('/api/users') .then(response => { console.log(response.data); }) .catch(error => { console.error(error); });注意这里拿数据用的是response.data,不是response本身。axios返回的响应对象是个包装体,里面除了data还有status、statusText、headers、config等字段。新手最常犯的错就是直接console.log(response)看到一堆东西,然后不知道数据在哪,其实业务数据永远在data里。
带查询参数的 GET 请求有两种写法。第一种是把参数拼在 URL 上:
axios.get('/api/users?page=1&size=10');第二种是用params配置项,这也是我更推荐的方式:
axios.get('/api/users', { params: { page: 1, size: 10 } });为什么推荐params?因为axios会自动帮你做 URL 编码。假设你的参数里有个搜索关键词带空格或者中文,手拼 URL 很容易漏掉encodeURIComponent,导致服务端收到的参数乱码或者被截断。用params它会自动处理这些转义,省心且不容易出错。
2.2 params 和直接拼 URL 的本质区别
这两者的区别不只是"写法好看",背后涉及编码和可维护性两个层面。从编码角度看,params会对每个值调用编码逻辑,特殊字符如&、=、空格、中文都会被正确转义;手拼的话你得自己保证。从可维护性看,参数一多,手拼的字符串又长又难读,改一个参数得在长串里找半天,而对象形式一目了然。
还有一个隐蔽的点:当参数值是undefined或null时,axios默认会把这个键直接忽略掉,不会拼成key=undefined这种脏数据发给后端。这个行为在表单筛选场景里特别有用——用户没填的筛选项你直接传undefined就行,不用手动过滤。不过要注意,空字符串''是会被保留的,因为它是一个有效值,这个区别要记清楚。
2.3 GET 请求加请求头的正确位置
GET 请求加请求头,很多人第一反应是往params里塞,这是彻底搞错了方向——params是查询参数,会拼到 URL 上;请求头是另一套东西,要放在headers配置里:
axios.get('/api/users', { params: { page: 1 }, headers: { 'Authorization': 'Bearer your-token-here', 'X-Custom-Header': 'some-value' } });这里要强调一个概念区分:查询参数(query params)走 URL,请求头(headers)走 HTTP 头部,请求体(body/data)走消息体。GET 请求按规范是不带请求体的,所以 GET 只有前两者。把这三者的位置搞混,是新手排查请求问题时的头号障碍。我见过有人把 token 塞进params里,结果 token 明文出现在 URL 上,既难看又有安全隐患,还容易被日志记录下来。
2.4 GET 请求的常见翻车场景复盘
说几个我实际遇到过、也帮别人排查过的典型问题。第一个是缓存问题:某些浏览器对 GET 请求会做缓存,你明明改了数据,页面刷新还是旧的。解决办法是在params里加个时间戳,或者在请求头里加Cache-Control: no-cache。第二个是数组参数的序列化:当params里某个值是数组时,axios默认序列化成key[]=a&key[]=b这种形式,但有些后端期望的是key=a&key=b,这时候就需要自定义paramsSerializer。第三个是URL 末尾斜杠:/api/users和/api/users/在某些后端框架里会被当成两个不同的路由,导致 404,这个坑极其隐蔽,排查时优先怀疑。
注意:GET 请求的参数长度受 URL 长度限制,虽然现代浏览器和服务器放宽了很多,但如果你要传很长的筛选条件,还是老老实实用 POST 把条件放请求体里,别硬塞 URL。
3. POST 请求:数据怎么发、发成什么样
3.1 POST 的两种数据格式:JSON 与表单
POST 请求和 GET 最大的不同在于它带请求体,而请求体的格式直接决定了后端怎么解析。最常见的两种是application/json和application/x-www-form-urlencoded。
用axios.post传一个普通对象,默认会以 JSON 格式发送,Content-Type自动设为application/json:
axios.post('/api/users', { name: '张三', age: 25 });如果你需要发传统的表单格式(很多老系统或者文件上传场景需要),有两种做法。一种是手动构造URLSearchParams:
const params = new URLSearchParams(); params.append('name', '张三'); params.append('age', 25); axios.post('/api/users', params);另一种是用qs这类库序列化后手动设Content-Type。这里的关键认知是:你传什么格式的数据,就要配对应的 Content-Type,两者必须匹配。我见过太多"后端说收不到参数"的案例,最后查出来是前端发了 JSON,后端却按表单格式解析,自然一片空白。这不是谁对谁错,而是双方约定没对齐。
3.2 第三个参数 config 到底放什么
axios.post(url, data, config)有三个参数,前两个好理解,第三个config是新手最容易忽略的。请求头、超时、响应类型这些全在第三个参数里:
axios.post('/api/users', { name: '张三' }, { headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer token' }, timeout: 5000 } );很多人写 POST 时把 headers 塞进第二个参数(也就是 data 里),结果请求头没生效,数据里反而多了个headers字段发给后端。这个错误的根源就是没搞清楚三个参数各自的职责:url 是地址,data 是请求体,config 是这次请求的所有其他配置。记住这个分工,位置就不会放错。
3.3 表单提交与文件上传的特殊处理
文件上传是 POST 的一个特殊场景,核心是FormData。用FormData时有个必须注意的点:不要手动设置Content-Type。因为FormData需要浏览器自动生成带boundary的multipart/form-data头,你一旦手动写死Content-Type: multipart/form-data,反而会因为缺少 boundary 导致后端解析失败。
const formData = new FormData(); formData.append('file', fileInput.files[0]); formData.append('userId', '123'); axios.post('/api/upload', formData, { headers: { // 这里不要写 Content-Type,让浏览器自己生成 'Authorization': 'Bearer token' } });这个坑我踩过,当时排查了很久,后端一直说文件解析失败,最后发现就是手动设了 Content-Type 把 boundary 弄丢了。所以记住这条经验:用 FormData 就放手让浏览器管 Content-Type。
3.4 POST 请求体为空的边界情况
有时候 POST 请求不需要带任何数据,比如某些"触发某个动作"的接口。这时候第二个参数传null或者{}都行,但要注意有些后端对空 body 的处理比较严格。传null时axios不会发送请求体,传{}会发送一个空的 JSON 对象{}。这两者对后端来说可能是不同的,具体看接口约定。我的习惯是:如果接口明确不需要 body,传null;如果接口期望一个对象但字段都可选,传{}。这个细节虽小,但在联调时能省掉一轮扯皮。
4. 请求头参数:加在哪、怎么加、为什么这么加
4.1 单次请求加头与全局加头的取舍
请求头有两种加法:单次请求加和全局加。单次的就是上面演示的,在 config 的headers里写。全局的则是通过axios.defaults.headers或者创建实例来设:
axios.defaults.headers.common['Authorization'] = 'Bearer token'; axios.defaults.headers.post['Content-Type'] = 'application/json';但我要泼一盆冷水:直接改全局axios.defaults在稍大的项目里是危险操作。因为它是全局单例,任何一处修改都会影响所有请求,一旦某个模块改了默认头,其他模块莫名其妙就带上了不该带的头,排查起来极其痛苦。正确做法是创建独立实例:
const request = axios.create({ baseURL: '/api', timeout: 10000, headers: { 'Content-Type': 'application/json' } });实例的好处是配置隔离,不同业务线可以用不同实例,互不干扰。这是我在多个项目里验证过的实践,强烈建议养成习惯。
4.2 拦截器才是加 token 的正解
虽然可以在每次请求的 config 里手写Authorization,但请求一多,重复代码就爆炸了。真正优雅的做法是用请求拦截器统一注入:
request.interceptors.request.use( config => { const token = localStorage.getItem('token'); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } return config; }, error => Promise.reject(error) );这样每个请求都会自动带上 token,业务代码里完全不用关心。这里有个细节:config.headers在较新版本的axios里是一个AxiosHeaders对象,用方括号赋值和用.set()方法都可以,但如果你遇到某些版本下直接赋值不生效的情况,改用config.headers.set('Authorization', ...)更稳妥。
4.3 常见请求头字段的含义速查
很多人加请求头是"别人让我加我就加",其实每个头都有明确用途。下面这张表把最常用的几个列清楚,理解了含义,遇到问题才知道该查哪个:
| 请求头字段 | 作用 | 典型值 |
|---|---|---|
Content-Type | 声明请求体的数据格式 | application/json、application/x-www-form-urlencoded、multipart/form-data |
Authorization | 携带身份凭证 | Bearer <token>、Basic <base64> |
Accept | 声明期望的响应格式 | application/json |
X-Requested-With | 标识这是异步请求 | XMLHttpRequest |
Cache-Control | 控制缓存行为 | no-cache、no-store |
理解Content-Type尤其重要,因为它是前后端数据格式对齐的关键。前面 POST 那节反复强调的格式匹配问题,本质就是这个头在起作用。
4.4 自定义头与跨域预检的关系
当你添加一些非标准请求头(比如自定义的X-Token)时,浏览器会触发 CORS 预检请求(OPTIONS 请求)。这是浏览器的安全机制,不是axios的行为。很多人加了个自定义头之后发现请求变成了两次,一次 OPTIONS 一次真正的请求,就慌了,其实这是正常的。要让它通过,需要后端在响应头里正确配置Access-Control-Allow-Headers,把这个自定义头列进去。这个知识点在联调跨域接口时几乎是必踩的,提前知道能少走很多弯路。
提示:标准的
Content-Type值里,只有application/x-www-form-urlencoded、multipart/form-data、text/plain这三种不会触发预检,application/json是会触发的。这也是为什么有些老项目坚持用表单格式的原因之一。
5. 把请求封装成能复用的东西
5.1 为什么要封装而不是到处写 axios
项目里如果每个页面都直接axios.get、axios.post,很快就会陷入几个困境:baseURL 到处重复、错误处理各写各的、token 注入逻辑散落各处、改一个公共逻辑要改几十个文件。封装的核心目的就是把这些横切关注点收敛到一处。
一个最小可用的封装大概长这样:
import axios from 'axios'; const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }); request.interceptors.request.use( config => { const token = localStorage.getItem('token'); if (token) config.headers['Authorization'] = `Bearer ${token}`; return config; }, error => Promise.reject(error) ); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 0) { return Promise.reject(new Error(res.message || '业务错误')); } return res.data; }, error => { if (error.response?.status === 401) { // 处理登录失效 } return Promise.reject(error); } ); export default request;这段代码里,响应拦截器做了两件事:统一判断业务状态码、统一处理 401。这样业务层拿到的直接就是干净的数据,不用每次判断code。
5.2 响应拦截器里统一处理错误的分寸
拦截器很强大,但滥用会带来副作用。比如把所有错误都在拦截器里console.error然后吞掉,业务层就永远拿不到错误,无法做针对性提示。我的原则是:拦截器只做"通用且无争议"的处理,比如 401 跳登录、统一弹一个全局错误提示;涉及具体业务语义的错误,交回给调用方处理。这个分寸感需要在项目里慢慢磨,但一开始就要有意识,别把所有逻辑都往拦截器里堆。
5.3 一个容易忽略的点:并发请求与取消
实际项目里经常有这种场景:用户快速切换筛选条件,前一个请求还没回来,后一个又发出去了,结果先发的后到,把新数据覆盖了。这就是竞态问题。axios支持通过AbortController取消请求:
const controller = new AbortController(); request.get('/api/search', { params: { keyword }, signal: controller.signal }); // 需要取消时 controller.abort();在搜索联想、标签切换这类高频请求场景里,这个能力几乎是必需的。我建议在封装层提供一个"取消上一次同类请求"的工具函数,能省掉大量手写逻辑。
6. 排查请求问题的实战思路
6.1 从浏览器 Network 面板倒推问题
请求出问题时,第一步永远是打开开发者工具的 Network 面板,看实际发出去的请求长什么样。重点看四样东西:请求 URL 对不对、请求方法对不对、请求头里 Content-Type 和 Authorization 在不在、请求体的格式对不对。这四项基本能覆盖 80% 的问题。很多人一上来就改代码,其实先看一眼 Network 就能定位到问题在哪一层。
6.2 常见错误码与对应排查方向
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 404 | URL 拼错、baseURL 重复、末尾斜杠不一致 | 对比 Network 里的完整 URL 与接口文档 |
| 401 | token 没带上、token 过期、头字段名拼错 | 检查请求头 Authorization 是否存在 |
| 400 | 请求体格式与 Content-Type 不匹配 | 检查 data 格式和 Content-Type 是否对应 |
| CORS 报错 | 自定义头触发预检但后端未放行 | 检查后端 Access-Control-Allow-Headers |
| 参数收不到 | 参数放错位置(query/body/header 混淆) | 确认参数该走 URL 还是 body |
这张表是我这些年排查问题的经验浓缩,遇到问题先对号入座,能快速缩小范围。
6.3 几个反直觉的坑
最后分享几个不那么直观、但确实会让人卡很久的坑。第一个是请求被浏览器缓存导致 POST 也返回旧数据,虽然少见,但在某些代理配置下会出现,加个随机参数或禁用缓存即可。第二个是**baseURL和请求路径都以斜杠开头或结尾导致的路径拼接错误,axios的拼接规则是简单的字符串拼接,/api加/users会变成/api/users,但如果 baseURL 是/api/而路径是/users,可能拼出/api//users,某些服务器会因此 404。第三个是响应拦截器里 return 了response.data之后,业务层又.data了一次**,拿到undefined,这个纯粹是封装约定没统一导致的,团队里一定要把"拦截器返回什么"这件事说清楚。
这些坑单看都不复杂,但在真实项目里,它们往往藏在层层封装之下,排查成本很高。把上面这些链路和边界情况提前装进脑子里,遇到问题时就不会像无头苍蝇一样乱试了。