1. 网络请求的前世今生:从XHR到fetch再到axios
1.1 前端为什么要专门学“网络请求”这一课
写前端页面写到后面,一定会碰到一个坎:你辛辛苦苦用 HTML、CSS、JavaScript 把页面布局和交互做好了,但页面上的数据是写死的,用户登录后看不到自己的信息,提交表单后没有反馈。这个时候你就需要网络请求,也就是让浏览器去后端接口拿数据、发数据。
我在带新人的时候经常说一句话:如果只会写静态页面,那你只是一个“页面拼装工”;从会发网络请求、处理接口数据那一刻起,你才真正开始做“应用”。网络请求这个能力几乎覆盖前端所有业务场景:登录注册、商品列表、订单提交、数据大屏的实时刷新、聊天消息的轮询、文件上传……全都离不开它。
所以新手阶段必须集中的问题就是这几点:如何发出一个请求、如何拿到响应数据、如何传递参数、如何处理错误、如何应对跨域、如何把请求层整理成工程化方案。这篇文章就是我基于自己在项目里折腾网络请求的经验,把这些东西一次讲透,内容偏向实操,没有太多空理论,你直接照着写就行。
1.2 从XMLHttpRequest到Fetch API,这条路是怎么走过来的
最早的时候,浏览器里发请求只有一个选择:XMLHttpRequest,也就是我们常说的 XHR。它是 1999 年前后微软在 IE 里提出的东西,后来被所有浏览器继承。名字里有 XML,但它其实可以请求任何类型的数据,JSON、文本、图片都行。
XHR 的基本用法写起来非常啰嗦,下面这段代码是我早期项目里经常出现的:
const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user/list'); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { const data = JSON.parse(xhr.responseText); console.log(data); } }; xhr.send();一个简单的 GET 请求,要处理 readyState、status、responseText 这么多状态,而且它天然是回调式的写法。如果多个请求之间有依赖,比如先取用户信息、再取订单、再取商品详情,代码就会一层套一层,变成所谓的“回调地狱”,维护起来非常痛苦。
后来随着 Promise 在前端普及,浏览器原生提供了 Fetch API。它一出生就是 Promise 风格,代码一下子清爽很多:
const response = await fetch('/api/user/list'); const data = await response.json(); console.log(data);fetch 比 XHR 更简洁,但它并没有解决所有问题。我在实际项目里用 fetch 踩过几个坑,新手一定要知道:
第一,fetch 默认不带 cookie,如果你需要传递会话凭证,必须手动加上credentials: 'include'。第二,只要服务器返回了 HTTP 响应,哪怕状态码是 404、500,fetch 也不会把 Promise 置为 reject,它只会把response.ok置为 false,你得自己判断。第三,fetch 没有内置超时机制,请求挂起就只能干等,需要配合 AbortController 自己实现。第四,上传文件时如果要看进度条,原生 fetch 的支持很弱,XHR 反而有现成的upload.onprogress事件。
所以现实情况是:很多人项目中最终还是选择了 axios 这样的第三方库,因为它把上面这些问题都处理掉了,而且提供了拦截器、取消请求、统一错误处理等机制。当然,你完全可以直接用 fetch,只要你能接受自己封装这些能力。
2. 网络请求实战选型:fetch、axios到底选哪个
2.1 先看一张对比图,按场景对号入座
每次培训新人,我都会让他们先看下面这张表。理解了这张表,你就知道为什么不同团队会选不同方案了。
| 对比项 | XMLHttpRequest | Fetch API | axios |
|---|---|---|---|
| 编程风格 | 回调 | Promise | Promise |
| 代码简洁度 | 低 | 高 | 高 |
| 默认携带Cookie | 是 | 否,需配置 | 浏览器环境下是 |
| HTTP错误自动reject | 否 | 否 | 否,需配合拦截器 |
| 内置超时机制 | 可设置timeout | 无,需AbortController | 有 |
| 请求/响应拦截器 | 无 | 无,需封装 | 有 |
| 取消请求 | 有abort | 有AbortController | 有 |
| 上传进度 | 支持 | 较弱 | 支持 |
| 浏览器兼容性 | 最好 | 现代浏览器 | 通过XHR实现,兼容性好 |
选型建议很简单:老项目还在用 XHR 回调式写法的不建议继续扩展,尽早迁移;新项目追求零依赖、项目规模不大,可以直接用 fetch 封装一层轻量工具;团队项目、业务复杂、需要拦截器做统一鉴权和错误处理的,axios 基本是默认选择。我自己的经验是,除了极轻量的 Demo 或学习项目,绝大多数业务项目用 axios 是省心省力的。
2.2 方法、地址、参数、请求体,一次说清楚
网络请求的核心就是四个东西:方法、地址、参数、请求体。很多人接口调不通,十有八九是把这四者的关系搞混了。
HTTP 方法常用的就四个:GET 用来查数据,POST 用来新增数据,PUT 用来整体更新,DELETE 用来删数据。PATCH 也有,用来局部更新,但很多项目直接用 PUT 了事。
GET 请求的参数是拼在 URL 上的,也叫 query string。比如请求用户列表,要传页码和每页数量:
// fetch写法 fetch('/api/user/list?page=1&pageSize=20') .then(res => res.json()) .then(data => console.log(data)); // axios写法 axios.get('/api/user/list', { params: { page: 1, pageSize: 20 } });注意 axios 里写params是拼 URL 查询参数,写data是发请求体,这两个非常容易混。我见过不止一个新人把参数写在data里用 GET 发出去,后端同事怎么看都收不到。
POST 请求一般有两种请求体格式。一种是 JSON,这是目前最主流的:
axios.post('/api/user/create', { name: '张三', email: 'zhangsan@example.com' });发送这种请求时,axios 会自动把 Content-Type 设置为application/json,并且把对象序列化成 JSON 字符串。
另一种是表单格式(application/x-www-form-urlencoded),适合普通表单提交:
const params = new URLSearchParams(); params.append('name', '张三'); params.append('email', 'zhangsan@example.com'); axios.post('/api/user/create', params);用 URLSearchParams 时,Content-Type 会变成表单格式,数据以name=张三&email=...的方式传输。很多后端老接口只认这种格式,你如果直接发 JSON,后端的 request.getParameter() 就取不到值。
DELETE 请求有一个容易踩坑的细节:参数放 query 还是放 body。按 RESTful 语义,删除资源时通常把资源 ID 放在 URL 路径或 query 参数里;但也有后端约定删除时把条件对象放 body。我在对接不同团队接口时遇到过好几种风格,最好的办法是写接口前先和后端对清楚。比如 axios 这样发:
// 参数放query axios.delete('/api/user/delete', { params: { id: 123 } }); // 参数放body axios.delete('/api/user/delete', { data: { id: 123 } });同一个 delete,前者多了一个空格,后者换成了data,语义完全不同。
2.3 请求头与请求体里容易被忽略的细节
请求头这个东西,平时不是每个请求都要手动设,但一旦要设,出错率非常高,因为它直接决定了后端怎么解析你的请求。
最常见的请求头就是 Content-Type,前面已经提到。第二个常见的是 Authorization,也就是身份令牌:
axios.get('/api/user/info', { headers: { Authorization: 'Bearer eyJhbGciOiJIUzI1NiIs...' } });很多团队要求在请求头里带 token,你可以在每个请求里手动加,但更合理的做法是放到拦截器里统一加,这个后面会讲。
还有一个容易被忽略的点:自定义请求头。比如很多接口要求传X-Requested-With: XMLHttpRequest来标识这是 AJAX 请求,或者传一个固定的X-App-Version给后端做版本控制。这种自定义头一旦没传,后端可能直接返回 403,排查起来还特别隐蔽,因为你第一眼往往不会去检查请求头。
另外,请求体里如果有中文,要确保编码一致。JSON 格式默认 UTF-8,一般没事;但如果走表单格式,遇到特别老的后端系统,可能需要先用encodeURIComponent手动编码再拼接,否则后端拿到的中文是乱码,查了半天其实是字符集问题。
还有一个细节提醒:浏览器对请求头有大小限制,一般几 KB 到几十 KB 不等。有人把大量查询条件塞进自定义 header,结果请求被浏览器直接拦截,报错信息又没说清楚是 header 超限,很容易卡壳。长数据就放 body 或 params,别为难请求头。
3. 把网络请求的几个细节做到位,接口才算真正打通
3.1 拦截器的正确打开方式
如果你是一个项目里几十个页面都在发请求,你肯定不想在每个页面里重复做“加 token、处理错误、判断状态码”这件事。拦截器就是用来解决这个问题的。
axios 的拦截器分两种:请求拦截器和响应拦截器。请求拦截器在请求发出前执行,最适合干三件事:给所有请求加上认证 token、给某些接口加时间戳防止缓存、给需要签名的接口加签名参数。
我在项目里常用这样的请求拦截器:
import axios from 'axios'; const request = axios.create({ baseURL: '/api', timeout: 15000 }); request.interceptors.request.use( (config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => { return Promise.reject(error); } );响应拦截器是重头戏,它统一处理返回结果和异常。比如说后端约定,所有接口返回{ code: 0, data: {...}, message: 'ok' },那响应拦截器里就可以统一拆包,把data直接吐给业务代码,同时在code不等于 0 时统一弹出错误提示:
request.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { // 这里可以做统一错误提示,也可以跳转登录等 console.error(res.message || '请求失败'); return Promise.reject(new Error(res.message)); } return res.data; }, (error) => { if (error.response && error.response.status === 401) { // 登录过期,跳转登录页 window.location.href = '/login'; } return Promise.reject(error); } );拦截器虽然好用,但有一个容易走偏的坑:有人把拦截器当成万能工具箱,什么逻辑都往里面塞,比如埋点、日志、权限判断,最后拦截器代码几百行,改一次就出问题。我的建议是拦截器只做两件事:统一注入请求公共信息、统一处理响应和异常。业务相关的逻辑一律放接口层或页面层,保持这里的干净。
3.2 超时、取消、重试,这些“暗坑”怎么处理
请求发出去之后,最怕两种局面:一是请求卡住永远不返回,二是请求返回了但响应顺序乱了。
先看超时。axios 设置超时很简单:
const request = axios.create({ timeout: 15000 });这个 15 秒的意思是:从请求发出到收到响应,最多等 15 秒,超过就报错。这里我建议超时时间不要设得太短。我看过有团队把超时设成 3 秒,结果用户在网络稍微慢一点的环境下几乎每次请求都会失败,页面上的操作频繁中断,体验非常差。
关于超时,还要区分“连接超时”和“读取超时”。axios 提供的是整个请求的超时,开箱够用;如果用原生 fetch 做更细的控制,就复杂得多。
再看取消请求。最常见的场景是:用户在搜索框输入关键字,每输入一个字就发一次请求,但上一次请求还没返回,下一次又发出来了,最后页面先显示旧结果再被新结果覆盖,甚至旧请求比新请求晚到造成显示混乱。这时候就需要取消过期请求。
axios 取消请求的经典写法在旧版本里通过 CancelToken 实现,新版本更推荐用 AbortController:
const controller = new AbortController(); axios.get('/api/search', { signal: controller.signal }); // 需要取消时 controller.abort();在 React 的 useEffect 里,这个能力尤其重要。我经常在组件卸载时把未完成的请求取消掉,避免组件卸载后还 setState 触发报错:
useEffect(() => { const controller = new AbortController(); fetch('/api/article/detail', { signal: controller.signal }) .then((res) => res.json()) .then(setData); return () => controller.abort(); }, [id]);再说重试。网络请求偶尔失败是正常现象,尤其是弱网环境,一个请求可能是偶发超时,重试一次就好了。但重试不是盲目地多试几次,我建议遵循几个原则:只对幂等操作重试,也就是 GET、PUT、DELETE 等重复执行不会产生额外影响的请求;重试次数控制在两三次以内;重试之间要有退避间隔,比如第一次失败后等 1 秒,第二次失败后等 2 秒。直接在 catch 里原地循环重发的做法,在弱网下只会加重服务器的负担。
3.3 并发请求与竞态处理
前端经常需要同时发多个请求,比如一个页面有用户信息、公告列表、消息数,这三个数据互相独立,就适合并发请求。
axios 或原生 Promise 的并发写法:
const [userInfo, noticeList, unreadCount] = await Promise.all([ request.get('/api/user/info'), request.get('/api/notice/list'), request.get('/api/message/unread') ]);Promise.all 的一个问题是:只要其中一个失败,整个 Promise.all 就 reject,其他两个的返回结果也拿不到了。如果你希望每个请求都独立处理成功或失败,应该用 Promise.allSettled:
const results = await Promise.allSettled([ request.get('/api/user/info'), request.get('/api/notice/list'), request.get('/api/message/unread') ]);这种情况下,每个 result 都有 status、value 或 reason,你可以单独判断。
竞态处理是我特别想强调的。竞态的表现是:你连续发了两个请求 A 和 B,A 先发出,B 后发出,但 A 的响应比 B 更晚到达,结果页面最终展示的是 A 的结果,而不是最后一次请求 B 的结果。这在列表筛选、搜索、标签页切换里非常常见。
解决竞态的思路有两种。一种是用“请求序号”标记,每次发新请求时递增序号,响应回来时只处理最新序号的响应。另一种就是前面说的 AbortController,发新请求时把上一次请求 abort 掉。我在实际开发里更推荐后者,因为 abort 能直接省掉旧请求对网络资源的占用,响应也不会再回来。
4. 跨域、状态码、调试工具:网络请求的三座靠山
4.1 CORS其实没那么玄乎
很多新手第一次遇到跨域错误时都会懵。控制台里报No 'Access-Control-Allow-Origin' header is present,然后不知道该怎么办。跨域问题本质上是浏览器出于安全考虑,限制页面去请求不同源(协议、域名、端口任一不同)的资源。
跨域问题的解决方案里,最常用的就是 CORS,也就是后端在响应头里加规则,告诉浏览器:这个站点的请求放行。比如后端在响应头加上:
Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization加了之后,浏览器就不再拦截。
但是注意,不是所有跨域请求都能简单加头解决。如果你在请求头里带了自定义字段,比如 Authorization,浏览器会在正式请求前发一个 OPTIONS 预检请求,去试探后端允不允许。后端必须对这种 OPTIONS 请求返回正确的 Access-Control-Allow-Headers,否则你的正式请求根本不会发出。这也是新手经常遇到的怪事:单独 GET 请求能通,带上 token 就报跨域错误。
实际项目开发中,如果前后端联调时跨域配置没就绪,也可以在本地开发环境用 devServer 代理转发。这种方式本质上是让同源的开发服务器去请求目标接口,再返回给前端,从而绕开浏览器的跨域限制。但上线后必须走正式的 CORS 配置,不能用开发技巧替代线上方案。
4.2 状态码速查表与排查思路
网络请求返回的状态码,是排查接口问题的第一入口。我整理了一份平时用得最多的速查表:
| 状态码 | 含义 | 常见排查方向 |
|---|---|---|
| 200 | 请求成功 | 无需处理 |
| 201 | 创建成功 | 常用于 POST 创建资源 |
| 301/302 | 重定向 | 接口地址变更,检查是否走了 CDN |
| 304 | 未修改 | 浏览器缓存生效,不是错误 |
| 400 | 参数错误 | 检查请求参数格式、类型、枚举值 |
| 401 | 未授权 | token 缺失、过期,跳登录或刷新 token |
| 403 | 无权限 | 登录了但没权限,联系后端配置权限 |
| 404 | 地址不存在 | 检查 URL、接口是否上线、网关配置 |
| 405 | 方法不允许 | 接口定义了 POST 但你发了 GET |
| 429 | 请求太多 | 限流被触发,降低请求频率 |
| 500 | 服务器异常 | 后端报错,查看服务端日志 |
| 502 | 网关错误 | 服务未启动或挂掉了 |
| 504 | 网关超时 | 后端处理太久,检查慢接口 |
我在排查接口问题时有一个习惯动作:先看 Network 面板里状态码,再看响应内容,再看请求体和请求头。很多问题看第一个就定位了,比如 404 基本就是地址不对,405 就是方法不对,400 大概率是参数不对。不要一上来就怀疑跨域,跨域错误和状态码错误的表现形式完全不同。
还需要注意一个细节:有些服务端框架即使发生业务异常,也会返回 HTTP 200,只是在响应体里把 code 设为非 0。这也是为什么我们前面要统一拆包判断 code,而不仅仅看 HTTP 状态码的原因。
4.3 浏览器网络面板和Mock数据的高效用法
Chrome 的 Network 面板,是我日常排错用得最多的工具。学会了它,很多问题能少问别人大半。
最常用的操作有几个。第一,勾选 Fetch/XHR,只看接口请求,过滤掉图片、样式、脚本等资源。第二,点开某一个请求,分别看 Headers、Payload、Response 三个 Tab。Headers 里能看到请求地址、请求方法、请求头、状态码,Payload 里能看到实际发送出去的数据,Response 里能看到后端返回的原始内容。第三方库已经帮我们解析过的数据会和原始响应有差异,所以排查时一定要看原始 Response。第三,用 Network 面板左上角的清理按钮清空请求列表,重新操作页面,这样可以排除“上一次请求的残留结果干扰当前判断”的情况。
关于 Mock 数据,我强烈建议在项目里留下一条 Mock 通道。当你后端接口还没写好时,前端可以通过 Mock 数据先把页面流程跑通。做法很多,可以用 Mock 拦截请求,也可以在封装的 request 函数里根据环境变量切换真实接口和 Mock 数据。我常用的方式是把 Mock 数据抽成单独文件,在开发和测试环境用环境变量控制:
const isMock = import.meta.env.VITE_USE_MOCK === 'true'; if (isMock) { // 走本地Mock逻辑 mockAdapter(axios); }有了 Mock 通道,前端不用等后端,后端也不用为了配合前端提前写死数据,两边并行开发效率会高很多。
5. 接口封装落地:一套可以复制到项目里的完整方案
5.1 分层封装,从基础设施到业务API
很多项目里的网络请求代码是每个页面各写各的,有的页面直接裸用 axios,有的页面图省事写了axios.get还带了业务逻辑。这种代码一开始没什么问题,页面多了之后,一旦需要调整统一逻辑,就要在每个页面里挨个改,非常糟心。我的建议是给请求代码分三层:基础设施层、接口定义层、页面调用层。
基础设施层就是前面提到的 request 实例,专门负责创建 axios 实例、配置超时、注册拦截器、统一错误处理。接口定义层按业务模块划分,把每一个后端接口封装成一个函数,比如用户模块:
// api/user.js import request from '../utils/request'; export function getUserInfo() { return request.get('/user/info'); } export function login(data) { return request.post('/user/login', data); } export function updateUserInfo(data) { return request.put('/user/info', data); }页面调用层直接调用这些函数,落在页面的业务逻辑里:
import { getUserInfo } from '../api/user'; const info = await getUserInfo(); console.log(info);分层之后,你拿到一个后端给的新接口,第一个反应不是去复制 axios 代码,而是去接口定义层补充一个函数,然后直接调用。后端的接口路径一旦改了,你只需要改一个函数的位置,不涉及所有页面。
5.2 错误码统一管理与边界处理
接口封装最重要的环节之一是错误码的处理。后端接口一般有两种出错形式:HTTP 层错误和业务层错误。HTTP 层错误通过状态码暴露,业务层错误则藏在响应体的 code 字段里。我们需要在拦截器里把这两个层面的错误都处理掉。
我在项目里会维护一张业务错误码对照表,不写死在拦截器里,而是以配置的方式存在单独模块中:
const ERROR_MAP = { 10001: '登录已过期,请重新登录', 10002: '没有权限查看该内容', 20001: '参数不合法', 30001: '数据库操作失败' };拦截器里拿到非 0 的 code 之后,先去表里查文案,查不到就使用后端返回的 message 作为兜底。统一的效果是:业务代码里几乎看不到if (res.code === 10001)这种散落的判断。
另外几个边界情况也需要处理到位。比如网络断开了,axios 会抛出ERR_NETWORK错误,此时要区分“网络压根不可用”和“服务器报错”,给用户不一样的提示。再比如请求被取消时,拦截器会在取消请求上抛错,这种错误不应该弹业务提示,需要用 axios.isCancel 判断后直接静默返回。上传大文件时还需要考虑 token 过期的边界,请求发出去了但返回 401,这个时候文件重试逻辑要设计成幂等的,避免重复上传。
5.3 一个完整的封装示例
我把前面几节讲的内容汇总成一个可直接照搬的最小方案,你可以把它复制到自己的项目里,再按团队要求裁剪。
// utils/request.js import axios from 'axios'; const request = axios.create({ baseURL: '/api', timeout: 15000 }); // 请求拦截 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 && res.code !== 0) { console.error(res.message || '接口异常'); return Promise.reject(new Error(res.message)); } // 直接把业务数据返回给调用方 return res.data; }, (error) => { // 取消的请求不提示 if (axios.isCancel(error)) { return Promise.reject(error); } if (!error.response) { console.error('网络连接失败,请检查网络'); } else { const { status, data } = error.response; console.error(`请求错误 ${status}:`, data?.message || statusText); if (status === 401) { // 清理登录态并跳转 localStorage.removeItem('token'); window.location.href = '/login'; } } return Promise.reject(error); } ); export default request;// 使用示例 import request from '../utils/request'; async function loadArticleList() { try { const list = await request.get('/article/list', { params: { page: 1, pageSize: 10 } }); console.log(list); } catch (err) { // 这里可以什么都不做,统一错误提示已经在拦截器里处理 } }这个方案已经能覆盖绝大多数后台管理项目、中后台系统的需求。如果你的项目要处理文件上传、轮询、并发等复杂场景,在这个基础上去扩展就是水到渠成的事。
我在实际项目里把这个封装复制给不同团队后,大家普遍反映最直观的收益是:页面里的请求代码从几十行缩到两三行,调试问题也更容易定位了,因为所有的错误处理都集中在一个地方。等到你习惯了这套分层的写法,再去看那些每个页面都裸调 axios 的代码,你会很想动手重构它们。
关于网络请求,我最后再分享一个自己的体会:很多人把请求失败归结于“运气不好”,但绝大多数问题其实是可以穷举和分析的。把请求的每一项细节拆开来看,从 URL、方法、参数、请求头到响应体、状态码、拦截器,每一步都是可控的。花一个下午把请求层整体搭好,后面一年都能少加很多班。