☰
Ajax、XHR、Fetch 全解析:原理、实战选型与踩坑
2026/10/1 1:15:33 网站建设 项目流程

XHR、Ajax、Fetch 这几个词,几乎每个写页面的人都听过,可真要坐下来把它们的来龙去脉讲清楚,不少人还是会卡壳。有人以为 Ajax 是一门编程语言,有人觉得 Fetch 就是 XHR 换了个名字,还有人写了几年$.ajax,却从来没直接碰过XMLHttpRequest对象本身。这篇东西就是想把这三者从头到尾捋一遍:它们分别是什么、各自解决什么问题、在真实项目里怎么写、踩过哪些坑,以及一次网络请求从前端发出、到后端处理完再返回,中间到底经历了哪些环节。不管你是刚入门的新人,还是写了好几年业务代码、想补补底层原理的老手,应该都能在里面翻到点有用的东西。我会尽量用大白话加代码片段的方式,把每个关键点都落到实处,而不是停在概念层面空转。

1. 先把概念理清楚,别一上来就写代码

1.1 一个常见误区:Ajax 不是某个具体的 API

Ajax 的全称是 Asynchronous JavaScript and XML,直译过来就是“异步 JavaScript 和 XML”。注意它的定语是“异步”,核心描述的是一个模式、一套技术组合,而不是某一个函数或者某一个对象。它的核心思想非常朴素:在不刷新整个页面的前提下,用 JavaScript 悄悄和服务器交换数据,拿到结果之后再局部更新页面。早期的经典案例是邮箱和地图类应用,它们让网页第一次有了“不用整页重载也能变化”的体验,这在当时是挺震撼的。

很多人第一次听到 Ajax 是在 jQuery 的文档里,看到$.ajax那一串参数,就下意识认为 Ajax 等于 jQuery 的一个方法。实际上,$.ajax只是 jQuery 对原生XMLHttpRequest做的一层封装,把繁琐的状态监听、参数拼接、回调组织给你包好了。真正发请求的底层,是浏览器提供的XMLHttpRequest对象。理解这一点非常关键,因为后面你会看到 Fetch 也处在同样的位置——它只是另一种发起请求的方式,和 Ajax 这个概念根本不是一个层级的比较对象。

提示:把概念分层来记,能帮你少绕很多弯路。异步通信这种模式叫 Ajax;底层实现可以选 XHR,也可以选 Fetch;jQuery 的$.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 表示对象已创建但还没调用open;1 表示已经调用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?page=1&size=10', 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;charset=UTF-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,后端按表单去解析,就会拿到一堆解析不出来的怪东西。反过来,如果你写表单格式a=1&b=2,头里却写 JSON,同样会解析失败。关于charset=UTF-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;charset=UTF-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()直接支持部分支持AbortController
HTTP 错误处理手动判断 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这个请求头的本质,是在告诉服务端“我这份数据是什么格式、用什么字符集编码的”,服务端据此选择合适的解析器。字符集部分尤其关键,中文乱码十有八九是这里没对齐。前端发charset=UTF-8,服务端却按 GBK 去解,结果就是满屏问号。虽然现在大多数框架默认 UTF-8,但当你的请求经过网关、经过中间层转发时,配置不一致的情况并不少见。

实操上,我建议所有涉及文本的请求都显式写明字符集,尤其是 JSON 和表单两种格式。JSON 的标准建议用application/json;charset=UTF-8,表单用application/x-www-form-urlencoded;charset=UTF-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、跨域这些疑难杂症时特别管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询