尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

XHR、Ajax、Fetch 区别与网络请求排查实战

XHR、Ajax、Fetch 区别与网络请求排查实战 上周帮一个朋友看他那个上传头像的功能前端点了上传之后控制台干干净净页面上只弹一句上传失败网络请求错误后端日志里一条记录都没有。他把代码发过来我扫了一眼用的是 jQuery 的$.ajax外面还套了一层自己封装的request方法。最后定位到的问题很朴素请求头里的Content-Type写成了application/json但请求体发出去的却是FormData。服务端按 JSON 解析直接抛异常异常被框架吞掉前端只收到一个模糊的失败提示。这件事让我意识到XHR、Ajax、Fetch这三个词很多人每天都在写却从来没真正分清过它们各自是什么、边界在哪里、什么时候该用谁。更麻烦的是一旦网络请求出了问题报错信息往往只有一句请求失败把所有排查线索都藏起来了。这篇就按我平时的排查习惯把这三样东西从头到尾捋一遍顺便把一次请求从浏览器出发、到达 Spring Boot、再返回的全过程拆开讲。看完之后你至少应该能做到看到一句含糊的报错能在脑子里列出三到五条排查方向而不是对着控制台发愣。1. Ajax 不是一门技术它是一类做法的统称1.1 把名字拆开看每个词都带着年代感Ajax 全称是 Asynchronous JavaScript and XML翻译过来就是异步的 JavaScript 和 XML。这四个词放在今天有三个已经不太准确了JavaScript 还在但 XML 基本被 JSON 取代了异步这个词也从一种新鲜能力变成了默认行为。所以这个名字你完全可以当成一个历史遗留标签它描述的是一类行为也就是在不刷新整个页面的前提下由脚本在后台向服务器要数据拿到数据之后局部更新页面。这里最关键的是不刷新页面这五个字。在 Ajax 这套做法流行之前网页想从服务器拿新数据只能整页提交或整页跳转用户会看到白屏、看到进度条、看到页面重新排版。Ajax 把这套流程改成了脚本发请求服务器只回数据脚本拿到数据后自己决定往哪个 DOM 节点里塞。用户体验上的差别就是从换一页变成了变一块。很多人会把 Ajax 和 jQuery 画等号这是个挺常见的误解。jQuery 只是当年最流行的、把 Ajax 这套做法封装得最顺手的库之一它出现得晚也退得早。真正让浏览器具备这个能力的东西是下面要讲的 XHR。1.2 Ajax、XHR、Fetch 三者的真实关系这三个词经常被混着用但它们的层级完全不一样。我用一个类比来说明把发网络请求想象成寄快递Ajax 是寄快递这件事这个说法本身XHR 和 Fetch 是两家不同的快递公司一个开得早、规矩多一个开得晚、流程简洁但有些特殊条款。名称本质是什么出现时间现在的地位Ajax一种做法的统称不是 API概念流行于 2005 年前后仍然是通用叫法但不是具体技术XMLHttpRequest浏览器提供的原生 API 对象很早就进入浏览器规范仍在使用尤其是上传场景Fetch浏览器提供的原生函数基于 Promise较晚进入规范新项目首选但需要补齐超时等能力各类封装库对上面两者的二次包装各自不同提升开发效率但会遮蔽底层行为从这个表能看出来Ajax 是概念XHR 和 Fetch 是实现手段。所以当有人说我用 Ajax 发个请求他真正在用的可能是 XHR也可能是 Fetch还可能是某个封装库。判断标准只有一个——他有没有触发浏览器的网络请求能力并且是异步的。1.3 为什么浏览器敢把发请求的能力开放给脚本这里有个容易被忽略的背景问题为什么浏览器愿意让页面里的 JavaScript 主动向外发请求早期这么设计是冒了风险的因为脚本一旦能任意发请求就可以在用户不知情的情况下把数据往外送。后来定下来的规则是同源策略 CORS。同源策略规定脚本默认只能向同源协议、域名、端口三者完全一致的地址发请求如果目标是别的源必须由对方服务器明确回应我允许你来访问这就是 CORS 那一套响应头的作用。理解了这一层后面看到跨域相关报错时你就能立刻判断问题不在你的请求代码而在于响应头没给对或者预检请求没被正确处理。注意跨域报错是浏览器在拦截不是服务器拒绝了你的请求。很多时候服务器日志里其实已经有记录了只是浏览器把响应丢掉了。2. XHR一个被嫌弃但一直在岗的老兵2.1 readyState 的五个状态就是一次请求的推进进度XMLHttpRequest这个对象的设计风格在今天看来相当复古它用的是事件回调加状态机。这个状态机就是readyState取值 0 到 40 (UNSENT)对象刚创建还没调用open。1 (OPENED)调用了open参数和地址已经记下来了但还没发出去。2 (HEADERS_RECEIVED)响应头已经到了status和响应头可以读了。3 (LOADING)响应体正在一点点回来responseText里能读到部分内容。4 (DONE)整个请求结束无论成功还是失败都落到这个状态。这套状态机的好处是粒度很细你能在第 3 个状态里读到流式返回的内容也能在状态 2 拿到响应头之后决定要不要继续。坏处是写起来啰嗦必须监听readystatechange或者load然后在回调里再做一层状态码判断一不小心就写成一坨套娃。2.2 手写一个完整的 XHR 请求要照顾多少细节我们直接看一段能上生产的写法比任何描述都直观function request(url, options {}) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(options.method || GET, url, true); // 只有非跨域且需要携带凭据时才开启 xhr.withCredentials !!options.withCredentials; // 接收类型告诉浏览器我期望回什么方便自动解析 xhr.responseType options.responseType || json; // 超时控制单位毫秒 xhr.timeout options.timeout || 10000; // 自定义请求头必须在 open 之后、send 之前设置 if (options.headers) { Object.keys(options.headers).forEach((key) { xhr.setRequestHeader(key, options.headers[key]); }); } xhr.onload () { // 注意只要服务器有响应无论 200 还是 500都会走到这里 if (xhr.status 200 xhr.status 300) { resolve(xhr.response); } else { reject(new Error(HTTP ${xhr.status})); } }; xhr.onerror () reject(new Error(网络层失败)); xhr.ontimeout () reject(new Error(请求超时)); xhr.onabort () reject(new Error(请求被取消)); xhr.send(options.body || null); }); }这段代码里有几个细节值得单独拎出来说。第一setRequestHeader必须写在open之后、send之前写在open之前会被浏览器直接忽略而它不会给你任何提示。第二onload触发时并不代表请求成功2xx 之外的状态码也走onload所以必须自己判断status。第三onerror只在网络层出问题时触发比如 DNS 解析不了、连接被拒、跨域被拦截跟服务器返回 500 完全是两回事。我自己踩过最坑的一次是把responseType设成json结果后端在出错时返回的是纯文本或者空响应体xhr.response直接变成null前端拿到null之后一路往下传最后在最深的一个组件里报无法读取 undefined 的属性。排查了很久才反应过来是错误分支的响应体格式和成功分支不一致。2.3 XHR 至今没被淘汰的两个硬理由新项目基本都在用 Fetch 或者基于 Fetch 的封装了但 XHR 有两个能力是 Fetch 至今比较别扭的一是上传进度。XHR 上有xhr.upload.onprogress可以直接拿到已上传字节数和总字节数做进度条非常自然。Fetch 的标准里没有对称的请求体上传进度能力需要借助流式请求体之类的较新特性兼容性和写法都更麻烦。二是对老环境的兼容性。一些内嵌的浏览器内核、某些老版本的运行环境Fetch 支持不全而 XHR 一直在。如果你的页面需要跑在那些环境里用 XHR 反而更省心。我的实际选择是普通的数据接口用 Fetch 封装涉及文件上传、需要实时进度的地方老老实实回到 XHR。3. Fetch语法清爽了坑也换了地方3.1 Promise 化的设计取舍Fetch 最直观的变化是把回调改成了 Promise链式写法让代码看起来顺眼多了async function getUser(id) { const res await fetch(/api/users/${id}, { method: GET, headers: { Accept: application/json }, }); if (!res.ok) { throw new Error(HTTP ${res.status}); } return res.json(); }但这里藏着一个必须理解的设计取舍Fetch 只负责把请求发出去并把响应对象交给你它不替你判断这次请求算不算成功。这是它跟 XHR 那套onload status 判断最大的区别也是最多人踩坑的地方。为什么这么设计因为从 HTTP 协议的视角看服务器返回 404、500本身就是一次成功的通信——请求发出去了对方也回应了。失败与否是业务层面的判断应该由调用方决定。这个逻辑没问题但它跟大多数人的直觉不一致所以res.ok这个检查必须写而且要在res.json()之前写。3.2 fetch 不会因为 404、500 而 reject这句结论值得单独占一个小节因为它导致的问题实在太多了。// 错误写法500 也会进 then拿到的是一个错误响应的 body fetch(/api/order).then((res) res.json()).then((data) { render(data); // data 可能是 { error: xxx }页面直接渲染崩 }); // 正确写法先判 ok再做业务处理 fetch(/api/order) .then((res) { if (!res.ok) throw new Error(HTTP ${res.status}); return res.json(); }) .then(render) .catch(showError);Fetch 真正会 reject 的情况只有几类地址格式非法、网络不可达、请求被AbortController取消、CORS 预检失败。你可以简单记成请求根本没走完整个来回才会 reject只要对方给了响应无论状态码是多少都走 resolve。3.3 超时、取消、上传进度Fetch 的短板怎么补Fetch 一开始连超时参数都没有后来才通过AbortSignal.timeout()提供了标准做法更通用的方式是手动用AbortControllerasync function fetchWithTimeout(url, options {}, timeout 10000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); try { const res await fetch(url, { ...options, signal: controller.signal }); if (!res.ok) throw new Error(HTTP ${res.status}); return await res.json(); } catch (err) { if (err.name AbortError) throw new Error(请求超时或被取消); throw err; } finally { clearTimeout(timer); } }AbortController的用法有个容易忘的点controller.abort()之后这个 signal 就永久处于已中止状态同一个 controller 不能复用。如果你要做一个输入框输入时取消上一次请求的搜索联想功能每次请求都必须新建一个 controller并且把旧的 abort 掉。把两个 API 放在一起对比选择逻辑就很清楚了对比项XMLHttpRequestFetch写法回调 状态机Promise / async-awaitHTTP 错误状态码不自动报错需判断 status不自动 reject需判断 res.ok超时原生timeout属性需 AbortController 等方式实现上传进度原生支持支持较弱请求取消xhr.abort()AbortController默认携带 Cookie同源下默认携带默认不携带需credentials配置最后一行那个差异是很多登录态突然丢了问题的来源。Fetch 默认不带 Cookie接口返回一个未登录的重定向或者 401前端一脸懵。需要带上凭据时要显式设置credentials: include同时服务端的 CORS 响应头也不能用通配符必须写明具体来源。4. 一次请求从浏览器到 Spring Boot 的完整旅程4.1 浏览器端从一行调用到一份请求报文你在代码里写下的那行fetch(/api/users/1)在浏览器内部会被拆解成好几步。第一步是解析这个相对地址结合当前页面的 origin 补全成完整 URL顺便处理掉路径里的..、多余斜杠这些。第二步是根据 URL 判断是否需要走跨域流程——如果目标源和当前页不同源简单请求直接发非简单请求要先发一个 OPTIONS 预检。第三步才是组装请求报文请求行方法 路径 协议版本、请求头Host、User-Agent、Accept、Content-Type、Cookie 等、请求体。这里有个经常被忽略的事实请求头里有相当一部分是浏览器自动加的你控制不了。比如Host、Origin、Content-Length、User-Agent还有以Sec-开头的那一批安全相关头。有些头属于受限头脚本设置了也会被浏览器忽略这就是为什么有些需要自定义头部的场景在浏览器里怎么都调不通换成服务端调用就正常。4.2 出网路上DNS、连接、TLS 各管一段请求报文组装好接下来是真正往外走。这一路上有好几段任何一段出问题你在前端看到的都是同一句网络请求错误。DNS 解析把域名换成 IP。这一层失败通常表现为无法解析主机名页面上给出的是最笼统的失败提示。建立连接和目标 IP 的端口握手。端口不通、对方没在监听、被中间设备拦截都会在这一层失败。TLS 握手如果是 https还要完成证书校验和密钥协商。证书过期、证书域名不匹配、系统时间不对都会导致这一层失败而报错信息和连接被拒长得完全不一样。发送请求与等待响应这才是我们平时最关注的那一段。我遇到过好几次本地开发好好的部署上去就请求失败最后查出来是服务器的系统时间偏了几个小时导致 TLS 证书校验失败。这类问题在前端代码里怎么改都没用必须往链路层面看。提示这类失败和 pip 报的could not fetch url https://pypi.org/simple/pip/在排查思路上是同一套——先确认域名能不能解析再确认连接能不能建立再看返回的状态码。客户端换了链路结构不会变。4.3 服务端Spring Boot 收到请求后做了什么请求到达服务端之后很多人以为进 Controller 就完事了其实前面还有一串。以 Spring Boot 的常见链路为例大致是这样内嵌容器接收请求先到内嵌的容器如 Tomcat由它解析 HTTP 报文包装成请求对象。进入 DispatcherServlet这是核心调度入口所有请求先到这里。HandlerMapping 查找处理器根据路径和方法匹配到对应的 Controller 方法。参数解析这一步是绝大多数参数收不到问题的现场。RequestParam、PathVariable、RequestBody分别由不同的解析器处理它们对请求的要求完全不同。执行方法并返回拿到返回值后由消息转换器序列化成响应体。写回响应设置状态码、响应头、响应体交给容器发出去。第 4 步值得展开说。RequestBody的解析依赖请求头里的Content-Type如果前端发的是application/x-www-form-urlencoded而方法参数上写了RequestBody那服务端拿到的会是一个空的或者解析失败的对象表现就是字段全是 null而不是报一个明确的错。反过来前端发 JSON服务端用RequestParam逐个接同样接不到。这个配对关系搞不清楚就会陷入我明明传了值的循环。4.4 响应回来状态码、响应头、响应体的分工响应回到浏览器之后前端能拿到的东西分三块。状态码决定这次通信在 HTTP 层面算成功还是失败响应头里带着内容类型、缓存策略、CORS 授权信息响应体才是业务数据。Fetch 那套设计之所以不自动 reject正是因为状态码属于HTTP 层的判断而业务是否成功属于应用层的判断两者被刻意分开了。你在前端做错误处理时也应该分成两层HTTP 层的失败用res.ok兜住应用层的失败看响应体里约定的业务码。这两层混在一起处理代码很快就会乱成一团。还有一个细节响应体只能被读一次。res.json()和res.text()都是消费响应体的操作调用过一次之后再调另一个会报body already read。如果你需要在日志里打印原始文本又需要解析成 JSON就得先await res.text()然后自己JSON.parse或者在读到文本之后用new Response(text)重新包一个。5. 编码格式与参数传递报错信息的根源大多在这里5.1 Content-Type 是后端选解析器的依据请求头里的Content-Type不是一个参考信息它是指令。服务端会拿它来决定用哪个消息转换器、按什么规则解析请求体。常见取值就那么几个application/x-www-form-urlencoded键值对形式形如a1b2表单默认格式。multipart/form-data带边界分隔符文件上传必用。application/jsonJSON 文本前后端分离项目的主流选择。text/plain纯文本没约定格式时的兜底。同一个请求体换个Content-Type服务端解析出来的东西可能完全不同甚至直接解析失败。我见过最典型的一种情况是用 Fetch 发了一个对象字面量作为 body没设置Content-Type浏览器自动把它转成了字符串[object Object]服务端收到一坨毫无意义的内容。正确的做法是发 JSON 时显式设置头部并且手动JSON.stringify// 错误body 是对象Content-Type 默认变成 text/plain fetch(/api/user, { method: POST, body: { name: 张三 } }); // 正确手动序列化 手动声明类型 fetch(/api/user, { method: POST, headers: { Content-Type: application/json;charsetUTF-8 }, body: JSON.stringify({ name: 张三 }), });5.2 三种提交格式的实操对照把这三种格式放在一起对比参数能不能被正确接收一眼就能看明白。格式前端写法服务端接收方式典型误用form-urlencodedURLSearchParams或 jQuery 默认RequestParam或对象接收用RequestBody接不到JSONJSON.stringify 设置头RequestBody接对象头部没设解析成[object Object]multipartFormData不要手动设 Content-TypeRequestParam(file) MultipartFile手动设了 JSON 头边界丢失表格第三列那个手动设了 JSON 头是很常见的错误。用FormData的时候Content-Type必须由浏览器自己生成因为它要在里面带上随机边界字符串。你一旦手动覆盖边界信息就没了服务端根本切不开各个部分表现就是文件字段为 null。至于给 Ajax 请求参数赋值这件事其实就是在拼请求体。用 jQuery 的话data传对象会被自动序列化成 form-urlencoded用原生 XHR 的话你得自己拼或者用URLSearchParamsconst params new URLSearchParams(); params.append(page, 1); params.append(size, 20); params.append(keyword, 搜索词); fetch(/api/list, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded }, body: params.toString(), });URLSearchParams的好处是会自动做 URL 编码中文、空格、特殊符号都不会出问题。手动拼字符串的时候如果忘了encodeURIComponent一旦参数里带个或者整个请求的语义就变了这类问题特别隐蔽因为开发时用的测试数据往往很简单。5.3 编码格式设错之后的表现清单把常见症状和原因对应起来排查效率会高很多现象大概率原因后端字段全是 null无报错Content-Type 和接收注解不匹配收到[object Object]body 传了对象但没序列化中文变成乱码字符集没声明或者前后端字符集不一致文件字段为 nullFormData 的 Content-Type 被手动覆盖解析报 JSON parse errorbody 不是合法 JSON比如多了尾逗号参数里带后截断手动拼参数没做 URL 编码注意中间件和网关有时会重写请求体导致长度或格式变化。如果你确认前端发的内容没问题服务端还是收不到可以让后端把原始请求体整个打出来看一眼这一步往往能省下大半天。6. 从报错原文倒推问题几类高频失败场景的排查链路6.1 上传失败网络请求错误这种模糊提示怎么定位这类提示最大的问题是它把几种完全不同的失败合并成了一句话。我通常按这个顺序拆先看控制台 Network 面板里这条请求存不存在。如果列表里根本没有这条记录说明请求在发出之前就被拦下了重点查代码逻辑、请求前置校验、以及是否有拦截器提前返回。如果记录存在但状态是(failed)或者(canceled)说明请求发出去了但没走完重点查跨域、超时、连接被中断。如果记录存在且有状态码那问题就在响应处理上了。再看请求头里的 Content-Type 和实际发送的 body 是否匹配。这是我在开头那个例子里最终定位到的地方也是实战中最常见的。你可以在 Network 面板的 Payload 标签里直接看到浏览器实际发出去的内容对着它看比看代码更快。最后看服务端日志有没有对应记录。如果一条都没有说明请求根本没到业务代码往容器层、网关层、路由配置上找。如果有记录但报异常那异常信息就是最直接的线索。6.2 403 和跨域预检请求压根没进业务代码这两类问题的共同点是——服务端业务代码一行都没执行。403 意味着对方明确拒绝了这次访问可能是鉴权信息缺失、可能是来源不在白名单里、也可能是访问的路径本身受保护。跨域预检失败则更隐蔽一些浏览器会先发一个OPTIONS请求如果这个请求的响应里没有正确的允许头真正的请求压根不会被发出去。排查跨域时我习惯先在 Network 面板里找那个OPTIONS请求看它的响应头里有没有允许来源、允许方法、允许头部这几项。很多人只配了允许来源忘了允许头部结果带自定义头部的请求全部失败而普通 GET 请求却正常于是误判成跨域没问题。还有一种情况是预检请求被服务端的拦截器或者认证过滤器拦住了直接返回 401 或 403。这会导致预检失败浏览器报跨域错误但真正的问题在认证配置上。解决办法通常是让预检请求绕过认证逻辑因为它不携带业务凭据。6.3 代码包大小超限与请求体被截断代码包大小超过限制这类提示指向的是请求体或者部署产物的体积问题跟请求格式没关系。如果是上传场景通常要同时检查三个地方的限制前端有没有做大小校验、服务端容器的请求体上限、以及网关或反向代理层的大小限制。这三层的限制是叠加的任何一层拦下来表现都是上传失败但日志出现的位置完全不同。我在排查这类问题时会先把限制值都列出来然后按从外到内的顺序逐个排除。最外层的限制往往最容易被忽略因为它的配置不在项目代码里而在部署环境里。顺便说一个通用经验任何xhr 报错但看不出原因的情况都值得先确认一下请求体有没有被中途改动。有些场景下请求会被压缩、被重新编码、被中间层改写长度对不上就会直接失败。6.4 把失败案例抽象成一条通用的排查链不管是浏览器的 fetch 报错、Ajax 上传失败、还是命令行工具拉取远程索引失败排查链条其实是同一条请求有没有构造成功参数、头部、body 格式是否正确请求有没有发出去目标地址能不能解析、端口通不通对方有没有响应状态码是多少响应头里有没有拒绝信息响应有没有被正确处理前端有没有判 ok、有没有读错响应体格式业务层有没有正常执行服务端日志里有没有对应记录。我自己的习惯是遇到任何一句含糊的失败提示先把这五步在纸上写下来然后从第 2 步开始验证因为第 1 步和第 4 步是代码问题改起来最快通常也是最先被排除的。真正花时间的往往在第 2 步和第 5 步那才是需要看环境、看日志、看链路的地方。用 XHR 还是用 Fetch其实从来不是这类问题的分水岭。我见过用 Fetch 写得很规范的项目也见过用 XHR 写得层层封装、出错时连原始请求体都找不到的项目。真正决定排查效率的是你对一次请求到底经历了什么这件事有没有清晰的画面感。有了这张图控制台里那句网络请求错误就不再是一堵墙而是一条可以顺着往下走的线索。
返回列表