
XHR、Ajax、Fetch 这几个词几乎每个写页面的人都听过可真要坐下来把它们的来龙去脉讲清楚不少人还是会卡壳。有人以为 Ajax 是一门编程语言有人觉得 Fetch 就是 XHR 换了个名字还有人写了几年$.ajax却从来没直接碰过XMLHttpRequest对象本身。这篇东西就是想把这三者从头到尾捋一遍它们分别是什么、各自解决什么问题、在真实项目里怎么写、踩过哪些坑以及一次网络请求从前端发出、到后端处理完再返回中间到底经历了哪些环节。不管你是刚入门的新人还是写了好几年业务代码、想补补底层原理的老手应该都能在里面翻到点有用的东西。我会尽量用大白话加代码片段的方式把每个关键点都落到实处而不是停在概念层面空转。1. 先把概念理清楚别一上来就写代码1.1 一个常见误区Ajax 不是某个具体的 APIAjax 的全称是 Asynchronous JavaScript and XML直译过来就是“异步 JavaScript 和 XML”。注意它的定语是“异步”核心描述的是一个模式、一套技术组合而不是某一个函数或者某一个对象。它的核心思想非常朴素在不刷新整个页面的前提下用 JavaScript 悄悄和服务器交换数据拿到结果之后再局部更新页面。早期的经典案例是邮箱和地图类应用它们让网页第一次有了“不用整页重载也能变化”的体验这在当时是挺震撼的。很多人第一次听到 Ajax 是在 jQuery 的文档里看到$.ajax那一串参数就下意识认为 Ajax 等于 jQuery 的一个方法。实际上$.ajax只是 jQuery 对原生XMLHttpRequest做的一层封装把繁琐的状态监听、参数拼接、回调组织给你包好了。真正发请求的底层是浏览器提供的XMLHttpRequest对象。理解这一点非常关键因为后面你会看到 Fetch 也处在同样的位置——它只是另一种发起请求的方式和 Ajax 这个概念根本不是一个层级的比较对象。提示把概念分层来记能帮你少绕很多弯路。异步通信这种模式叫 Ajax底层实现可以选 XHR也可以选 FetchjQuery 的$.ajax、axios 这类只是其中一种封装。1.2 XHR 与 Fetch 的定位差异XMLHttpRequest出现得很早是浏览器最早提供的异步请求能力。它的 API 设计偏事件回调风格用起来步骤多状态管理靠手动监听onreadystatechange或者onload。Fetch是后来加入标准的一套新的请求接口基于 Promise写法更接近现代 JavaScript 的异步风格代码更短更清晰。但两者不是简单的“新替旧”关系各自都有对方早期不擅长的角落。举个具体的点XHR 一直支持上传进度和下载进度也支持随时中断请求abort而 Fetch 在刚推出时对这些支持得并不好后来才通过AbortController和ReadableStream慢慢补上。所以你在一些需要显示进度条、或者需要用户点“取消”就真的中止请求的场景里会看到不少老项目仍然用 XHR这不是守旧而是有实际考量。1.3 为什么现在又要聊回原生请求现在前端生态很成熟多数时候我们用 axios 之类的库就够用了为什么还要回头看原生原因有几个。第一是排查问题控制台里报出来的往往是底层的 XHR 或 fetch 行为你不懂底层就只能干瞪眼。第二是轻量场景有些活动页、嵌入页根本不想为了发一个请求就引入一整个库。第三是原理理解很多“玄学 bug”追到根上都是对请求生命周期不清楚。第四是精细控制比如手动设置编码格式、手动处理超时、自定义上传逻辑库的封装反而会挡住你。所以接下来我会把 XHR、jQuery 封装、Fetch 三块分别拆开讲最后再横向对比给你一套能直接抄的选型逻辑。每一块都会配上能跑的代码不会只给片段让你猜上下文。2. 原生 XMLHttpRequest老将的完整请求流程2.1 XHR 的五个就绪状态与生命周期XMLHttpRequest有一个很核心的属性叫readyState它用数字表示当前请求所处的阶段一共五个值。理解这五个值基本就理解了 XHR 的一生0 表示对象已创建但还没调用open1 表示已经调用open连接建立完成2 表示已经调用send请求头已发送、响应头已收到3 表示响应体正在接收4 表示响应全部接收完成。大多数人真正关心的就是 4因为只有到这一步数据才算齐了。配合readyState变化的还有一个status也就是 HTTP 状态码200 表示成功404 表示找不到资源500 表示服务端内部错误。新手最常犯的错就是只判断readyState 4而忘了看status结果服务器返回 404 时也当成成功走了下去。正确的姿势是readyState 4且status在 200 到 299 之间或者等于 304才算成功其余都要进错误分支。这个细节在实际项目里能帮你挡掉一大堆“明明接口挂了页面却显示成功”的诡异问题。2.2 一次完整的 GET 请求该怎么写下面是一段可以原样复制到浏览器控制台运行的 GET 请求代码注释标出了每一步的意图const xhr new XMLHttpRequest(); // 第三个参数 true 表示异步默认就是异步老代码里会显式写出来 xhr.open(GET, https://example.com/api/list?page1size10, true); // 设置超时时间毫秒超过这个时间会自动触发 ontimeout xhr.timeout 8000; xhr.onreadystatechange function () { if (xhr.readyState 4) { if (xhr.status 200 xhr.status 300) { // responseText 是字符串responseXML 在返回 XML 时才有用 console.log(拿到数据, xhr.responseText); } else { console.error(请求失败状态码, xhr.status); } } }; xhr.ontimeout function () { console.error(请求超时了检查网络或调大 timeout); }; xhr.onerror function () { console.error(网络层错误可能是跨域被拦或断网); }; xhr.send();几个容易被忽略的点。open的第三个参数控制同步还是异步虽然写false能做同步请求但浏览器早就警告这种做法会阻塞主线程页面会直接卡死所以永远别用同步模式。send之后才真正发出请求open只是做准备。还有就是onerror和ontimeout是两个不同的事件超时不会走到onerror如果你只监听onerror超时的情况就会静默丢失这也是很多“请求没反应但也没报错”的根源。2.3 POST 请求、请求头与编码格式设置POST 比 GET 多了两件事一个是设置请求头一个是往send里塞请求体。中文乱码、后端收不到参数、参数变成一坨字符串这些问题几乎都和请求头设置有关。最常见的写法是这样const xhr new XMLHttpRequest(); xhr.open(POST, https://example.com/api/save, true); // 告诉服务端我发的是 JSON xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8); xhr.onload function () { if (xhr.status 200) { console.log(保存成功, xhr.responseText); } }; const payload { name: 张三, age: 28 }; xhr.send(JSON.stringify(payload));这里的关键就是Content-Type它决定了服务端用什么方式解析你的请求体。如果你发 JSON 但头里写的是application/x-www-form-urlencoded后端按表单去解析就会拿到一堆解析不出来的怪东西。反过来如果你写表单格式a1b2头里却写 JSON同样会解析失败。关于charsetUTF-8这个尤其重要涉及中文的时候字符集没对齐就是乱码的直接来源。虽然现在多数场景默认就是 UTF-8但显式写上是个好习惯能避免在不同环境之间来回切换时踩坑。还有一种常见格式是multipart/form-data用在上传文件的场景。这个格式比较特殊浏览器需要在内容里加一段随机的 boundary 分隔符所以你尽量不要手动去设Content-Type而是交给FormData对象自动处理const formData new FormData(); formData.append(file, fileInput.files[0]); formData.append(remark, 这是我的说明); const xhr new XMLHttpRequest(); xhr.open(POST, https://example.com/api/upload, true); // 千万别手动 setRequestHeader Content-Type交给浏览器自动加 boundary xhr.send(formData);我这几年见过太多上传失败是因为手贱去设了Content-Type: multipart/form-data但没带 boundary浏览器一看你写死了头就默认你懂结果后端解析不出来直接报“上传失败网络请求错误”。记住这条能省你半天排查时间。2.4 上传进度、超时与中断控制XHR 相比 Fetch 的一个传统优势就是对进度的支持。它提供了onprogress事件你可以拿到已经传输的字节数和总字节数算出百分比来画进度条xhr.upload.onprogress function (e) { if (e.lengthComputable) { const percent (e.loaded / e.total * 100).toFixed(1); console.log(上传进度 percent %); } };注意xhr.upload.onprogress监听的是上传xhr.onprogress监听的是下载两个事件别搞混。中断控制靠xhr.abort()调用之后会触发onabort事件你可以在那里把 UI 上的 loading 状态清掉。这套东西在文件上传、大表单提交的场景里非常实用也是很多成熟上传组件底层采用 XHR 而不是 Fetch 的原因。3. jQuery 时代的 Ajax 封装为什么当年那么香3.1 $.ajax 的参数结构拆解在原生 API 还比较磕脚的年代jQuery 的$.ajax几乎成了标配。它把 XHR 的一堆监听、状态判断、数据序列化全给包住了你只要填一个对象就行$.ajax({ url: /api/user/detail, type: GET, dataType: json, // 期望服务端返回 JSON data: { id: 123 }, // jQuery 会自动拼到 URL 上 timeout: 8000, success: function (res) { console.log(成功, res); }, error: function (xhr, textStatus, err) { console.log(失败, textStatus, err); }, complete: function () { // 无论成功失败都会走这里适合收尾 loading } });这里几个参数值得单独说。dataType是 jQuery 帮你做的“自动转换”它声明你期望返回什么类型jQuery 会尝试把响应体解析成对应类型解析失败会走到error所以有时候你会发现success里没事、error里却报了个解析错误那就是返回的内容和dataType对不上。data在 GET 请求里会自动拼成查询串在 POST 请求里则会默认序列化成表单格式这一点很多人不知道导致和后端约定的 JSON 对不上。3.2 $.get、$.post、$.getJSON 的适用场景$.ajax是完整版jQuery 还提供了几个快捷方法。$.get和$.post是最常用的两个语法糖对应最简单的 GET 和 POST 请求$.getJSON则是在 GET 基础上默认dataType为 json。它们的适用场景很清楚接口简单、响应类型确定、不需要精细控制超时和请求头的时候用快捷方法代码短、可读性好一旦需要自定义请求头、处理复杂错误、控制超时就回到$.ajax。我个人的习惯是封装的工具函数里统一用$.ajax因为参数可控、易于统一加 loading 和错误提示而在一些脚本式的临时逻辑里用$.getJSON图个快。团队协作时建议统一避免有人用快捷方法有人用全量方法维护起来风格不齐。3.3 全局事件与 loading 状态管理jQuery 很贴心的一点是提供了全局事件比如$(document).ajaxStart()和$(document).ajaxComplete()。这样你就能写一次 loading 逻辑所有 Ajax 请求共用$(document).ajaxStart(function () { $(#loading).show(); }).ajaxComplete(function () { $(#loading).hide(); });这在多请求并发的页面里特别好用不用每个请求手动开关 loading。但要注意全局事件是“计数器”逻辑多个请求会一起触发如果一个页面同时发出好几个请求loading 会等到最后一个完成才消失这是符合直觉的但如果你期望的是“每个请求各自控制”那还是手动管理更合适。理解这个差异能避免“loading 怎么一直转不停”或者“loading 闪一下就没了”的困惑。4. Fetch API现代浏览器的新标准4.1 Fetch 的 Promise 模型与响应对象Fetch 的写法是第一眼就让人舒服的那种基于 Promise配合async/await更顺async function getUser(id) { const res await fetch(/api/user/ id); const data await res.json(); return data; }这里有两个概念要分清fetch返回的 Promise 解析出来的是一个Response对象它代表的是“服务器已经给了回应”而并不是“数据已经拿好了”。Response是一次性的流要拿到真正的数据还需要再调用一次res.json()、res.text()或者res.blob()这又是一个 Promise。所以你会发现代码里经常连着两个await这是 Fetch 的结构决定的不是啰嗦。Response对象上还有几个有用的属性res.ok是布尔值表示状态码是否在 200 到 299 之间res.status是数值状态码res.headers可以读取响应头不过受同源策略限制跨域时能读到的头部是有限制的。这些属性在写健壮代码时非常关键。4.2 Fetch 不抛 HTTP 错误这个坑这是 Fetch 最著名的一个坑也是无数新手的第一个教训fetch只在网络层出错比如断网、DNS 解析失败、跨域被拦时才 reject而对 HTTP 错误状态码404、500是当作成功处理的。也就是说服务器返回 500你的await fetch(...)不会抛错res.ok会是 false但代码不会自动跳到catch。所以正确的写法必须手动判断async function request(url, options) { const res await fetch(url, options); if (!res.ok) { // 这里才是真正处理 HTTP 错误的地方 throw new Error(请求失败状态码 res.status); } return res.json(); }不写这段判断的后果就是接口挂了页面还傻乎乎地往下走等到后面用数据时报一个莫名其妙的“读取 undefined 属性”错误。这个坑几乎每个从 XHR 或库迁移到原生 Fetch 的人都会踩一次记住它。4.3 请求配置、headers 与 body 的常见写法Fetch 的第二个参数是配置对象常用的有method、headers、body、mode、credentials等。发一个 JSON 的 POST 请求长这样const res await fetch(/api/save, { method: POST, headers: { Content-Type: application/json;charsetUTF-8 }, // body 必须是字符串、FormData、Blob 等不能直接传对象 body: JSON.stringify({ name: 李四 }) });几个容易出错的点。body不能直接传普通对象必须自己JSON.stringify这点和某些库不一样credentials默认是same-origin如果你需要跨域携带 Cookie要显式设置成include否则你会发现登录态丢了mode默认为cors一般不用动。另外Fetch 现在也支持通过AbortController中断请求了const controller new AbortController(); fetch(/api/slow, { signal: controller.signal }); // 需要时中断 controller.abort();这套组合是 Fetch 补齐“可中断”能力后的官方方案在需要用户主动取消的场景里可以用。5. 三个方案横向对比与选型建议5.1 功能维度对比表把三者的关键能力放一起看选型就清晰多了维度原生 XHRjQuery$.ajaxFetch编写风格事件回调和状态监听配置对象加回调Promise / async-await上传进度原生支持依赖 XHR间接支持需用流式读取较复杂请求中断abort()直接支持部分支持AbortControllerHTTP 错误处理手动判断 status自动进 error 回调必须手动判断res.ok默认解析响应需要手动处理按dataType自动需再调用json()/text()依赖体积无需引入 jQuery无超时控制timeout属性timeout参数需配合 AbortController这张表不需要背看的时候对照自己的场景就行。需要上传进度条倾向 XHR维护老项目已经引了 jQuery用$.ajax最省事新项目、现代浏览器环境Fetch 是默认选择。5.2 兼容性与项目场景选型兼容性上XHR 支持面最广远古浏览器都能跑Fetch 在很新的浏览器才完整支持特别老的环境需要 polyfill。所以如果你的用户群体里有大量旧设备XHR 仍然是稳妥方案。但如果是内部系统、移动端现代浏览器、或者 Electron 这类可控环境Fetch 用起来要舒服太多。我的实际经验是新项目一律用 Fetch 打底再包一层统一的请求函数把res.ok判断、超时、错误提示、token 注入都做进去老项目在改造时不要一刀切先在新写的模块里用新方式老模块保持不动避免引入回归风险。这种渐进式策略在真实团队里最不容易出事。6. 编码格式与请求头乱码、403、上传失败的根因排查6.1 Content-Type 与字符集设置的核心逻辑Content-Type这个请求头的本质是在告诉服务端“我这份数据是什么格式、用什么字符集编码的”服务端据此选择合适的解析器。字符集部分尤其关键中文乱码十有八九是这里没对齐。前端发charsetUTF-8服务端却按 GBK 去解结果就是满屏问号。虽然现在大多数框架默认 UTF-8但当你的请求经过网关、经过中间层转发时配置不一致的情况并不少见。实操上我建议所有涉及文本的请求都显式写明字符集尤其是 JSON 和表单两种格式。JSON 的标准建议用application/json;charsetUTF-8表单用application/x-www-form-urlencoded;charsetUTF-8。另外要注意某些库会自动帮你加字符集你手动再加一遍可能变成重复参数个别服务端会因此解析异常所以统一在一处设置就好别多个地方乱加。6.2 常见错误现象与排查速查表实际开发里遇到的请求错误其实翻来覆去就那么几类。整理成表方便对着查错误现象常见原因排查方向请求 403 拒绝访问权限校验失败、referer 或 token 缺失检查鉴权头、Cookie、跨域配置上传失败网络请求错误请求头Content-Type手动写死缺失 boundary用FormData且不手动设该头部代码包大小超过限制服务端或网关设了体积上限压缩资源、分片上传、调大限制中文显示为乱码字符集前后端不一致两端统一 UTF-8显式声明请求无响应也无报错只监听了一种事件超时被漏掉同时监听onerror和ontimeout跨域相关报错响应头缺少跨域许可服务端配置跨域前端确认mode举个例子很多人看到“403”就以为是账号问题其实跨域场景下也可能是服务端拒绝了不合规的请求来源。排查时不要只盯着前端代码把浏览器开发者工具的 Network 面板打开看请求头、响应头、响应体三段大部分问题都能定位到具体哪一环。7. 一个请求到了后端链路与参数绑定7.1 请求进入服务端后的处理流程前端发出去的请求到了后端并不是“直接执行你的业务代码”。它要先经过网络层、再由容器接收、解析请求行和请求头然后根据路径和请求方法匹配到对应的处理函数接着才做参数绑定最后执行你的业务逻辑、组装响应、再原路返回。以常见的 Java 服务为例请求会先经过容器然后由框架的前端控制器按 URL 映射找到对应的方法再根据参数类型从请求中取值。理解这条链路的实际价值在于排查。比如你前端明明发了 JSON后端却收不到参数问题很可能出现在参数绑定阶段——框架期望的是表单格式或者字段名对不上或者缺少必要的注解。知道“参数绑定”这个环节的存在你就能顺着它去找原因而不是在前端反复改了又改。7.2 参数绑定与常见的“收不到参数”问题参数绑定的规则通常是普通参数按名字匹配对象参数按属性名匹配请求体则按内容类型选择解析器。前后端对接最常见的失败就是名字对不上或者格式对不上。前端传userName后端接username大小写差一点就绑不上前端传 JSON 数组后端接单个对象也会失败。我的做法是写接口时先把请求格式用一段能跑通的示例固定下来然后把这段示例直接发给对接的同学双方以它为基准比来回口头描述靠谱得多。还有个小技巧调试接口时先用模拟请求工具把请求跑通确认后端没问题再回头调前端代码这样能把问题范围缩小一半。注意联调阶段最忌讳的就是前后端同时改。约定好请求格式之后先把一端的请求打通再让另一端去适配效率会高很多。8. 我个人在这套东西上的实操体会踩过的坑多了慢慢会形成一些自己的习惯。第一个习惯是不管用哪种方式发请求我都会在项目里包一层统一的请求函数把错误提示、超时、鉴权头、返回值解构这些重复逻辑收进去业务代码里只关心“拿到什么数据”不关心底层是 XHR 还是 Fetch。第二个习惯是涉及上传、下载、进度条的场景我会优先考虑 XHR因为它的进度事件确实省事而 Fetch 要做同样的效果得多写不少代码。第三个习惯是任何和字符集、请求头相关的配置我都会显式写清楚绝不偷懒靠默认值因为默认值在不同环境里可能不一样出问题时最难查。最后再分享一个小技巧当你遇到“请求失败但看不出原因”的情况先把请求复制成 curl 命令在命令行里单独跑一遍。命令行不会帮你隐藏任何细节请求头、响应码、响应体全都摊开给你看往往一眼就能看出是哪个头的问题。这个方法我用了很多年在排查编码格式、403、跨域这些疑难杂症时特别管用。