深入理解XHR:从底层原理到请求封装与文件上传实战
2026/9/19 15:53:45 网站建设 项目流程

做前端这些年,我见过太多人一上来就甩 fetch 一把梭,遇到文件上传进度、请求中断、老项目维护这类需求时又回头补课,才发现当初没好好学 XHR。XHR(XMLHttpRequest)这东西看着年代久远,但它依然是浏览器环境里最基础的网络请求方案之一,很多企业级项目、低代码平台、SDK 里跑得最快的反而是它。这篇文章我会从一个实际开发者的角度,把 XHR 的底层逻辑、核心 API、封装思路、文件上传进度、取消请求、常见坑位一次性说透,适合刚入门前端的同学,也适合写了好几年业务但对 XHR 细节一知半解的同行。

1. 先搞清楚 XHR 的底层设计,而不是急着复制代码

1.1 它到底是怎么完成一次请求的

XHR 本质上是浏览器提供的一个宿主对象,让你可以用 JavaScript 发起 HTTP 请求并接收响应。很多人第一次接触它时往往只记住了opensend,实际上这两步只是把请求“发出去”,真正的响应处理都在事件回调里。一个完整 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(实例已创建)、OPENED(open 已调用)、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 可以设置responseTypearraybufferblobdocument等,在接收二进制大文件时表现稳定。

所以我的观点是:fetch 和 XHR 不是替代关系,而是互补关系。在一个成熟项目里,通常是封装一个请求层,底层用 XHR 或者 fetch 都可以,关键在于对外暴露统一的接口。下面我会具体讲封装思路。

2. 核心细节逐个拆解,这些才是 XHR 的实战重点

2.1 请求头 Content-Type:最常见的翻车点

用 XHR 发请求时,setRequestHeader用得最多也最容易出问题的是Content-Type。很多人发现后台拿不到req.body,十有八九是这里没设置对。下面我把几种常见 body 形式和对应的 Content-Type 列出来,方便你直接抄:

body 类型写法示例Content-Type
JSON 字符串JSON.stringify({name: '张三'})application/json;charset=UTF-8
URL 编码表单'name=张三&age=18'application/x-www-form-urlencoded;charset=UTF-8
FormData 对象new FormData()不需要手动设置,浏览器会自动带上multipart/form-data; boundary=...
纯文本'hello'text/plain;charset=UTF-8
Blob/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',依然要做判空处理。

实际项目中,我比较推荐按场景设置:

  • 接口返回 JSON:responseType = 'json',配合判空。
  • 下载文件:responseType = 'blob',方便前端生成临时 URL 后再触发下载。
  • 处理二进制流:responseType = 'arraybuffer',方便对字节做进一步处理。
  • 需要解析 XML:responseType = '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: true,Cookie 也不会被自动携带。这里有一个连带约束:一旦设置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事件对象上有loadedtotal两个字段,可以用来计算百分比。但有一个坑:某些服务器响应头里没带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;charset=UTF-8'); } } } else if (body && (method.toUpperCase() === 'GET' || method.toUpperCase() === 'HEAD')) { // GET/HEAD 请求不推荐使用 body,这里做一次兜底 body = null; } xhr.send(body); }); }

这个封装的几个设计点值得展开:

  1. 返回 Promise,调用方可以用await或者.then,业务代码可读性好很多。
  2. data做统一处理:对象自动转 JSON,同时自动设置 Content-Type,FormData、Blob、ArrayBuffer 原样透传。
  3. 通过onUploadProgressonDownloadProgress透出上传/下载进度事件,弥补 fetch 的短板。
  4. 把超时、网络错误、取消请求分别用不同的错误标记区分,方便上层做提示。
  5. 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.lengthComputabletrue时才适合计算百分比,否则直接显示“上传中”更稳妥。
  • 大文件上传时建议配合后端做切片,但 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.onprogress
onload 触发了但 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 相关问题,我通常是这么操作的:

  1. 打开 DevTools 的 Network 面板,筛选 XHR/Fetch 类型,找到对应请求。
  2. 先看请求行,确认 URL、Method、Status 是否符合预期。
  3. 如果状态码是(canceled),说明请求被 abort 或者页面跳转中断了。
  4. 点进 Headers,重点看Content-TypeAuthorizationCookieAccess-Control-Allow-Origin这几个头。
  5. 切到 Payload 或 Request,确认 body 内容是否按预期序列化。
  6. 响应阶段如果拿不到数据,切到 Response 或 Preview 分页,看返回原始内容和浏览器解析后的差异。

跨域场景下还要额外关注 “Preflight” 请求。当请求带非简单头、使用 PUT/DELETE 等方法,或者发application/json的 POST 时,浏览器会先发一个 OPTIONS 预检请求。如果这个预检失败,主请求根本不会发出,Network 面板里能看到一条被 CORS 拦截的记录。这时候 Go 到 Console 面板看错误提示,比在代码里反复调试效率要高得多。

5. 扩展思路:从 XHR 到统一请求层的设计建议

5.1 千变万化的业务,需要一个抽象层

一个成熟的前端项目通常不会直接让业务代码碰 XHR 或 fetch 的底层细节,而是统一封装成request.getrequest.post这样的形式。封装层的好处是:如果底层从 XHR 换到 fetch,业务代码可以无感切换;同时可以在这一层统一做登录态注入、错误提示、埋点上报、Loading 控制。

我见过一个很有效的做法是:封装层对外只暴露业务意义的语义化接口,比如loginApigetUserApi,内部再组装 URL、method、data。这样即使后端接口路径发生变化,也只需要在一个地方改。至于底层是 XHR 还是 fetch,反而不是最重要的,只要对外行为一致。

5.2 XHR 与 fetch 混用的注意事项

有人会问,项目里能不能同时用 XHR 和 fetch?可以,但要注意它们的事件模型和错误处理逻辑不一样。比如 XHR 的超时用timeout+ontimeout,fetch 的超时要用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 的选择自然就清晰了。

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

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

立即咨询