前端开发做到第三篇,终于轮到“网络请求”这个绕不开的核心主题了。很多同学写静态页面很溜,一到对接真实接口就发怵:数据从哪来?怎么发出去?报错了怎么看?这些问题我在带人时天天遇到。不管你是写原生JavaScript、Vue还是React,只要页面里要展示动态数据——用户列表、商品详情、订单状态——网络请求就是你的必经之路。
这篇内容围绕前端最主流的几种请求方案展开:XMLHttpRequest、fetch、axios。我会结合实际开发中的踩坑经验,把请求参数、响应处理、跨域、拦截器、错误处理这些高频细节拆开讲一遍。适合刚学完JavaScript基础、准备写真实项目的同学,也适合用了很久请求库但没搞懂底层原理的开发者。
我对网络请求有个很直观的比喻:它就像餐厅点菜。你报菜名(请求方法),告诉服务员口味偏好(请求头),后厨做菜(服务端处理),最后菜端上桌(响应)。中间任何一环出错,你都要知道问题出在哪个环节。
1. 网络请求的前世今生:从表单提交到fetch
1.1 为什么页面必须依赖网络请求
网页从“静态展示”到“动态交互”的关键转折点,就是网络请求。早期网页是纯HTML,所有内容都在服务器端写死,用户每次点击都要刷新整个页面。后来Ajax出现,页面终于可以不刷新地从服务器拿数据,“前端网络请求”这个概念才算真正立住了。
对于刚入门的朋友,可以这样理解:你写了一个用户列表页面,但页面上的用户数据不可能凭空变出来,它存在服务器的数据库里。前端要做的事,就是通过HTTP协议向服务器要数据,拿到之后渲染到页面上。这个“要数据”的动作,就是网络请求。
实际开发中,除了拿数据,网络请求还遍布在各种场景里:
- 用户登录:把账号密码发给服务端,换回一个token
- 提交表单:把用户填写的内容POST到一个接口,保存到数据库
- 文件上传:把图片、文档以multipart/form-data格式传到服务器
- 轮询接口:定时请求某个接口,获取最新状态,比如在线人数、任务进度
前端网络请求的本质,就是一次完整的HTTP事务。它包含请求行(方法加路径加协议版本)、请求头(headers)、请求体(body),以及服务端返回的状态行、响应头和响应体。把这些概念搞清楚,后面所有问题都能迎刃而解。
1.2 三代请求方式对比:XHR、fetch、axios
前端发请求的方式,我习惯分成三代来看。
第一代是XMLHttpRequest,也就是常说的XHR。2000年左右出来的老技术,用起来相当啰嗦,要写回调函数,要自己处理readyState状态变化,代码一多就很容易嵌套成“回调地狱”。现在直接手写XHR的团队不多了,但很多老项目、部分第三方库底层还在用它,所以面试和排查问题时还是得认识它。
第二代是fetch。现代浏览器原生提供,基于Promise设计,语法比XHR简洁太多。最大的优势是“浏览器内置、不用引库”,一个简单的GET请求三行代码就搞定。但它也有明显的坑:默认不带cookie,超时和取消请求支持得很原始,服务器返回404或500时它不会把请求判定为失败。
第三代是axios。它不是官方标准,而是一个第三方HTTP客户端库,浏览器底层封装的还是XHR(同时支持Node环境)。它流行起来的原因很现实:拦截器、取消请求、超时设置、统一错误处理这些能力开箱即用,团队协作时能快速统一请求风格。
三种方式放在一起对比,优缺点就非常清晰了:
| 方案 | 底层 | Promise | 拦截器 | 超时/取消 | 适用场景 |
|---|---|---|---|---|---|
| XMLHttpRequest | 浏览器原生 | 不支持 | 需要手动封装 | 原生支持但写法繁琐 | 老项目兼容、底层库 |
| fetch | 浏览器原生 | 原生支持 | 需要手动封装 | 需配合AbortController | 轻量请求、新项目 |
| axios | 浏览器XHR/Node | 支持 | 内置 | 内置 | 中大型项目、团队协作 |
这里想提醒一句:不要为了“流行”而用axios,也不要为了“原生”而拒绝fetch。方案选型的核心是看项目需求,后面我会专门展开聊这个话题。
2. 手写第一个fetch请求:参数、响应与跨域
2.1 fetch完整参数拆解:method、headers、body
fetch的基本用法很简单,新手照着写就能通:
const res = await fetch('https://api.example.com/users'); const data = await res.json(); console.log(data);这段代码做了两件事:向指定地址发一个GET请求,然后把响应体解析成JSON。对于初学者来说,这是最直观的入门写法。
但fetch的完整签名远不止两个参数,完整的调用形态是这样:
fetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, body: null, credentials: 'same-origin' });参数里最常用的五个,我逐个说:
- method:请求方法,最常用GET、POST、PUT、DELETE。GET用来拿数据,POST用来提交数据,PUT用来整体更新,DELETE用来删除。
- headers:请求头对象,用来设置Content-Type、Authorization等元信息。
- body:请求体。GET请求通常是null,POST请求放JSON字符串或FormData对象。
- credentials:控制请求是否携带cookie,取值有omit、same-origin、include。
- signal:用来和AbortController配合,实现取消请求。
有个非常容易踩的坑:POST请求如果传的是JavaScript对象,必须先通过JSON.stringify序列化成字符串,同时header里声明Content-Type为application/json。很多人直接把对象塞进body,结果后端收到的是"[object Object]",还反过来怪接口有bug。这个问题我在代码评审里见过太多次。
2.2 响应解析的四种方法与关键坑
fetch返回的res并不是最终的响应数据,而是一个Response对象。你必须调用对应的解析方法,才能拿到真正的内容:
const json = await res.json(); // 解析JSON const text = await res.text(); // 解析纯文本 const blob = await res.blob(); // 解析二进制数据(图片、文件) const formData = await res.formData(); // 解析表单数据这几种解析方法有一个共同特性:都返回Promise,而且同一个Response对象只能被解析一次。如果你先调了res.json(),再调res.text(),后面会直接报错,提示body stream already read。实际开发中,有的同学想在catch分支里再读一次响应体拿错误详情,就很容易碰到这个错误。
另一个关键细节很多人不知道:fetch只有在网络层真正出错时才会reject。比如断网、DNS解析失败、连接被重置。如果服务器返回404、500这种HTTP错误状态码,fetch不会走进catch分支,而是正常返回一个Response对象,只是res.ok为false。
也就是说,你必须手动检查:
if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`); }否则你会在页面上收获一个合法却不合理的“成功请求”,数据却没有如期渲染。
2.3 请求头、CORS跨域与credentials配置
请求头是前后端沟通的“元信息”,最常见的几个包括:
- Content-Type:告诉服务器你发送的body是什么格式,application/json、application/x-www-form-urlencoded、multipart/form-data是三大主力。
- Authorization:存放身份凭证,最常用格式是Bearer加一个token字符串。
- Accept:告诉服务器你希望返回什么格式。
- X-Requested-With:有一部分后端用它判断当前请求是不是Ajax请求。
跨域方面,浏览器出于安全考虑,默认拦截“不同源”的请求。所谓同源,是指协议、域名、端口三者完全一致,只要有一个不一样,就算跨域。
跨域报错长这样,看着吓人但信息量很大:
Access to fetch at 'https://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
解决CORS,前端能做的其实有限。开发环境下最常用的是配置代理,比如Vite的server.proxy或者webpack的devServer.proxy;生产环境则由后端在响应头里加Access-Control-Allow-Origin。前端代码层面没有“绕过”浏览器同源策略的正规方法,谁要是跟你说他能绕过去,多半是用了危险的做法,千万别信。
credentials这个参数也要重点记住。fetch默认不带cookie,如果你依赖cookie做登录态,必须显式设置credentials: 'include',同时后端响应头还得配上Access-Control-Allow-Credentials: true。很多同学登录成功后,下一次请求依然是“未登录”状态,多半就是这里漏掉了。
3. 前端请求层封装:拦截器、超时与错误处理
3.1 自己封装一个request基础函数
项目里如果每个页面都直接写fetch,代码会散得没法看。日志、token注入、错误处理到处重复,后端接口一旦调整,前端要改的地方能有几十处。我的习惯是封装一个request函数,所有请求都走同一个出入口。
一个最小可用的封装长这样:
async function request(url, options = {}) { const token = localStorage.getItem('token'); const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...options.headers } }); if (!response.ok) { throw new Error(`HTTP error ${response.status}`); } return response.json(); }封装的价值在几方面:
- token统一注入,不用每个请求手动带
- Content-Type默认设置好,避免遗漏
- HTTP错误统一抛出,调用方可以用try/catch兜底
- 后续加日志、埋点、Loading控制都只需要改这一处
我见过有人觉得这样多了一层、不够“透明”,宁愿在各个页面里手写fetch。短期看是省了几行代码,长期看每加一个功能就要动几十个文件的感觉,谁写谁知道。
3.2 拦截器机制与AbortController取消请求
拦截器的概念,很多人是从axios认识的。它的本质是“在请求发出去之前、响应回来之后,插入一段统一逻辑”。比如请求前统一加token,响应后统一处理401跳转登录页。
axios的拦截器写法非常经典:
axios.interceptors.request.use(config => { config.headers.Authorization = `Bearer ${token}`; return config; }); axios.interceptors.response.use( response => response, error => { if (error.response && error.response.status === 401) { window.location.href = '/login'; } return Promise.reject(error); } );如果用的是原生fetch,你需要自己模拟拦截器,思路就是在request内部做类似操作。虽然没有axios那么优雅,但维护性好,照样能统一逻辑。
超时和取消请求是fetch最让人头疼的部分。原生fetch没有超时机制,取消需要配合AbortController:
const controller = new AbortController(); setTimeout(() => controller.abort(), 5000); try { const res = await fetch('https://api.example.com/slow', { signal: controller.signal }); } catch (err) { if (err.name === 'AbortError') { console.log('请求超时或已取消'); } }AbortController不仅fetch能用,axios的新版本也支持传signal参数。我建议团队统一约定:所有请求都设超时时间。没有超时的请求一旦接口卡住,用户体验就是loading转半小时,这种问题在线上特别常见。
3.3 错误分类:网络错误、HTTP错误与业务错误
网络请求的错误,我习惯分成四类,每一类的处理方式都不一样:
- 网络层错误:断网、连接被重置。用户不需要知道技术细节,提示“网络连接异常”就行。
- HTTP错误:4xx是客户端问题,比如404、403;5xx是服务器问题,比如500、502。需要根据状态码给不同提示。
- 业务错误:接口返回200,但响应体里的code字段不是0,比如“密码错误”“库存不足”。这种错误只能在业务层判断。
- 解析错误:服务端返回了200,但响应体不是预期的JSON格式,导致res.json()直接抛异常。
处理方式上,我的建议是“错误信息向上抛,用户提示向下沉”。底层request只负责把错误规范化,UI层再决定提示什么文案。别把“请求失败”四个字写死在底层,提交订单失败和加载列表失败是两种完全不同的用户场景,提示文案和后续动作都不一样。
封装的错误对象可以统一成这种结构:
{ code: 'NETWORK_ERROR', message: '网络连接异常,请稍后重试', status: 0, raw: Error }上层拿到之后,按code精确处理,后端接口的报错信息也能在UI层展示出来,而不是只留下一个干巴巴的“Error”。
4. 网络请求常见问题排查与避坑记录
4.1 CORS跨域报错怎么看、怎么解
跨域是我见过新手提问最多的问题,没有之一。报错信息里通常藏着三个关键信息:请求是从哪个origin发起的、被什么策略拦截、响应头里缺少什么字段。
第一步先分清是谁的问题:打开浏览器的Network面板,如果请求已经发出去了,并且响应头里确实没有Access-Control-Allow-Origin,那大概率是后端没配置CORS。前端再折腾也没用,直接去和后端同事对需求。如果响应头里有这个字段但仍然报错,可能是允许的域名范围不对,或者credentials配置有冲突。
开发环境还经常遇到一种情况:明明后端配置了CORS,但还是被拦截。这时候大概率是请求带了自定义header,比如Authorization,触发了浏览器的预检请求(OPTIONS)。后端如果没处理OPTIONS,后续请求就会失败。你可以在Network面板里看有没有一条OPTIONS记录,如果有,就让后端对OPTIONS请求做合法响应。
跨域不是前端代码能“绕过”的东西,它存在的基础是浏览器安全模型。正确的态度是:开发环境用代理,生产环境找后端配响应头。谁要是试图用奇怪的方式忽略安全策略,项目上线时爆发的问题绝对够喝一壶。
4.2 请求成功但页面没数据的排查步骤
“接口明明返回200,页面就是没数据”,这是我被问烂了的一句话。实际上原因通常就那么几种,按顺序排查很快就能定位。
第一种:接口返回200,res.ok也是true,但res.json()抛错。这种情况往往是后端返回了空字符串,或者返回的是HTML错误页。先别急着解析JSON,在Network面板里看响应体的原始内容,如果是空串,前端代码要做好兼容。
第二种:请求发出去了,但Network面板里看到的URL和你代码里写的不一样。这可能是发生了重定向,也可能是BASE_URL拼接出了双斜杠或者前缀丢失。重点检查相对路径和绝对路径的拼接逻辑,尤其是环境变量里BASE_URL和管理后台接口地址的组合。
第三种:拿到的数据是空数组或null。这不一定是请求的问题,很可能是过滤条件没传对,或者分页参数出了问题。先看Network面板里的请求参数具体是什么,再和后端联调确认。
排查这类问题的标准动作,我总结成三个字:看Network。不要凭直觉猜,不要靠console.log一个大对象瞎翻。打开开发者工具,选中对应请求,依次看请求URL、请求头、请求参数、响应体、响应状态码。九成的问题在这一步就能定位。
4.3 竞态、重复提交与并发控制
前端网络请求里有一种隐蔽问题叫“竞态条件”,就是多个请求发出后,先发的不一定先返回。典型场景是搜索框:用户快速输入关键词,第一次请求的结果可能在第二次请求之后才返回,导致页面显示了旧数据。
解决竞态的核心思路是“只认最后一次”。可以用请求序号来做:
let requestId = 0; async function search(keyword) { const currentId = ++requestId; const res = await fetch(`/api/search?q=${keyword}`); if (currentId === requestId) { render(res); } }这样旧请求即使返回了,因为编号已经不是最新的,也不会被渲染到页面上。
重复提交也很常见。用户快速双击“提交订单”按钮,结果创建了两笔订单,这种事故我见过不止一次。处理办法是提交后立刻禁用按钮,或者用一个isSubmitting标记拦截。更稳妥的方式是在request层做“相同请求去重”:短时间内相同URL和相同参数的请求,只发第一个,后面的直接复用第一个的Promise。
这些细节看着不起眼,但线上事故往往就是它们引起的。竞态问题、重复下单、并发超载,都藏在几段看起来没问题的代码里。
5. 请求方案选型与我的实操体会
5.1 fetch优先还是axios优先:两种选型逻辑
我自己的选型标准很简单,跟团队规模、项目阶段强相关:
- 新项目、团队愿意写封装逻辑、没有老代码包袱:优先用fetch。浏览器原生、零依赖、维护成本低,而且它的底层机制能逼着你真正理解HTTP请求。
- 中大型项目、后端接口不规范、需要大量拦截器和统一错误处理:优先用axios。它生态成熟,团队上手成本低,不用自己从头造轮子。
- 老项目维护:保持原有方案,不要为了技术兴奋随意重写请求层。这个教训我见得太多,网络请求方案一换,涉及面极广,特别容易引发线上回归。
如果你问我个人偏好,我其实更偏向fetch加自封装。因为fetch给开发者留下的控制空间更大,封装得越深,你对项目请求流的理解就越扎实。但我也完全理解团队选择axios的理由,工具终究是为人服务的,别被“技术洁癖”绑架。
5.2 容易被忽略的五个请求细节
最后分享几个我这些年实打实踩过的坑,每一个都不起眼,每一个都坑过人:
第一,POST请求的Content-Type必须和body格式匹配。JSON用application/json,普通表单用application/x-www-form-urlencoded,文件上传用multipart/form-data。不要所有请求都统一用application/json,文件传不了,后端也解析不了。
第二,请求日志一定要留。哪怕只是console.info,也能让你在排查问题时少走很多弯路。正规项目建议把请求URL、参数、状态码、耗时都记录下来。线上环境出了问题,如果没有日志,等于闭着眼睛查案。
第三,所有对外请求都要设置合理超时。后端再稳定,也扛不住网络抖动和异常进程。一个没有超时的请求,很可能让整个页面loading状态持续很久,用户只能干等。
第四,不要在前端代码里硬编码接口地址。统一维护一份api配置,开发、测试、生产环境切换时只改一处。这一点在多人协作的项目里尤其重要,不然每个人手里一份地址,联调时鸡飞狗跳。
第五,请求返回的数据结构最好和后端约定统一格式。我比较推荐code/message/data结构,前端封装时统一解包,业务代码里就不用到处写res.data.data这种取数逻辑,出错的概率也会小很多。
我个人的实操体会是:网络请求这块,理解HTTP本身比背库函数重要得多。你把请求头、响应头、状态码、跨域逻辑搞懂了,用fetch还是axios只是换写法的问题。我建议你本地随便起一个接口,亲手发几个请求,把断网、404、500、跨域这些错误全部触发一遍,打开控制台认真看报错长什么样,再决定要不要引入重型封装。踩过一轮坑的人,和只看文档的人,区别一眼就能看出来。