恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
2024年搞懂Ajax底层流程:XHR、跨域与fetch/axios封装
首页
资讯中心
/
2024年搞懂Ajax底层流程:XHR、跨域与fetch/axios封装
2024年搞懂Ajax底层流程:XHR、跨域与fetch/axios封装
发布时间:2026/10/20 8:51:16
1. 为什么2024年了还要抠Ajax的底层流程前端圈子里有个挺有意思的现象新入行的朋友上来就用 axios 或 fetch发个请求拿个数据三行代码搞定根本不知道浏览器底层还跑着一个叫 XMLHttpRequestXHR的对象。等到哪天真遇到一个老项目、一个奇怪的兼容性需求、或者要自己封装一个请求库的时候才发现离开封装之后自己对“一个请求到底是怎么从浏览器走到服务器再走回来”这件事几乎是一张白纸。Ajax 的全称是 Asynchronous JavaScript and XML放在 2024 年看XML 早就不流行了JSON 才是主流但“异步 JavaScript 请求”这个核心思想一点没过时。我在实际调接口和排查线上问题的时候凡是能快速定位问题的人几乎都有一个共同特征他们能熟练说出 XHR 从 open、send 到 onreadystatechange 触发的完整状态流转过程也清楚请求头里每个字段的语义。这不叫“学术”这叫排障基本功。这篇文章不打算讲“怎么用 axios 一天调十个接口”而是把 Ajax 的原生 XHR 实现从头到底拆一遍请求流程怎么串、响应数据怎么到手、Content-Type 和跨域这些老坑到底坑在哪、超时和取消怎么优雅处理最后再聊聊 fetch 和 axios 跟 XHR 之间到底是什么关系。适合谁看刚入门前端没多久想彻底搞懂网络请求这块的新人也适合写了两三年业务、但一到原生 XHR 就发怵的同学。我尽量用踩坑的方式讲而不是抄一遍文档。2. Ajax 请求的完整生命周期从一个 XHR 实例的诞生说起2.1 先搞清楚浏览器帮你做了什么在写代码之前建议先建立一个整体认知。一个 Ajax 请求从发起到结束要经历下面这几个阶段创建 XHR 实例此时浏览器内部会初始化一个请求对象但还没跟服务器建立任何联系。调用 open()这是“配置阶段”。请求方法、URL、是否异步、用户名密码这些参数在这里确定。调用 send()这是“启动阶段”。请求体被发送出去浏览器开始等待响应如果 send() 不调用整个流程永远不会启动。浏览器通过网络栈跟服务器交互期间 XHR 实例的 readyState 会不断变化通过事件通知你进度。响应到达后浏览器把响应体交给 XHR 对象触发 load 事件如果有错误触发 error、abort 或 timeout。你在回调里检查 status 和 response结束。这里我想强调一个容易误解的点XHR 的 readyState 变化并不等同于网络层面的状态变化。readyState 是 XHR 对象自己维护的一组状态机3 表示“交互中”也就是响应体还在接收4 表示“完成”也就是所有数据都拿到了。很多人一看到 readyState 为 4 就直接拿数据结果发现 responseText 是空的就是因为没检查 HTTP status。两者不是一个维度必须分开判断。2.2 经典五步走从创建到拿到数据下面这段代码是 XHR 最标准的写法我建议每个人都手敲一遍不要复制敲完你基本就记住了整个流程const xhr new XMLHttpRequest(); xhr.open(GET, https://api.example.com/users, true); xhr.onreadystatechange function () { if (xhr.readyState 4) { // 请求完成 if (xhr.status 200 xhr.status 300) { console.log(xhr.responseText); } else { console.error(请求失败HTTP 状态码, xhr.status); } } }; xhr.send();注意几个细节open() 的第三个参数 async 传了 true表示异步。如果传 false变成同步请求会阻塞主线程浏览器甚至会弹警告。除非极端情况否则永远用 true。事件回调里先判断 readyState再判断 status顺序不能反。响应文本在 responseText 里如果设置了 responseType就要用xhr.response拿。在这个基础上再升级一个更现代的写法用onload代替onreadystatechange事件触发时机是 readyState 变成 4 之后代码更干净。但这里有个隐藏差异onload 在网络层错误时不会触发只有下层的 onerror 会触发。所以在实际项目中我经常是 onload 和 onerror 一起写。const xhr new XMLHttpRequest(); xhr.open(GET, https://api.example.com/users, true); xhr.onload function () { // 注意onload 里已经处于 readyState 4 if (xhr.status 200 xhr.status 300) { handleData(xhr.responseText); } else { handleHttpError(xhr.status); } }; xhr.onerror function () { // 底层网络错误比如断网、DNS 解析失败、跨域被浏览器拦截 handleNetworkError(); }; xhr.send();这里面的 onerror 一定要写。我排查过不少线上事故请求“静默失败”用户看到空白页控制台什么都没有后端也没有日志最后发现是 onerror 没处理某个阶段网络波动了一下请求直接死在半路。2.3 参数设置open 和 setRequestHeader 的正确打开方式open() 的完整签名是xhr.open(method, url, async, username, password);后两个参数用的场合极少只有需要 Basic Auth 的服务才用得上。很多人有个误解以为 open() 之后请求就发了其实没有。open() 只是配置真正发请求的是 send()。所以想设置请求头必须在 open() 之后、send() 之前调用 setRequestHeader这个顺xù错了请求头发不出去const xhr new XMLHttpRequest(); xhr.open(POST, https://api.example.com/login, true); xhr.setRequestHeader(Content-Type, application/json); xhr.setRequestHeader(X-Requested-With, XMLHttpRequest); const payload JSON.stringify({ username: demo, password: 123456 }); xhr.send(payload);还有一个稍冷门但很实用的知识点如果你对同一个 header 名字调用了多次 setRequestHeader浏览器会帮你合并比如 Cookie 可能有多个值合并成逗号分隔但有些 header 是你设不了的比如 Origin、Host、Content-Length这些由浏览器自己控制你设了也会被忽略。我在教学时见过有人试图用 setRequestHeader 改 Referer 或 Origin 来“模拟跨域”这是做不到的浏览器层面就直接拦掉了。3. 数据从请求到响应Content-Type 和各种 response 读取方式3.1 Content-Type 填错后端真的会“装死”这一节可能是很多人最需要的。前后端联调时数据“传过去”了后端却说没收到或者后端拿不到正确的字段。问题十有八九出在 Content-Type。Content-Type 告诉服务器“我这包数据是什么格式”。常见的三种Content-Type请求体格式后端一般怎么取典型场景application/x-www-form-urlencodednameabcage18request.body表单解析普通表单提交multipart/form-data二进制分块有 boundary 分隔按文件/字段解析上传文件application/jsonJSON 字符串按 JSON 解析RESTful 接口最经典的坑是后端接口定义成接收 JSON你前端忘了 setRequestHeader(Content-Type, application/json)结果 XHR 默认用了text/plain;charsetUTF-8后端框架解析不到请求体返回“参数缺失”。或者反过来后端是表单接口你传了 JSON 字符串后端把整个字符串当成一个 key数据自然是乱的。有一个还算常见的做法是用 URLSearchParams 构造表单格式的数据既方便又能避免手动拼接字符串const params new URLSearchParams(); params.append(username, demo); params.append(age, 18); const xhr new XMLHttpRequest(); xhr.open(POST, https://api.example.com/form, true); xhr.setRequestHeader(Content-Type, application/x-www-form-urlencoded); xhr.send(params.toString());3.2 responseType 不设置你拿到的可能只是“半个数据”默认情况下XHR 把响应体当作文本字符串放进 responseText。如果你请求的是二进制图片、ArrayBuffer、或者 JSON直接读字符串会非常别扭。解决方案是设置 responseTypeconst xhr new XMLHttpRequest(); xhr.open(GET, https://api.example.com/file, true); xhr.responseType json; // 浏览器自动把响应体反序列化成 JS 对象 xhr.onload function () { if (xhr.status 200) { console.log(xhr.response); // 已经是对象了可以直接操作 } }; xhr.send();这里有个小坑responseType 设置成 json 之后如果响应体不是合法 JSONxhr.response 为 null但 onload 照样触发。所以别以为设置了类型就万事大吉业务代码里还得对 null 做兜底判断。responseType 支持的值包括 text、json、document、arraybuffer、blob。下载文件时用 blob做图片处理用 arraybuffer接口数据优先用 json。注意一点responseType 必须在 open() 之后、send() 之前设置而且有些浏览器对“先读 responseText 再改 responseType”的行为会有警告顺序别搞反。3.3 响应状态码的“灰色地带”HTTP 状态码 200 是大多数人心里的“成功”但实际项目中远不止这么简单。常见的处理策略2xx 全家桶都算成功但 201 Created 表示资源创建成功204 No Content 是“有响应头但没有响应体”你要是强行读取 responseText会得到一个空字符串这不见得是 Bug。304 Not Modified 是缓存命中表示“你本地缓存还能用”对业务来说也算成功但要判断是不是走了缓存。3xx 重定向XHR 默认会跟随重定向你在回调里基本感知不到这个过程。4xx 是客户端问题最好针对 401、403、404 分别做提示文案。5xx 是服务端问题常见做法是统一弹“服务器开小差”同时把请求 ID 记录下来便于排障。我习惯先判断status 200 status 300 || status 304这套写法兼容性最好。另外要知道status 为 0 的情况如果请求在跨域时被 CORS 拦截或者发生了 abort / timeoutstatus 经常是 0。这也是为什么 onerror 里要单独处理别只看 status。4. 跨域问题不是请求没到是被浏览器“截胡”了4.1 同源策略到底在保护什么很多人第一次遇到跨域是在前后端分离项目里本地开发地址是 localhost:3000后端接口在 remote:8080发个请求控制台报错CORS 字样一出来头皮发麻。有个理解上的误区跨域报错不代表请求没发出去。浏览器其实把请求发出去了服务器也处理了但浏览器发现响应头里没有允许跨域的标记于是把响应“扣下”然后在控制台报个错。所以排查跨域问题时光在前端调是没用的要去后端响应头里加字段Access-Control-Allow-Origin: https://your-frontend-domain.com同源策略保护的是“你已登录的 Cookie、LocalStorage 等敏感信息不被第三方网站读取”。举个例子你在银行站点登录了如果任何站点都能向银行接口发请求并且读取响应那随便一个恶意网页都能把你的余额抓走。浏览器拦截跨域响应就是为了防这个。4.2 简单请求 vs 预检请求为什么有的请求发两次CORS 把请求分成两类。满足以下条件的属于“简单请求”GET/HEAD/POST 方法且 Content-Type 只允许三种值application/x-www-form-urlencoded、multipart/form-data、text/plain且没有自定义请求头。简单请求直接发浏览器在响应头里找 Access-Control-Allow-Origin找不到就拦截。一旦你的请求带有自定义请求头比如 Authorization或者 Content-Type 是 application/json又或者用了 PUT/DELETE 方法浏览器会先发一个 OPTIONS 预检请求询问服务器“我可以这么发吗”服务器返回允许后浏览器才发真正的请求。这就是为什么你明明只发了一个 POSTNetwork 面板里却出现两个请求。预检请求失败真实请求压根不会发出报错信息里通常写着“Response to preflight request doesnt pass access control check”。4.3 JSONP一个古老但能救命的技术CORS 是标准做法但万一后端接口就是不改响应头而且是个老系统怎么办JSONP 提供了一种“绕过”方案——利用script标签不受同源策略限制的特性。你动态插入一个 script 标签src 指向接口地址服务器返回的是一段 JS 代码把数据包在一个回调函数里调用。function jsonp(url, callbackName, params) { return new Promise((resolve, reject) { const script document.createElement(script); const callback jsonp_cb_ Date.now(); window[callback] function (data) { resolve(data); script.remove(); delete window[callback]; }; const query new URLSearchParams({ ...params, callback }).toString(); script.src ${url}?${query}; script.onerror reject; document.head.appendChild(script); }); }JSONP 有几个硬伤只能支持 GET因为它是加载脚本所以拿不到 HTTP 状态码而且线上环境要对 callback 做处理防止全局变量名字冲突。但确实有些老系统只留了 JSONP 的口子这种场景下这个方案依然是有效的。5. 超时、取消、重试与异常处理完整的工程化方案5.1 超时和取消很多人以为设置了就完事XHR 提供了超时属性和取消方法但它们的语义经常被搞混xhr.timeout设置的是“等待响应的最长时间”单位毫秒。一旦超时会触发ontimeout事件同时请求被中止。xhr.abort()是手动取消请求触发onabort事件。注意abort 后readyState 会变成 0。工程上有个常见需求搜索框输入用户输入节奏很快上一次请求还没回来不能等它应该直接取消。代码长这样let currentXhr null; input.addEventListener(input, function () { if (currentXhr) { currentXhr.abort(); // 取消上一次未完成的请求 } currentXhr new XMLHttpRequest(); currentXhr.open(GET, /api/search?q encodeURIComponent(input.value), true); currentXhr.onload function () { if (currentXhr.status 200) { renderResult(currentXhr.responseText); } }; currentXhr.send(); });有一个小陷阱调用 abort() 之后旧的 XHR 对象实例不能再复用必须重新 new 一个。而且 abort 会触发 onreadystatechange此时 readyState 可能已经不是 4回调里如果没判断状态可能会跑进去拿一个空的响应。5.2 重试策略时间退避的简单实现接口偶发 500或者网络偶发波动弹一个“加载失败”很容易但对用户体验不太好。合理做法是引入重试机制。这里分享一个带时间退避的简易实现function requestWithRetry(options, maxRetries 3) { return new Promise(function (resolve, reject) { function attempt(remaining) { const xhr new XMLHttpRequest(); xhr.open(options.method, options.url, true); Object.entries(options.headers || {}).forEach(([k, v]) xhr.setRequestHeader(k, v)); xhr.timeout options.timeout || 10000; xhr.onload function () { if (xhr.status 200 xhr.status 300) { resolve({ status: xhr.status, data: options.responseType json ? xhr.response : xhr.responseText }); } else { reject({ status: xhr.status, message: HTTP Error }); } }; xhr.onerror function () { if (remaining 0) { const delay Math.pow(2, options.maxRetries - remaining) * 500; setTimeout(() attempt(remaining - 1), delay); } else { reject(new Error(Network Error after retries)); } }; xhr.ontimeout function () { if (remaining 0) { setTimeout(() attempt(remaining - 1), 1000); } else { reject(new Error(Timeout after retries)); } }; xhr.send(options.body || null); } attempt(options.maxRetries); }); }要点是重试只针对网络层错误和超时不要对 HTTP 4xx 做重试那是参数问题重试一百次也一样会失败。即便对 5xx重试次数也不宜太多一般 2-3 次就够避免雪球效应把后端打挂。这个简易函数里我特意没有对 onload 里的非 2xx 做重试分支原因就在这。生产环境要加“是否应该重试”的白名单判断别什么都重试。5.3 常见异常排查清单调试 Ajax 请求时我习惯打开浏览器开发者工具从 Network 面板入手结合控制台信息做判断。下面是一份经验型的排查清单适合贴到团队文档里现象可能原因排查方向请求一直 pending 不返回后端没处理 / 代理超时看后端日志看代理层请求显示 canceled主动调用了 abort() / 页面跳转检查业务代码里哪里有 abort控制台报 CORS 错响应头缺 Access-Control-Allow-Origin让后端加响应头或走代理请求发出但后端没收到参数Content-Type 不匹配核对请求体格式和后端解析方式status 为 0跨域被拦截 / 请求未完成看控制台具体报错明明 200但 response 是空响应体就是空的或 responseType 设置异常看响应内容的具体字节这部分内容写出来是因为我见过太多团队同学把时间耗在无意义的瞎试上。掌握这个排查链路比背十个 API 方法都管用。6. 从 XHR 到 fetch 到 axios到底该怎么选6.1 fetch 真的比 XHR 好吗经常有人问我fetch 出现之后XHR 是不是就该淘汰了我的看法是fetch 确实更现代、更简洁返回值是 Promise配合 async/await 写起来很舒服但它在几个方面并不比 XHR 强fetch 默认不携带 Cookie需要手动设置credentials: include。很多新手从 XHR默认同源带 Cookie切到 fetch突然发现登录态丢了一脸懵。fetch 对 HTTP 错误状态码不会 reject依然返回一个 Response状态 404 也走 then。很多人以为 fetch 会自动把非 2xx 当异常实际不会需要自己判断res.ok。fetch 的超时机制需要配合 AbortController 才能实现XHR 自带 timeout 属性。fetch 没有内置的上传进度事件XHR 有upload.onprogress。做文件上传进度条原生 fetch 很别扭。所以结论是fetch 不是 XHR 的简单替代品两者有各自的适用场景。静态数据请求、接口调用、微服务网关这些fetch 够用文件上传、实时进度、复杂请求控制XHR 反而更顺。6.2 axios 到底做了什么封装axios 被用得最多它的核心是基于 XHR 做了一层 Promise 封装。它做的事可以概括为把 XHR 的回调地狱改成 Promise 链、在浏览器端自动判断是否设置 Content-Type、拦截器支持请求和响应统一处理、超时和取消封装得更好用、对响应状态统一抛错。它的核心适配器在浏览器里仍然调的是 XMLHttpRequest 对象。理解了 XHR 底层你再看 axios 的源码就不会觉得玄乎。它的核心就是一个老模式把 XHR 的各种事件翻译成 Promise 的 resolve 和 reject。你遇到 axios 上传进度条不知道怎么写本质上就是因为它暴露的onUploadProgress是从 XHR 的upload.onprogress转发过来的。顺便说一句axios 在同构场景还有一个优势Node.js 环境里没有 XHR它自动切到 http 模块实现浏览器环境里用 XHR。这种适配能力是 fetch 做不到的——因为 Node.js 18 以上才原生支持 fetch。6.3 生产环境的选型建议给一个比较务实的参考标准新项目、团队熟悉异步语法优先 fetch 或 axios代码简洁心智负担小。老项目维护、浏览器兼容性要求高就老实继续用 XHR或者基于 XHR 封装一层。文件上传、下载进度条、断点续传直接用 XHR别折腾 fetch。自己写一个请求库、需要深度控制请求头、拦截、队列基于 XHR 封装最稳因为它的事件和属性最贴近底层。这三个工具没有“谁碾压谁”的关系。我在团队里经常说一句话如果一个人能把 XHR 的事件、状态、请求头这些底层机制讲清楚那么他写 axios 封装的代码和只会“照着文档抄”的人写的完全是两个质量等级。前者出了问题看一眼报错就知道是请求头、跨域还是数据格式的问题后者只会换一个工具试。写到这我把 Ajax 的整个流程基本拆完了。最后再说一个实际开发中的小技巧调试的时候可以在 open 之后、send 之前临时给 XHR 加一层onreadystatechange把每次状态变化打出来xhr.open(GET, url, true); // 临时调试代码 xhr.onreadystatechange function () { console.log(readyState:, xhr.readyState, status:, xhr.status); }; xhr.send();这样你能非常直观地看到请求从配置到完成的完整过程也就能深刻理解之前说的 readyState 和 HTTP status 是两个维度这件事。Ajax 不是难它只是有很多细节藏在文档没强调的地方。把这些细节攒齐了网络请求这块你基本就能稳着走了。