
做前端这些年我见过太多人一上来就甩 fetch 一把梭遇到文件上传进度、请求中断、老项目维护这类需求时又回头补课才发现当初没好好学 XHR。XHRXMLHttpRequest这东西看着年代久远但它依然是浏览器环境里最基础的网络请求方案之一很多企业级项目、低代码平台、SDK 里跑得最快的反而是它。这篇文章我会从一个实际开发者的角度把 XHR 的底层逻辑、核心 API、封装思路、文件上传进度、取消请求、常见坑位一次性说透适合刚入门前端的同学也适合写了好几年业务但对 XHR 细节一知半解的同行。1. 先搞清楚 XHR 的底层设计而不是急着复制代码1.1 它到底是怎么完成一次请求的XHR 本质上是浏览器提供的一个宿主对象让你可以用 JavaScript 发起 HTTP 请求并接收响应。很多人第一次接触它时往往只记住了open和send实际上这两步只是把请求“发出去”真正的响应处理都在事件回调里。一个完整 XHR 请求的典型流程是这样的const xhr new XMLHttpRequest(); xhr.open(GET, /api/users, true); xhr.onreadystatechange function () { if (xhr.readyState 4 xhr.status 200) { console.log(xhr.responseText); } }; xhr.send();这里有个关键点readyState不是 HTTP 状态码而是 XHR 实例自身的状态机。它一共有 5 个取值从 0 到 4含义分别是UNSENT实例已创建、OPENEDopen 已调用、HEADERS_RECEIVED响应头已接收、LOADING响应体下载中、DONE整个请求完成无论成功失败。很多初学者会在readyState 3时就去读responseText结果拿到的可能是半截数据所以在真实项目中我一般更推荐直接用onload事件而不是死磕onreadystatechange。一个容易忽略的点是open方法第三个参数async控制同步还是异步。当它传false时代码会阻塞在send这一行直到服务器返回结果才继续往下走。早期浏览器里有人靠这个写“伪多线程”后来被证明对页面渲染伤害极大所以现代浏览器已经在主线程上对同步 XHR 给出了弃用警告。我的建议很直接业务代码里别用同步 XHR哪怕你觉得写起来省事。它会让所有交互卡死尤其在慢网络环境下体验非常糟糕。1.2 为什么 XHR 还没被 fetch 彻底取代近些年fetch成了很多人写请求的第一选择因为它基于 Promise代码更简洁配合async/await很舒服。但 XHR 远没到退役的程度原因也很现实上传/下载进度事件fetch目前没有原生的进度反馈能力而文件上传时用户需要看到进度条。XHR 的upload.onprogress事件是为数不多的浏览器原生方案。请求中断fetch需要配合AbortController来实现取消而 XHR 原生就有abort()方法处理起来更直观兼容性也更好。老项目兼容很多存量系统还在 IE、旧版 WebView 环境里跑fetch在这些环境不可用或不稳定XHR 几乎是唯一选择。响应类型支持XHR 可以设置responseType为arraybuffer、blob、document等在接收二进制大文件时表现稳定。所以我的观点是fetch 和 XHR 不是替代关系而是互补关系。在一个成熟项目里通常是封装一个请求层底层用 XHR 或者 fetch 都可以关键在于对外暴露统一的接口。下面我会具体讲封装思路。2. 核心细节逐个拆解这些才是 XHR 的实战重点2.1 请求头 Content-Type最常见的翻车点用 XHR 发请求时setRequestHeader用得最多也最容易出问题的是Content-Type。很多人发现后台拿不到req.body十有八九是这里没设置对。下面我把几种常见 body 形式和对应的 Content-Type 列出来方便你直接抄body 类型写法示例Content-TypeJSON 字符串JSON.stringify({name: 张三})application/json;charsetUTF-8URL 编码表单name张三age18application/x-www-form-urlencoded;charsetUTF-8FormData 对象new FormData()不需要手动设置浏览器会自动带上multipart/form-data; boundary...纯文本hellotext/plain;charsetUTF-8Blob/ArrayBuffernew Blob([...])application/octet-stream或按文件类型设置最容易踩的坑是发送 JSON 时漏了Content-Type。如果没有设置浏览器默认可能不发这个头后端框架解析不到参数。反过来如果你直接传一个普通对象给send浏览器会直接抛错因为 XHR 只接受字符串、FormData、Blob、ArrayBuffer 等类型不接受普通 Object。另一个细节是setRequestHeader应该在open之后、send之前调用。有些同学在open之前调会直接报错。还有一个容易忽视的点是同一个 header 可以调用多次但某些情况下浏览器会合并比如自定义头如果前后值不同可能只有最后一个生效后面我会在避坑章节再展开。2.2 responseType决定你能拿到什么数据默认情况下XHR 返回的responseText是字符串response也是字符串。如果你希望拿到解析好的 JSON通常有两种办法第一种是不设置responseType然后在onload里手动JSON.parse(xhr.responseText)。这种方式的缺点是多了一步解析而且如果后端返回的不是合法 JSON解析会直接抛异常需要用try/catch包住。第二种是设置responseType json浏览器会自动解析响应体为 JSON 对象。看起来省事但有一个隐藏问题如果后端返回的响应体为空比如状态码 204或者返回的不是标准 JSON 格式xhr.response会变成null而readyState 4时的onload仍然会触发。所以即使设置了responseType json依然要做判空处理。实际项目中我比较推荐按场景设置接口返回 JSONresponseType json配合判空。下载文件responseType blob方便前端生成临时 URL 后再触发下载。处理二进制流responseType arraybuffer方便对字节做进一步处理。需要解析 XMLresponseType document。这里有一个比较隐蔽的点responseType如果在open之后设置需要在send之前完成否则部分浏览器可能忽略。而且设置responseType json后如果响应头里的 Content-Type 不是application/json部分旧浏览器可能依然返回字符串所以兼容性敏感的场合我更倾向于手动解析。2.3 超时、取消请求和跨域 Cookie这三个能力在实际业务里特别常用我用一段代码把它们串起来const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload, true); xhr.timeout 10000; // 10秒超时 xhr.withCredentials true; // 跨域带上身份凭证 xhr.ontimeout function () { console.error(请求超时); }; xhr.onabort function () { console.warn(请求已被主动取消); }; xhr.onload function () { if (xhr.status 200 xhr.status 300) { console.log(请求成功, xhr.response); } else { console.error(HTTP 错误, xhr.status); } }; xhr.onerror function () { console.error(网络异常); }; xhr.send();关于withCredentials只有在跨域请求且后端开启了 CORS 并允许携带凭证时才有效。前端不配置这个字段的话即使后端返回了Access-Control-Allow-Credentials: trueCookie 也不会被自动携带。这里有一个连带约束一旦设置withCredentials true后端的Access-Control-Allow-Origin就不能是*必须是具体域名否则浏览器会拦截响应。关于超时timeout单位是毫秒ontimeout触发后请求并不会自动进入onerror需要自行处理。值得注意的是超时后 XHR 的内部状态会被终止但不代表后端真的没有处理这个请求可能请求已经到了服务器并产生了副作用。所以超时提示要对用户说清楚后端也需要有幂等设计。2.4 事件体系onload、onreadystatechange、onprogress 怎么选XHR 的事件不少很多新手会被绕晕。我的选择经验是onreadystatechange通用性强但要做状态判断适合需要感知请求头已到达、响应体下载中这类中间状态的场景。onload只在请求成功完成后触发写法最简洁推荐日常接口调用使用。onerror网络层错误才会触发比如断网、DNS 解析失败、连接被重置。HTTP 状态码不是 2xx 时onload依然会正常触发这一点特别重要。onprogress响应阶段下载进度反馈适合加载大文件时做进度条。upload.onprogress上传阶段进度反馈文件上传时的核心事件。onprogress事件对象上有loaded和total两个字段可以用来计算百分比。但有一个坑某些服务器响应头里没带Content-Length甚至用了 chunked 编码这时total可能是 0直接做loaded / total会得到Infinity或者NaN。我的做法是判断一下total是否存在不存在就先显示“传输中”而不是具体百分比。3. 实操一个完整的封装从原生 XHR 到 Promise 化改造3.1 先写一个足够实用的 request 函数直接用原生 XHR 写业务代码回调嵌套多了以后维护成本很高。最实用的办法是把它封装成 Promise 风格的接口。下面这个封装我在多个项目里改过很多版本去掉了依赖库保留了核心能力你可以直接拿去用function request(options) { const { url, method GET, data null, headers {}, timeout 30000, responseType json, withCredentials false, onUploadProgress null, onDownloadProgress null, } options; return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(method.toUpperCase(), url, true); if (responseType) { xhr.responseType responseType; } if (withCredentials) { xhr.withCredentials true; } if (timeout) { xhr.timeout timeout; } Object.keys(headers).forEach((key) { xhr.setRequestHeader(key, headers[key]); }); if (onUploadProgress) { xhr.upload.onprogress onUploadProgress; } if (onDownloadProgress) { xhr.onprogress onDownloadProgress; } xhr.onload function () { if (xhr.status 200 xhr.status 300) { resolve(xhr.response); } else { const err new Error(Request failed with status ${xhr.status}); err.status xhr.status; err.response xhr.response; reject(err); } }; xhr.ontimeout function () { const err new Error(Request timeout); err.code TIMEOUT; reject(err); }; xhr.onerror function () { const err new Error(Network error); err.code NETWORK_ERROR; reject(err); }; xhr.onabort function () { const err new Error(Request aborted); err.code ABORTED; reject(err); }; let body data; if (body method.toUpperCase() ! GET method.toUpperCase() ! HEAD) { if (typeof body object !(body instanceof FormData) !(body instanceof Blob) !(body instanceof ArrayBuffer)) { body JSON.stringify(body); if (!Object.keys(headers).some((key) key.toLowerCase() content-type)) { xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8); } } } else if (body (method.toUpperCase() GET || method.toUpperCase() HEAD)) { // GET/HEAD 请求不推荐使用 body这里做一次兜底 body null; } xhr.send(body); }); }这个封装的几个设计点值得展开返回 Promise调用方可以用await或者.then业务代码可读性好很多。对data做统一处理对象自动转 JSON同时自动设置 Content-TypeFormData、Blob、ArrayBuffer 原样透传。通过onUploadProgress和onDownloadProgress透出上传/下载进度事件弥补 fetch 的短板。把超时、网络错误、取消请求分别用不同的错误标记区分方便上层做提示。HTTP 状态码非 2xx 时也走 reject这样业务代码里不用到处判断 404、500 这类情况。3.2 中断请求和超时在封装里怎么暴露原生 XHR 的abort()方法可以直接终止请求但上面 Promise 化的封装把xhr实例藏在了 Promise 内部调用方拿不到它就没法主动取消。一个常用的解法是在返回 Promise 的同时对外暴露一个取消函数。我这里有另一种实现思路function createCancellableRequest(options) { const xhr new XMLHttpRequest(); const promise new Promise((resolve, reject) { xhr.open(options.method || GET, options.url, true); xhr.onload () resolve(xhr.response); xhr.onerror () reject(new Error(Network error)); xhr.send(options.data || null); }); return { promise, cancel(reason cancel) { xhr.abort(); }, }; }业务里常见的场景是用户点击搜索框后快速输入前一个请求还没返回就被新请求覆盖这时候主动把前一个请求 abort 掉能省流量也避免竞态条件带来的数据覆盖。3.3 加上拦截器的进阶封装很多团队用 axios 就是看中它的拦截器能力统一在请求前加 token、在响应后处理登录态失效。其实用 XHR 自己也能实现一个轻量版拦截器。核心思路是维护一个函数队列在调用request前逐个执行请求拦截器在 Promise resolve/reject 前执行响应拦截器。我的简化实现思路如下const interceptors { request: [], response: [], }; function useRequestInterceptor(fn) { interceptors.request.push(fn); } function useResponseInterceptor(fn) { interceptors.response.push(fn); } function requestWithInterceptor(options) { let config { ...options }; for (const fn of interceptors.request) { config fn(config) || config; } const result request(config); let chain result; for (const fn of interceptors.response) { chain chain.then(fn, fn); } return chain; }请求拦截器里最常用的操作就是给 headers 加Authorization。响应拦截器里则统一处理 401 跳转登录、403 提示无权限、网络错误提示等。这样业务代码就只需要关心接口数据本身不用到处写重复的错误处理。3.4 文件上传进度XHR 最具代表性的场景文件上传用 fetch 写会有点尴尬因为没有进度事件要么用XMLHttpRequest要么依赖第三方库。用 XHR 实现一个带进度条的文件上传其实非常直接function uploadFile(file, onProgress) { const formData new FormData(); formData.append(file, file); return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.open(POST, /api/upload, true); xhr.upload.onprogress (event) { if (event.lengthComputable onProgress) { const percent Math.round((event.loaded / event.total) * 100); onProgress(percent, event); } }; xhr.onload function () { if (xhr.status 200) { resolve(xhr.response); } else { reject(new Error(Upload failed: ${xhr.status})); } }; xhr.onerror function () { reject(new Error(Upload network error)); }; xhr.send(formData); }); }这段代码里有几个值得注意的点FormData的 Content-Type 不要手动设置。如果手动加了multipart/form-data反而会因为缺少浏览器自动生成的boundary字段导致后端解析失败。event.lengthComputable为true时才适合计算百分比否则直接显示“上传中”更稳妥。大文件上传时建议配合后端做切片但 XHR 层不需要变化每个切片都是一个独立的 POST 请求进度条可以把所有切片已完成的大小加起来。4. 常见问题与排查技巧实录4.1 高频问题速查表现象可能原因排查方向请求发出去了但后端收到 body 为空Content-Type 没设置或 body 序列化方式不对检查请求头里的 Content-Type对照表格确认类型设置了 Content-Type 但浏览器提示 CORS 错误后端 Access-Control-Allow-Origin 未允许当前域名看 Network 面板预检请求查看响应头readyState 一直是 1不继续推进open 或 send 调用时机不对或页面被阻塞确认 send 是否调用是否被同步任务阻塞responseType 设为 json 后 response 为 null响应体为空或响应头 Content-Type 不对手动判空改用 responseText 后 JSON.parse上传进度不触发事件绑在了 xhr 而不是 xhr.upload 上检查xhr.upload.onprogressonload 触发了但 status 是 0跨域请求失败或请求被浏览器拦截打开 Network 看具体报错检查 CORS设置了 timeout 但还等了很久才失败服务器长时间不返回timeout 只在连接建立后生效结合后端超时配置前端做进程级兜底想取消请求但 cancel 不管用请求已经完成或已经进入发送阶段abort 只能中断未完成的请求4.2 我在实际项目中踩过的几个坑第一个坑是setRequestHeader大小写和重复设置问题。HTTP 头部本身不区分大小写但如果你用xhr.setRequestHeader(content-type, a)又用xhr.setRequestHeader(Content-Type, b)有些浏览器会把请求头发成两个值后端取到的可能是第一个也可能是拼接结果。靠谱的做法是在封装层统一管理 headers全部用小写 key 存储最终内部做好合并避免业务代码反复设置同一个头。第二个坑是responseType blob时内存暴涨。下载大文件如果直接转URL.createObjectURL再扔给 a 标签下载很容易把页面卡爆。我后来养成的习惯是下载完文件后主动调用URL.revokeObjectURL释放地址同时避免一次性把多个大文件并行下载。如果需要处理超大文件建议考虑流式下载用 stream 或者 service worker 配合虽然复杂度上去了但体验差距非常明显。第三个坑是onload触发时 HTTP 状态码是 200但后端在业务层面返回了一个{ code: 500 }。这不是 XHR 层面的问题但如果不统一处理业务代码会莫名相信这次请求成功。我会在封装层增加一个validateStatus的概念让调用方可以自定义什么状态才算成功或者在响应拦截器里统一判断业务 code。第四个坑是同步 XHR 引起的页面崩溃陷阱。有些老项目为了省事会在beforeunload里发同步请求做埋点结果页面关闭时浏览器直接白屏好几秒甚至被系统杀进程。现代浏览器已经不建议这么干可以用navigator.sendBeacon替代埋点上报对用户退出场景更友好。4.3 如何借助浏览器 DevTools 定位 XHR 问题排查 XHR 相关问题我通常是这么操作的打开 DevTools 的 Network 面板筛选 XHR/Fetch 类型找到对应请求。先看请求行确认 URL、Method、Status 是否符合预期。如果状态码是(canceled)说明请求被 abort 或者页面跳转中断了。点进 Headers重点看Content-Type、Authorization、Cookie、Access-Control-Allow-Origin这几个头。切到 Payload 或 Request确认 body 内容是否按预期序列化。响应阶段如果拿不到数据切到 Response 或 Preview 分页看返回原始内容和浏览器解析后的差异。跨域场景下还要额外关注 “Preflight” 请求。当请求带非简单头、使用 PUT/DELETE 等方法或者发application/json的 POST 时浏览器会先发一个 OPTIONS 预检请求。如果这个预检失败主请求根本不会发出Network 面板里能看到一条被 CORS 拦截的记录。这时候 Go 到 Console 面板看错误提示比在代码里反复调试效率要高得多。5. 扩展思路从 XHR 到统一请求层的设计建议5.1 千变万化的业务需要一个抽象层一个成熟的前端项目通常不会直接让业务代码碰 XHR 或 fetch 的底层细节而是统一封装成request.get、request.post这样的形式。封装层的好处是如果底层从 XHR 换到 fetch业务代码可以无感切换同时可以在这一层统一做登录态注入、错误提示、埋点上报、Loading 控制。我见过一个很有效的做法是封装层对外只暴露业务意义的语义化接口比如loginApi、getUserApi内部再组装 URL、method、data。这样即使后端接口路径发生变化也只需要在一个地方改。至于底层是 XHR 还是 fetch反而不是最重要的只要对外行为一致。5.2 XHR 与 fetch 混用的注意事项有人会问项目里能不能同时用 XHR 和 fetch可以但要注意它们的事件模型和错误处理逻辑不一样。比如 XHR 的超时用timeoutontimeoutfetch 的超时要用AbortController配合setTimeout模拟。如果封装层不统一业务代码需要同时适配两套异常类型很容易漏处理。我的建议是能统一就统一如果一个项目已经用 XHR 封装好了所有请求没必要为了“先进”强行切换到 fetch。除非遇到 streaming 响应、Service Worker 配合等 XHR 支持不了的需求再针对单点做替换。5.3 浏览器兼容性这张旧船票还能登船XHR 的兼容性可以说是目前所有网络 API 中最稳的几乎所有浏览器环境都支持。如果团队还有维护老 WebView、老旧浏览器的需求XHR 比 fetch 可靠得多。但要注意XMLHttpRequest在 IE 里早期是通过ActiveXObject(Microsoft.XMLHTTP)实现的IE7 之后才原生支持。如果真要兼容到 IE6/IE7需要写一个特性检测逻辑。当然这种极端场景现在已经很少见了绝大多数时候直接new XMLHttpRequest()就够了。5.4 个人习惯的经验总结从我自己的使用习惯来说80% 的常规接口调用我会用封装好的 Promise 版 XHR因为它兼容性最好、事件语义清楚、对上传下载有天然支持。只有遇到需要流式读取、逐步解析文本这类场景时我才会优先考虑 fetch 的 ReadableStream。这并不代表谁比谁高级关键是选型前想清楚服务器的返回格式是什么、数据量大不大、需不需要进度反馈、目标浏览器环境有哪些。把这几个问题想清楚了XHR 和 fetch 的选择自然就清晰了。