
1. 聊XHR之前先搞懂它和fetch的区别在哪里先说个现象这几年新入行的前端很多都没正经写过XMLHttpRequest一上来就是fetch甚至axios都用得糊里糊涂的。每次面试问到底层回答基本是axios就是封装了XHR——但你再追问一句XHR的超时事件怎么触发readyState到3的时候到底能拿到什么多半就卡壳了。XHR这个对象1999年就被IE引入了2006年被W3C标准化到现在二十多年过去它依然是浏览器内置的、最稳定、最底层的异步通信手段。fetch虽然长得好看、基于Promise但它在上传进度监听、请求中断、超时控制这几个关键场景上要么做起来麻烦要么根本没有原生的方案。axios能大火恰恰是因为它把XHR的这些能力包装成了好用的API。所以理解XHR不只是为了应付面试更是为了搞清楚你每天都在用的请求库底层到底是怎么跑起来的。还有一个现实问题存量项目里大量老代码还在用XHR甚至有些团队的前端基建完全基于XHR封装。你接手这类项目如果只会fetch连别人封装的请求层都看不懂那谈不上维护和优化。这篇就把XHR的使用从API拆解到实战封装、再到多文件合并这类细节一次讲透适合正在补基础的前端新人也适合需要快速上手维护老项目的开发者。1.1 你以为的“过时”其实没有过时每年都有人喊XHR已经被fetch取代了但这种说法并不准确。fetch确实是更现代的设计Promise语法友好、流式处理能力强但它有几个硬伤第一fetch默认不带Cookie虽然可以通过credentials去开但很多开发者不知道这个差异第二fetch没有内置的上传进度事件想做一个文件上传进度条得靠ReadableStream细抠成本远比XHR高第三fetch只有在网络层错误时才会reject像HTTP 404、500这种响应它照样走resolve坑了一批人以为自己请求成功了。反过来看XHR它对上传进度、下载进度、请求取消、超时控制都有专属的事件和属性支持。这些能力是浏览器层面的标准实现稳定性经过了二十年的验证。所以我的观点是fetch适合在纯请求场景下用一旦涉及上传文件、实时进度反馈、低频请求定制XHR依然是更可靠的选择。这也是为什么很多主流请求库底层依然挂靠在XHR上——不是因为落后而是因为能力覆盖更全面。1.2 从事件模型到“同步/异步”的底层差异XHR与fetch最大的思维差异其实是事件模型。fetch是Promise风格的then/catch链属于注册回调后等结果的单次事件而XHR是事件驱动的对象模型你在它身上注册各种事件监听器然后在整个请求生命周期里会被多次触发。比如readyState每次变化都会触发一次onreadystatechange这给了你细粒度掌控请求状态的可能。另一个容易忽视的点XHR支持同步模式。open(method, url, false)的第三个参数传false请求就会变成同步阻塞模式JavaScript主线程会卡住直到响应返回。这个特性在现代浏览器里虽然还留着但已经被警告了非常多次——同步XHR会严重影响页面交互性能在主线程上执行时浏览器甚至会弹出警告。开发调试时可以临时用一下但生产代码里不要这么写。异步模式才是常态这也是XHR设计中最基础、但又最容易被误解的一层。2. XMLHttpRequest核心API拆解open、send、setRequestHeader一个都别漏所有XHR操作都围绕几个固定的方法展开顺序不能乱参数又各有讲究。很多人照着网上抄了一段代码能跑但换个场景就不知道怎么改根本原因就是API层面的细节没吃透。这一节把XHR的几个核心方法一一拆开讲。2.1 状态机的秘密readyState与statusXHR内部是一个状态机readyState表示请求走到哪一步了它有5个取值readyState含义说明0UNSENT刚创建XHR对象open()还没调用1OPENED已调用open()尚未send()2HEADERS_RECEIVEDsend()已调用响应头和状态码已经可获取3LOADING响应体下载中responseText已有部分内容4DONE请求完成响应体全部拿到status则是HTTP状态码44200、403、404这些。要判断请求是否成功最稳妥的检查方式是把两者组合起来看readyState 4 status 200 status 300。这个在fetch里根本不用你管但在原生XHR里必须自己判断。这里有个实操细节readyState到3时responseText会拿到半截数据。如果传输的是大文件你可以利用这个阶段做数据流量的预览。不过多数场景下我们只需要关心4的状态。还有一点要注意status为0的情况通常发生在请求被abort或跨域被禁止时这属于异常边界要单独处理。2.2 响应类型与数据格式处理responseType和responseresponseText是字符串形式responseXML是XML文档对象但现代开发里我们更多用的是JSON。XHR里有一个responseType属性可以显式指定期望的响应类型可选值包括text、json、document、blob、arraybuffer和ms-stream。设成json之后response属性会直接返回解析好的JavaScript对象比手动JSON.parse(responseText)更顺手。如果返回的是文件流比如下载图片、PDF就设成blob或arraybuffer。我记得有一次给一个报表系统做导出功能后端返回的是二进制文件流我忘了设responseType blob结果拿到的数据全是乱码后来改成blob就正常了。处理响应数据的时候也要考虑兼容性老版本IE不支持responseType json稳妥做法是不设json而是用JSON.parse(xhr.responseText)。对很多老项目还在兼容IE的思路里打转这套写法至今依然通用。2.3 setRequestHeader与请求头设置的时机问题setRequestHeader必须在open()之后、send()之前调用顺序反了会直接抛异常。请求头最常见的就是Content-Type发送纯字符串text/plain;charsetUTF-8发送表单格式application/x-www-form-urlencoded发送JSONapplication/json文件上传用FormData时不要手动设置Content-Type浏览器会自动带上multipart/form-data; boundary...这个boundary是随机生成的你手写反而会让后端解析失败。还有个细节同样的请求头调用多次setRequestHeader不会覆盖而是用逗号拼接。比如你连续两次设置X-Token: abc和X-Token: def最终请求头是abc, def。需要覆盖旧值时要么先用setRequestHeader设置成空字符串再重新设置要么自己维护一个header对象统一管理。3. 实际开发中最容易翻车的三个细节XHR基础用法不难代码几行就能写出来。但真正到了生产环境超时、取消、重试、并发这些场景才是把很多初学者拦住的坎。这几块踩坑经验比API本身值钱。3.1 超时控制timeout属性到底怎么生效原生XHR有一个timeout属性单位是毫秒设置后请求超过这个时间就自动触发ontimeout事件。这个属性能用多久它会同时作用于连接建立阶段和数据接收阶段只要耗时超过设定的值就判定超时。代码模型大概是这样的const xhr new XMLHttpRequest(); xhr.timeout 10000; // 10秒超时 xhr.onload function () { console.log(请求完成, xhr.response); }; xhr.ontimeout function () { console.error(请求超时了请稍后重试); }; xhr.onerror function () { console.error(网络异常); }; xhr.open(GET, /api/user/list, true); xhr.send();这里有个容易踩的坑超时后XHR会继续占用资源吗不会超时事件触发后请求会终止。但如果你不主动监听到超时后调用xhr.abort()某些浏览器可能出现异常行为。我的习惯是ontimeout里主动执行一次xhr.abort()再进入错误提示流程双保险。另外一个真实经验超时时间别设置得太死。网络环境差的时候弱网下请求往返时间本来就长设置3秒超时可能误杀一批正常请求。推荐做法是把超时时间做成配置项在移动端设置15-20秒PC端设置10-15秒再根据业务特性微调。3.2 取消请求与搜索防抖搜索框输入时不断发请求是高频场景之一。老式写法是在每次输入前用一个计数器判断或者干脆没有取消逻辑直接让上一次请求继续飞然后响应乱序返回就出现数据覆盖问题。正确的做法是每次发起新请求前先abort()掉上一次请求。let currentXhr null; function search(keyword) { if (currentXhr) { currentXhr.abort(); } const xhr new XMLHttpRequest(); xhr.onreadystatechange function () { if (xhr.readyState 4 xhr.status 200) { renderResult(JSON.parse(xhr.responseText)); } }; xhr.open(GET, /api/search?keyword encodeURIComponent(keyword), true); xhr.send(); currentXhr xhr; }abort()触发后会进到onabort事件同时readyState变成0status变成0。要注意此时onreadystatechange里的完成判断不应该被当成正常响应处理。所以封装时会用标志位区分是主动取消还是请求结束这个细节新手很容易漏。3.3 重试策略与错误分类请求失败后的重试不能无脑重发。XHR本身没有内置重试机制全部要自己写。我的思路是把错误分成两类可重试错误超时、网络波动onerror、5xx服务端错误不可重试错误4xx客户端错误404、400、401说明请求本身或会话有问题重试多少次都没意义重试的时候还要考虑退避策略最简单的就是指数退避。第一次失败等1秒第二次等2秒第三次等4秒最多尝试3次或5次。这种策略胜在简单又能有效降低瞬时网络抖动带来的影响。function requestWithRetry(method, url, data, maxRetries 3) { let retryCount 0; return new Promise((resolve, reject) { function attempt() { const xhr new XMLHttpRequest(); xhr.onload function () { if (xhr.status 200 xhr.status 300) { resolve(xhr.response); } else if (xhr.status 500 retryCount maxRetries) { retryCount; setTimeout(attempt, Math.pow(2, retryCount) * 1000); } else { reject(new Error(HTTP xhr.status)); } }; xhr.onerror function () { if (retryCount maxRetries) { retryCount; setTimeout(attempt, Math.pow(2, retryCount) * 1000); } else { reject(new Error(Network Error)); } }; xhr.open(method, url, true); xhr.send(data); } attempt(); }); }重试逻辑还要注意幂等问题GET、PUT、DELETE这些幂等请求重试安全POST请求如果后端没做幂等处理重试可能导致重复提交数据。所以做重试前先问一句这接口能重复调吗不能就只做提示不做自动重试。4. 热搜里的“xhr文件合并”到底指什么最近搜“XHR的使用”的时候经常能看到“如何将xhr文件合并”的热词。我刚开始看也懵了一下后来仔细琢磨了一下发现这个词其实指向两个完全不同的场景一个是网络请求层面的合并另一个是代码文件层面的合并。两拨人搜同一个词背后诉求却完全不一样这里分别讲清楚。4.1 网络请求层面的合并多接口请求调度优化Web页面同一个时间点发起十几个XHR请求是很常见的事情。请求太多会占用浏览器并发连接数也会造成网络带宽浪费所以大家会想办法做“合并请求”。最常见的做法有两种方案一接口合并。前端把多个请求参数打包到一个数组里一次性发给后端一个接口后端批量处理并返回数组。这需要后端配合不是纯前端能搞定的。比如原来有三个接口各自返回用户信息、订单数量、消息未读数合并后就是一个接口返回三个模块的数据。优点很明显请求数减少、体积减少、首屏渲染更快。缺点是需要后端改接口而且在模块解耦做得比较好的团队这种“聚合接口”往往会被架构师否决。方案二前端队列调度。不合并接口但不让请求无限制地同时发出。用一个简单的队列维持一个并发数上限比如最多3个请求在飞剩下的排队等待。这种方案本质上是“合并控制权”它不减少请求数量却能把网络的拥堵降到最低。class RequestQueue { constructor(maxConcurrent 3) { this.queue []; this.activeCount 0; this.maxConcurrent maxConcurrent; } push(task) { return new Promise((resolve, reject) { this.queue.push({ task, resolve, reject }); this.next(); }); } next() { while (this.activeCount this.maxConcurrent this.queue.length 0) { const { task, resolve, reject } this.queue.shift(); this.activeCount; task() .then(resolve, reject) .finally(() { this.activeCount--; this.next(); }); } } }这段代码做成了一个通用的并发控制器每个task就是一个执行XHR请求的函数。这个方案很多前端基建里都在用也是我认为“xhr文件合并”比较贴近字面意思的解法——把多个XHR请求汇流到一个调度器里统一管理。4.2 代码文件层面的合并封装工具的打包策略另一种“xhr文件合并”指的是字面意思——开发过程中会把XHR相关的多个JS文件合并成一个文件。比如你有http.js、upload.js、request.js要在一个页面里同时引用为了减少HTTP请求数就得把它们合并成一个文件。这种合并的做法分两条路开发场景下用模块化。现在的项目大多用ES Module直接import浏览器原生加载开发时不需要物理合并打包工具会自动处理。问题是老项目还停留在一堆独立JS文件、按顺序引用的阶段那就需要手动合并或者用脚本把多个JS拼接成一个。手动拼接最怕的就是文件之间的依赖顺序错乱。比如request.js里引用了utils.js里的函数合并时utils.js必须放在前面。我见过一个项目因为手动合并顺序不对上线后报了一大片“xxx is not defined”排查了大半天才发现是文件顺序问题。构建场景用打包器。现在推荐的做法是把XHR封装涉及的所有文件交给webpack、Rollup这类工具处理它们会分析依赖树、自动排序、Tree-Shaking把入口文件依赖的所有XHR工具代码合并成最终的一个或多个文件。如果你项目里还在手动画“合并顺序图”是时候考虑迁移到构建工具了。把这两个层面的“合并”拆清楚之后再看热词其实答案就很明白了网络层面的合并靠队列调度或接口聚合代码层面的合并靠模块化加构建工具两者解决的问题不同千万别搞混。5. 实战一个能直接抄的XHR封装理论聊多了还是要落到代码上。我把自己在项目里常用的XHR封装整理了一下基础请求、超时、取消、上传进度、并发调度都涵盖进去了。这个封装可以直接拿去改也可以当作理解现有请求库底层逻辑的辅助材料。5.1 基础封装结构Promise化XHR原生XHR是事件风格用起来不方便所以第一步就是把它Promise化。这是我封装的核心结构function request(options) { const { method GET, url , data null, headers {}, timeout 15000, responseType json, onProgress null } options; return new Promise(function(resolve, reject) { const xhr new XMLHttpRequest(); xhr.timeout timeout; xhr.responseType responseType; if (onProgress) { xhr.addEventListener(progress, onProgress); } xhr.open(method, url, true); Object.keys(headers).forEach(key { xhr.setRequestHeader(key, headers[key]); }); xhr.onload function() { if (xhr.status 200 xhr.status 300) { resolve(xhr.response); } else { reject({ type: http, status: xhr.status, message: HTTP error xhr.status }); } }; xhr.onerror function() { reject({ type: network, message: Network error }); }; xhr.ontimeout function() { xhr.abort(); reject({ type: timeout, message: Request timeout }); }; xhr.onabort function() { reject({ type: abort, message: Request aborted }); }; xhr.send(data); }); }封装里把错误类型做了区分http、network、timeout、abort。后续在业务侧就可以根据err.type决定是否需要重试、是否需要提示用户而不是一把抓。5.2 上传进度与FormDataXHR的杀手锏上传文件是XHR最核心的不可替代场景。原生fetch想拿到上传进度需要把请求体包装成ReadableStream自己计算非常麻烦。XHR直接在upload对象上监听progress事件就能拿到百分比function uploadFile(file, url, onProgress) { const formData new FormData(); formData.append(file, file); return request({ method: POST, url: url, data: formData, onProgress: function(event) { if (event.lengthComputable) { const percent Math.round((event.loaded / event.total) * 100); onProgress(percent); } } }); }注意你没看到我设置Content-Type这个封装里也故意没设。FormData自带multipart/form-data边界标记一旦手动设置就可能出问题。这是我踩过一次的坑当时为了“保险”加上application/json结果后端接收到的文件永远不完整排查半天最后发现就是请求头不对。进度的回调里有一个event.lengthComputable当它是false时表示无法计算总长度比如后端没返回Content-Length此时不能拿loaded/total去算百分比否则会得到Infinity。上传进度条的处理上大多数人会忽略这个边界值得特别留意。5.3 多个请求批量调度并发控制和资源整合把前面那两个模块合在一起就形成一个比较完整的请求层。实际业务里经常遇到需要同时拉取多个接口并把结果一起处理的场景此时可以配合Promise处理并发再用RequestQueue做并发约束function getAllUsers() { return request({ method: GET, url: /api/users }); } function getAllOrders() { return request({ method: GET, url: /api/orders }); } // 使用并发控制器限制请求数量 const queue new RequestQueue(2); Promise.all([ queue.push(getAllUsers), queue.push(getAllOrders), queue.push(() request({ method: GET, url: /api/products })) ]).then(([users, orders, products]) { console.log(users, orders, products); });批量请求的用途很广仪表盘页面首屏需要依赖多个接口数据按顺序一个个请求页面会卡一把梭同时发10个请求又容易击穿服务器。用队列控制并发之后既能保持页面加载速度又不会造成后端压力瞬间飙升。6. 调试XHR时我常用的排查路径代码写完了总会有问题。XHR的调试方法和fetch基本相通但有几个专属的细节值得说道说道。因为很多同学问“为什么我的请求一直失败”排查流程往往是从头想到尾没有一个体系的路径。这里分享我自己的排查顺序。6.1 先从浏览器Network面板下手遇到XHR问题第一步永远不是看代码而是打开浏览器的开发者工具切到Network面板找到对应请求。一上来就看下面几项观察项排查方向Status Code请求有没有正常到达并返回Typexhr请求会标记为xhr类型确认请求确实走了XHRInitiator点进去能看到是哪一行代码发起的请求快速定位Timing看请求耗时判断是不是网络慢或被阻塞Request Headers / Payload检查请求头、请求体对不对Response看返回数据结构和预期是否一致大部分“我请求不到数据”的问题在这一步就能找到答案。比如你在Network里看到状态码是404那就是接口路径写错了看到状态码是0且Type是xhr但红字提示CORS error那就是跨域问题。6.2 状态码为0的几种常见诱因XHR有个比较特殊的现象status为0但网络面板里请求看起来似乎发出去了。这种情况通常有四个原因abort()被调用过请求主动取消后status归0跨源请求被浏览器的CORS策略拦截根本拿不到响应网络层不可达比如断网、域名解析失败安全原因导致响应被浏览器直接拒绝排查时优先区分是主动取消还是被动拦截。主动取消很简单看代码里是不是有abort()调用被动拦截的话Network面板里请求会显示红色的CORS错误提示处理方式是让后端加Access-Control-Allow-Origin响应头或者通过同源反向代理转发请求。6.3 封装的错误类型如何配合线上监控调试不只是看一次请求对于上线后的XHR问题我习惯在封装的request函数里加上监控上报逻辑。比如错误类型是network和timeout时把触发的URL、时间、UA信息上报给监控平台。这样线上用户遇到问题不是等他们来反馈而是从后台报表里就能看到各接口的错误率趋势。具体上报的字段我的建议是接口路径、错误类型、状态码、耗时、当前页面URL、用户ID如果有。这六个字段足够定位80%的问题。千万不要把完整的响应体一起上报一是体积大二是可能包含敏感信息隐私上有风险。最后再分享一个习惯给XHR请求统一加上请求追踪ID。前后端约定一个X-Request-ID请求头前端每次请求生成一个唯一ID带上后端日志里记录这个ID。一旦用户说某个操作失败你让后端根据这个ID捞日志就能把请求从发出到响应的完整链路拼出来效率比双方来回猜高得多。这个习惯是我从一次长达3小时的线上问题排查里总结出来的从那以后我所有XHR封装的请求都默认带上追踪ID。