
做了这么多年前端有一件事我一直觉得挺魔幻的——很多同学用 axios 用得飞起get、post随手就来拦截器、超时配置写在项目里跟喝水一样自然但你要是问他axios 底层到底是怎么发请求的XMLHttpRequest的readyState有哪几个值Promise的状态转移和then回调的执行时机到底是什么关系大概率会卡壳。这不怪大家框架和封装库确实帮我们挡掉了太多底层细节。但话说回来一旦你脱离 Node 环境脱离脚手架回到一个纯粹的浏览器页面或者需要在一个不允许引入第三方库的封闭内网环境里干活你会发现自己对原生 AJAX 和 Promise 的理解才是真正能救命的东西。这篇文章我就想用 Promise 把原生 AJAX 完整封装一遍从 XHR 的底层机制讲到 Promise 的原理再落到一个可以直接复制进项目的封装函数。中间会穿插我实际踩过的坑比如那个莫名其妙的Uncaught (in promise) error、超时和取消请求的边界处理、status为 0 时到底该不该 reject。希望你看完能有一种“原来如此”的感觉以后再用 axios 也能知道它每一步在做什么。1. 为什么我最后还是选择了原生 XMLHttpRequest 来封装1.1 框架里用不到但框架的底层全是它先回答一个问题现在都 2024 年了fetch都普及成这样了为什么还要回过头去封装 AJAX答案很简单你的项目可能没用 XHR但你的项目依赖的库很可能用了。比如 axios 的核心就是一个基于 XHR 的适配器比如各种上传组件、跨域调试工具、某些老牌 SDK底层都是 XHR。浏览器里你随便打开 DevTools 的 Network 面板过滤XHR类型能看到一大堆请求它们不一定都是你代码里直接写的。另外一个现实原因fetch 虽然优雅但它在某些场景下真的不够用。比如超时取消、请求进度、跨域携带 cookie 的细节处理fetch 要么不支持要么实现得很别扭。这些恰恰是 XHR 的传统强项。我在后面第 6 节会专门对比这里先按下不表。所以我的观点是XHR 不是过时的技术它是一套更底层、更开放的浏览器网络能力接口。你把它和 Promise 结合起来封装一次相当于把网络请求这块的最后一块拼图补齐了。以后再看到任何请求库的源码你都不会觉得是黑盒。1.2 直接裸写 XHR 的几个真实痛点不封装的情况下用原生 XHR 发一个带参数的 GET 请求大概是这样的const xhr new XMLHttpRequest(); xhr.open(GET, /api/user?id123, true); xhr.onreadystatechange function () { if (xhr.readyState 4) { if (xhr.status 200 xhr.status 300) { console.log(JSON.parse(xhr.responseText)); } else { console.error(请求失败, xhr.status); } } }; xhr.onerror function () { console.error(网络错误); }; xhr.send();看这段代码你大概能列出好几个痛点第一个痛点是回调嵌套。你只是发了一个请求就写了onreadystatechange和onerror两个回调。如果请求 A 成功之后要发请求 BB 成功之后要发 C恭喜你回调地狱初具雏形。而且这个地狱还不是靠缩进体现的是靠一堆分散的onxxx回调函数代码的可读性和维护性会急剧下降。第二个痛点是错误处理靠自觉。不管你是 404、500还是网络断线、超时每个失败场景都要手动写分支处理。写一次两次还行项目里几十个请求你每个都要写一遍if (xhr.readyState 4 xhr.status 200)这种模板代码纯属体力活还容易漏判。第三个痛点是请求配置和业务逻辑混在一起。URL、方法、请求头、超时时间、响应类型这些配置散落在代码各处。你很难做成统一管理比如统一加 token、统一处理登录态过期你只能每个请求调用处手动加。这其实就是我后面要做的“封装”的核心动机——把重复的模板代码收敛起来把变化的部分暴露成配置。1.3 封装的本质把“协议”变成“函数”我不知道你有没有想过一个问题axios 这种库本质上做的事情是什么其实就四个字统一约定。它把你和浏览器之间的网络通信协议封装成一个更符合开发者心智的函数接口。你不需要关心readyState到没到 4不需要关心status怎么判断你只需要说给我发一个 GET 请求到这个地址然后拿到数据。而用 Promise 封装就是在做同样的事情。resolve相当于告诉调用者“数据拿到了你接着处理”reject相当于告诉调用者“出问题了你处理错误”。这让异步代码的写法无限接近于同步代码——const data await request(url)读完一行就能理解意图。我自己在实际项目里的体会是封装完之后业务代码里几乎看不到任何 XHR 的痕迹请求模块变成了一个完全独立的工具层。哪一天你想把底层换成 fetch只要保持接口不变业务代码一行都不用改。这才是封装的真正价值——隔离变化稳定接口。2. Promise 能解决回调地狱靠的不只是“语法糖”2.1 三种状态之间的单向转移是整个异步逻辑的基石在动手写代码之前我们先把 Promise 的状态机搞透。这东西是基础中的基础但很多人对它的理解只停留在“会用”一涉及到“为什么这么设计”就懵了。Promise 内部只有三种状态pending进行中、fulfilled已成功、rejected已失败。关键规则是状态只能从 pending 转移到 fulfilled 或 rejected一旦转移就不可逆。不可能从 fulfilled 再变回 pending也不可能从 rejected 变成 fulfilled。你仔细品一下这个设计会发现它其实是在表达一个非常朴素的现实逻辑一件事要么成功要么失败没有“我反悔了再重新来”这种中间态。你在resolve之后再调用reject是没有任何效果的。这种“状态固化”保证了异步结果一旦确定所有依赖它的回调都会得到一致的结果不会出现一部分订阅者看到成功、另一部分看到失败的混乱情况。举个例子理解一下const p new Promise((resolve, reject) { resolve(data); reject(error); // 这行没啥用状态已经变成 fulfilled 了 }); p.then( (data) console.log(成功:, data), (err) console.log(失败:, err) ); // 输出成功: data这段代码在很多面试题里出现过核心考点就是状态不可逆。2.2 then 的回调为什么是异步的微任务队列比状态转移更隐蔽的一个机制是then里的回调一定不会同步执行而是会被放入微任务队列。你写Promise.resolve(hello).then(console.log)紧接着写console.log(world)输出顺序永远是先world后hello。这个大家都知道但很少有人想过“为什么非要异步不可”。我个人的理解是为了保证时序的一致性。如果 Promise 的回调支持同步调用那么一个 Promise 在创建时就已经 resolve 的情况下then会立即执行但如果 Promise 在执行一段异步任务后才 resolvethen就得等到任务完成才能执行。这种“有时候同步、有时候异步”的行为会让调用者非常困惑也容易引发一些难以追踪的竞态问题。统一变成异步之后无论 Promise 是否已经 settlethen注册的回调都会按照同样的节奏执行。顺带一提Promise 的回调进入的是微任务队列而不是宏任务队列。跟setTimeout比微任务会在当前脚本执行完、下一个宏任务开始前全部执行完毕。这就是为什么下面这段代码的输出顺序是1 3 2console.log(1); setTimeout(() console.log(2), 0); Promise.resolve().then(() console.log(3));理解了微任务你就能解释很多诡异的现象比如“为什么我在 then 里改了 DOM页面却没立刻重新渲染”——因为渲染是宏任务阶段的事你得等微任务队列清空了再说。2.3 错误传播与 Promise 链的“短路”机制Promise 链之所以能解决回调地狱靠的还有一个关键设计错误会自动向下传播。也就是说整条then链上任何一个环节抛出了异常或者返回了一个 rejected 的 Promise后续的then都不会执行直到你遇到一个catch。这有点像电路里的短路开关——哪里出问题哪里就断开电流直接流到地线。fetchSomething() .then((res) parseJson(res)) .then((data) render(data)) .catch((err) showError(err));parseJson如果抛错了render不会执行错误直接跳到catch。这种机制大大简化了错误处理逻辑你不再需要在每一层回调里单独写try...catch。但有两点务必注意第一没有 catch 的 Promise 链错误会被“吞掉”或者抛成全局错误。这就是那个著名的Uncaught (in promise) error的来源。后面我会专门讲这个现象。第二catch 之后链还能继续。很多人以为 catch 是终点其实不是。catch 里如果返回了一个正常值后面的 then 依然会执行。这个特性在某些场景下很好用比如“接口失败时给个默认值”。getUser() .catch(() ({ name: 游客 })) .then((user) render(user));这样即使接口挂了页面也还能渲染一个默认用户不至于白屏。3. 一步步写出 promiseAjax核心代码与逐行拆解3.1 先看一个完整的最小可用版本理论部分先告一段落现在直接上代码。我要封装的目标很明确支持 GET、POST 等常见方法支持设置请求头、超时时间、响应类型返回一个 Promise成功 resolve 响应数据失败 reject 错误信息处理status为 0 的边界情况先给一个完整的最小可用版本后面再逐步增强。我用的是XMLHttpRequest的onreadystatechange方案因为它的兼容性最好也最能体现底层机制function promiseAjax(options) { return new Promise((resolve, reject) { const { url, method GET, data null, headers {}, timeout 0, responseType json, withCredentials false } options; if (!url) { reject(new Error(请求地址不能为空)); return; } const xhr new XMLHttpRequest(); // 处理 GET 请求的 query 参数 let finalUrl url; if (method.toUpperCase() GET data) { const queryString Object.keys(data) .map((key) ${encodeURIComponent(key)}${encodeURIComponent(data[key])}) .join(); finalUrl url.includes(?) ? ${url}${queryString} : ${url}?${queryString}; } xhr.open(method.toUpperCase(), finalUrl, true); xhr.timeout timeout; // 设置响应类型 if (responseType) { xhr.responseType responseType; } // 跨域携带 cookie xhr.withCredentials withCredentials; // 设置请求头 Object.keys(headers).forEach((key) { xhr.setRequestHeader(key, headers[key]); }); // 监听状态变化 xhr.onreadystatechange function () { if (xhr.readyState ! 4) return; // 兼容处理 HTTP 层错误和网络层错误的边界 if (xhr.status 200 xhr.status 300) { resolve(responseType json ? JSON.parse(xhr.responseText) : xhr.response); } else { reject(new Error(请求失败状态码${xhr.status})); } }; // 低级错误处理 xhr.onerror function () { reject(new Error(网络异常请检查连接)); }; xhr.ontimeout function () { reject(new Error(请求超时)); }; xhr.onabort function () { reject(new Error(请求已取消)); }; // 发送数据POST 需要处理 body if (method.toUpperCase() POST data) { xhr.send(JSON.stringify(data)); } else { xhr.send(); } }); }这个版本大概是 70 行已经能覆盖日常 90% 的需求了。但注意我上面写了一个responseType json ? JSON.parse(...)这在真实场景里会有问题如果后端返回的是空字符串JSON.parse()会直接抛异常。这个坑我们留到第 5 节细说。3.2 核心逻辑拆解open 到 send 之间到底发生了什么很多人写过 XHR但对open、send和事件监听这三者之间的关系并没那么清楚。我在这里把整个流程拆开讲一下。首先xhr.open(method, url, true)这一步做的事情是初始化请求。它不会真的去连网络只是告诉浏览器“我要准备发请求了方法是什么地址是什么第三个参数true表示异步”。注意open之后到send之前你有机会设置各种参数请求头、超时时间、响应类型、withCredentials等。一旦send被调用请求就真正进入发送流程。其次xhr.send(data)里如果传了 body对 POST 请求来说就是请求体对 GET 请求来说传了也会被忽略这也是代码里 GET 和 POST 分开处理的原因。最后是事件监听。XHR 提供了一堆事件其中最核心的就是onreadystatechange。这个回调会在readyState变化时被触发它的取值有 5 个readyState含义说明0UNSENT已创建 XHR 实例open 尚未调用1OPENED已调用 open但 send 还没调用2HEADERS_RECEIVEDsend 已被调用响应头已经收到3LOADING正在下载响应体responseText 部分可用4DONE整个响应已接收完毕可以开始处理大多数情况下你只在readyState 4时处理结果因为那时才能拿到完整的响应。但readyState 3在下载大文件时可以配合onprogress做进度条也是很有用的。3.3 为什么用 onreadystatechange而不是 onload这里顺便讲一个选型问题。现在 XHR 其实提供了onload事件它会在请求成功完成后触发写法上更简洁xhr.onload function () { // 此时 readyState 必然是 4 };那为什么我上面的封装用的是onreadystatechange并且判断readyState 4两个原因。第一onload 只代表“加载完成了”不代表“请求成功”。404、500 照样会触发 onload你必须自己去看status。所以用 onload 并不能省掉状态判断只是换个写法而已。第二onreadystatechange 能让我统一处理所有异常分支。onerror管网络错误ontimeout管超时onabort管取消onreadystatechange只管结果解析。职责清晰逻辑分明。如果只用 onload你会发现还得再挂 onerror、ontimeout、onabort 三个事件代码也没简洁到哪去。所以我的建议是当你需要精细控制各种失败场景时onreadystatechange 独立错误事件是最稳妥的组合。这也是 axios 内部采用的经典方案。4. 让封装真正“落地”超时、取消、拦截器一个都不能少4.1 超时控制setTimeout 和 XHR.timeout 到底用哪个先问一个问题如果你想给请求加一个 5 秒超时你会怎么写很多人第一反应是setTimeout(() xhr.abort(), 5000);这能工作但不够精准。因为 XHR 自己就有timeout属性你可以直接设置xhr.timeout 5000;两者的区别在哪里xhr.timeout是浏览器原生层面的超时控制浏览器在发出请求后开始计时如果在规定时间内未收到响应会自动中断请求并触发ontimeout事件。这是最可靠的方式因为触发时机和请求的生命周期绑定在一起。而setTimeout是在业务代码层面的“模拟超时”它到点后手动调用abort()。这里有一个很微妙的 bug 隐患——如果请求在 4.5 秒时刚好完成了但你的 setTimeout 设置的是 5 秒abort 就会在请求已经成功之后被调用。这时候请求已经拿到数据了你把它 abort 掉等于白白丢掉了一个成功的响应。所以当你用 setTimeout 模拟超时时必须在请求完成时清掉 timerconst timer setTimeout(() xhr.abort(), 5000); xhr.onload () clearTimeout(timer); xhr.onerror () clearTimeout(timer);麻烦不说还容易漏。所以我强烈建议超时就用xhr.timeout别自己造轮子。有一点需要提醒xhr.timeout的计时是从调用send()之后开始的如果请求在连接阶段卡住比如 DNS 解析很慢不同浏览器的表现不太一样。Chrome 下 timeout 会正常触发但某些老版本浏览器可能对连接阶段的超时覆盖不全。内网项目遇到这种情况可以考虑在 timeout 之外再补一个 watchdog timer双保险。4.2 取消请求用 AbortController 还是 xhr.abort()前端取消请求的需求非常常见用户快速切换 Tab、组件卸载、搜索框防抖后只保留最后一次请求结果。在 Promise 封装里我们至少要支持“从外部取消一个正在进行的请求”。XHR 原生的取消方式就是abort()它会让请求进入 aborted 状态并触发onabort事件。在 Promise 封装里我可以在onabort中 reject 这次的请求。问题来了调用者怎么拿到这个abort函数呢方案一在返回值上做文章。返回的 Promise 额外挂一个cancel方法function promiseAjax(options) { let xhr null; const p new Promise((resolve, reject) { // ... 创建 xhr 并发送 }); p.cancel function () { if (xhr) xhr.abort(); }; return p; }这样调用方可以这么用const request promiseAjax({ url: /api/user }); request.then(handleData).catch(handleError); // 用户点击取消时 request.cancel();方案二配合标准的AbortController。这个主要是为了对齐 fetch 的习惯给封装加上一个 signal 参数function promiseAjax(options) { const { signal } options; return new Promise((resolve, reject) { const xhr new XMLHttpRequest(); xhr.onreadystatechange () { /* ... */ }; signal signal.addEventListener(abort, () { xhr.abort(); }); }); }调用方用起来是这样的const controller new AbortController(); promiseAjax({ url: /api/user, signal: controller.signal }); // 取消 controller.abort();这两个方案我都实际用过。简单场景下方案一更快不需要额外理解 AbortController 的概念但方案二更适合组合多个请求的场景——你可以用一个 controller 同时取消多个请求。如果你写的封装打算给别人用我建议支持 signal因为它正在成为浏览器异步操作的标准取消方式。4.3 进度回调onprogress 让大文件上传不再“黑盒”用 Promise 封装 XHR 有一个“副作用”就是onprogress事件很容易被忽略因为 Promise 本身不关心进度它只关心最终结果。但现实中的大文件上传、下载进度反馈是刚需。我在封装里通常会预留一个onProgress回调参数xhr.onprogress function (e) { if (e.lengthComputable options.onProgress) { const percent Math.round((e.loaded / e.total) * 100); options.onProgress(percent, e); } };然后在调用时promiseAjax({ url: /api/upload, method: POST, data: formData, onProgress: (percent) { progressBar.style.width percent %; } });这算是一个很实用的加分项尤其是你的项目里没有引入第三方上传库的时候。4.4 请求拦截器的思路在封装内部加钩子很多同学用 axios 习惯了拦截器觉得那是理所当然的。其实拦截器本质上就是在“请求发出前”和“响应返回后”这两个时间点插入钩子函数。我们自己封装时完全可以实现一个简化版本。思路不复杂在 create 阶段维护一个数组存放请求拦截器。当 promiseAjax 被调用时先执行所有请求拦截器再发起真正的 XHR拿到响应后先执行响应拦截器再 resolve 给调用者。下面用伪代码演示一下核心逻辑const requestInterceptors []; const responseInterceptors []; function useRequestInterceptor(fn) { requestInterceptors.push(fn); } function useResponseInterceptor(fn) { responseInterceptors.push(fn); } function request(config) { // 拦截器可以修改 config比如统一加 token let finalConfig config; for (const fn of requestInterceptors) { finalConfig fn(finalConfig) || finalConfig; } return promiseAjax(finalConfig).then( (response) { let finalResponse response; for (const fn of responseInterceptors) { finalResponse fn(finalResponse) || finalResponse; } return finalResponse; } ); }注意这里的细节请求拦截器最好设计成“可以修改配置”响应拦截器可以“统一处理错误”。比如后端约定返回格式是{ code: 0, data: {...} }那响应拦截器里你就可以判断code是否为 0如果不是就直接 reject调用方就不用每个接口都写一遍错误判断了。这比我前面那个 70 行的基础版要实用得多也是封装“落地”的关键一步。5. 跑通封装之后我踩过的那些坑5.1 status 为 0 的情况比你想的更常见如果你照着前面的代码跑起来第一个可能遇到的诡异问题就是明明请求发出去了Network 面板里也看到 200 了但xhr.status居然是 0。这是怎么回事我排查后发现status为 0 的场景通常有几种请求被跨域拦截浏览器 CORS 预检失败或服务器没返回正确的 CORS 头请求根本就没到业务层。请求被中断比如网络断开abort()被调用。请求尚未真正发出readyState不是 4 的时候去读 status当然可能是初始值 0。说到底status为 0 不代表 HTTP 状态码为 0它表示“HTTP 层没有给出有效状态”。所以你在封装时不能简单地在readyState 4时把status 0一股脑当成正常情况处理。我的处理方式是加一个判断在readyState 4时如果status 0转给专门的错误分支。为了稳妥我在基础版里没有加这个逻辑但在生产版本里是必须的if (xhr.readyState ! 4) return; if (xhr.status 200 xhr.status 300) { resolve(parseResponse(xhr)); } else if (xhr.status 0) { reject(new Error(请求未完成或已被取消)); } else { reject(new Error(请求失败状态码${xhr.status})); }5.2 响应类型为 json 时空字符串会导致 JSON.parse 崩溃这是一个非常隐蔽的坑。我做了一个接口后端在某种异常场景下返回了空 bodyHTTP 状态码仍然是 200。我的封装里写了resolve(JSON.parse(xhr.responseText));然后页面直接白屏。去 Console 一看Uncaught SyntaxError: Unexpected end of JSON input原因很简单JSON.parse()会抛异常而这个异常发生在resolve之前并不会自动被 Promise 捕获于是它变成了一个未捕获的同步错误顺着调用栈往外冒。修复方案有两种第一种判断响应内容是否为空const text xhr.responseText; if (responseType json text) { try { resolve(JSON.parse(text)); } catch (e) { reject(new Error(响应解析失败不是合法 JSON)); } } else { resolve(text); }第二种直接用xhr.response配合responseType json。当你把xhr.responseType设为json后浏览器会自动帮你解析 JSON如果解析失败xhr.response会是null。这就避免了手动JSON.parse的崩溃问题。但要注意如果响应本身不是合法 JSON浏览器在赋值 response 时可能会抛出异常在某些浏览器里这个异常会静默处理但依然值得用 try...catch 包一下。我现在的封装里采用的就是第二种思路优先用xhr.response拿不到再尝试手动解析。两种都失败就 reject。这样最稳。5.3 Uncaught (in promise) error 是怎么冒出来的这个报错简直是 Promise 新手噩梦原文长这样Uncaught (in promise) error: ...它的产生机制其实前面已经铺垫过了你创建了一个 rejected 的 Promise但你没有给它挂 catch。这个错误不会被吃掉而是被浏览器抛到全局。在我们这个封装里最常见的触发场景是promiseAjax({ url: /api/user }).then((data) render(data));你只处理了成功没处理失败。当接口返回 500 时Promise 变成 rejected但没有.catch接手浏览器就抛出一个 Uncaught (in promise) error。解决方式有两个层面调用方层面始终给 Promise 链加上 catch哪怕你只是简单console.errorpromiseAjax({ url: /api/user }) .then((data) render(data)) .catch((err) console.error(请求失败, err));封装层面可以提供全局错误钩子。比如在封装内部默认给所有 Promise 挂一个 catch把错误交给统一的错误处理函数同时不影响调用方继续 catchconst p new Promise((resolve, reject) { /* ... */ }); p.catch((err) { // 全局错误上报或统一提示 console.error([request error], err); }); return p;注意p.catch(...)返回的是一个新 Promise不影响你 return 给调用方的原 Promise。这样既做了全局兜底又保留了调用方的独立错误处理能力不过副作用是调用方如果不 catch错误还是会被全局兜底 catch 掉就不会再报 Uncaught (in promise) error 了。这正是我们想要的。这里顺便提一下如果封装内部在xhr.onerror里 reject而调用方在.then里第二个参数也处理了失败那同一个错误会不会被处理两次我的经验是不会因为 Promise 的状态一旦变成 rejected后续不管哪个分支处理用的都是同一个“失败结果”不存在重复消费的问题。5.4 请求头的坑setRequestHeader 必须在 open 之后、send 之前这个坑不深但真的很常见。XHR 的setRequestHeader必须在open之后调用在send之前调用。你在open之前就调setRequestHeader浏览器会静默忽略或抛异常取决于浏览器在send之后调几乎必然抛异常。而且还有个细节不能设置Content-Type为multipart/form-data当你 send 的是 FormData 对象时浏览器会自动带上正确的 Content-Type 和 boundary你手动设置反而会导致边界错误。我的封装里对这几个头做了白名单处理如果用户传入的 headers 中包含Content-Type且 data 是 FormData 类型就忽略这个自定义头。这个细节是让我被坑了好几次才加上去的。5.5 关于编码格式GET 请求的 query 必须 encodeURIComponent热搜词里有一个“ajax请求设置编码格式”我顺带在这里讲清楚。AJAX 的编码问题主要体现在 GET 请求的参数拼接上。如果参数里包含中文、特殊字符比如、、#直接拼到 URL 上会导致中文变成乱码被服务端误解为参数分隔符#后面的内容被浏览器当成锚点根本不会发送到服务端正确的做法是使用encodeURIComponent对 key 和 value 都编码。encodeURIComponent比encodeURI更彻底连/、?、、#这些保留字符都会转义适合用在 query 参数上。我前面的代码里已经用到了const queryString Object.keys(data) .map((key) ${encodeURIComponent(key)}${encodeURIComponent(data[key])}) .join();这是一个很容易忽略但一旦出错就很麻烦的点建议各位封装成固定写法别嫌丑。6. 封装和 fetch 对比什么时候用哪个6.1 fetch 的简洁背后藏着三个默认行为fetch 出来之后社区很兴奋觉得终于可以抛弃 XHR 了。它确实简洁const res await fetch(/api/user); const data await res.json();两行搞定一个 GET 请求语法太舒服了。但实际用下来你会遇到一些默认行为带来的“惊喜”第一个是 fetch 默认不带 cookie。你需要显式设置credentials: include而同源下的 XHR 默认就带 Cookie。如果项目是前后端分离、跨域部署这个差异会把你的登录态搞丢。第二个是 fetch 不会 reject 4xx/5xx。常见误解fetch 遇到 404 会走 catch。其实不会。fetch 只有在网络层失败断网、DNS 错误、跨域被拦时才会 rejectHTTP 状态码 404、500 对 fetch 来说都是“成功响应”。你同样需要手动检查res.ok或res.status。第三个是 fetch 没有内置超时。你要实现 5 秒超时还得靠AbortController和setTimeout配合。虽然有官方方案但写起来并不短。这三点让我在项目里经常跟团队强调fetch 是“看起来简单”但真实场景的复杂度并没有消失只是转移到了调用方身上。而这恰恰就是我们封装 XHR 的价值所在——把复杂的细节内聚在函数内部外部保持简单。6.2 自封装 XHR 的不可替代场景既然 fetch 这么流行为什么还有场景必须用 XHR我举几个实际遇到的场景一上传文件的进度反馈。fetch 目前对上传进度没有原生支持你只能靠XMLHttpRequest的upload.onprogress事件。如果你做的是一个文件上传后台没有进度条用户会很不爽这时候 XHR 几乎是唯一选择。场景二IE 兼容。虽然 IE 已经变成历史名词了但在某些政府、银行内网项目里你依然可能被迫面对旧内核浏览器。fetch 在这些环境里不可用XHR 从 IE6 时代就开始被支持。别笑我 2023 年还碰过一个要求兼容 IE11 的金融项目。场景三更精细的请求控制。XHR 的onreadystatechange让你能在下载中途做各种处理比如提前解析响应头、判断是否需要重定向upload.onprogress、abort、timeout的组合也更成熟。fetch 的 AbortController 虽然能用但在兼容性和细节上还没有 XHR 那么“久经沙场”。当然我还要说一个反直觉的结论如果你的项目就是标准浏览器环境且没有特殊需求直接用 fetch 其实更合适。我封装 XHR 并不是为了取代 fetch而是为了理解底层、应对特殊场景、保留自己的技术储备。选择哪种方案取决于你的项目环境而不是社区潮流。附动手实践时的一些个人体会最后再分享几条我在写这个封装时反复验证过的体会希望能帮你少走弯路。第一封装不是越复杂越好而是越符合你的业务模型越好。我见过有人把 xhr 封装写了两千行支持拦截器、队列、重试、缓存、轮询结果团队里没人能维护最后被迫重写。小项目的基础封装控制在 150 行以内把超时、取消、错误处理做好就足够应付绝大多数场景了。第二错误信息的可读性非常重要。如果你在 reject 里只写new Error(请求失败)排查问题的时候你会疯掉。我建议至少带上状态码、请求地址、方法。比如请求失败GET /api/user 404一眼就能定位问题。第三用封装之前务必亲手裸写一次 XHR。不是开玩笑只有你亲手处理过readyState的五个状态亲手踩过status为 0 的坑你才能理解封装里每一行代码到底在解决什么问题。不然你用封装也只是在抄代码出了问题照样无处下手。第四别忘了把封装的返回值设计清楚。我现在的约定是resolve 时返回响应数据本身已经解析好 JSONreject 时返回一个 Error 对象Error 上额外挂了status、url、method等字段。这样调用方既能通过err.message展示错误信息也能通过err.status做更精细的分支判断比如 401 统一跳登录页。希望这篇内容能帮你对 Promise 和 AJAX 有一个从原理到落地的完整认识。你可以在自己的项目里试着把原生的XMLHttpRequest用起来也可以用这个封装替换一部分 fetch 逻辑多跑几轮、多踩几次坑你会比我写一万字更有收获。