写这篇文章的起因很直接我在项目里看到不少同事还在用回调嵌套写 AJAX遇到竞态条件只能加 flag请求超时处理得七零八落。其实 Promise 封装 AJAX 是一个已经被讨论过很多轮的话题但大多数教程只给一个 Promise 包裹 XMLHttpRequest 的 Demo没有解释清楚为什么这么封装、拦截器怎么设计、超时怎么处理、报错为什么是uncaught (in promise)。这篇文章我从原理讲到落地把我在实际项目中踩过的坑和最终沉淀下来的封装方案一块儿聊清楚适合刚接触 Promise 的前端新手也适合想把项目里的请求层整理干净的进阶开发者。1. 整体设计思路为什么需要封装以及封装到什么程度1.1 AJAX 与 Promise 各自解决什么问题AJAX 本身只是浏览器提供的一组 API 组合通过 XMLHttpRequest 或者 fetch 发出网络请求。XMLHttpRequest 的事件模型本质上依然是回调式的onreadystatechange、onload、onerror这些回调函数在真实业务里非常容易形成嵌套地狱。例如一个表单提交后需要刷新列表刷新列表后需要重新渲染图表代码就会变成三层以上的回调嵌套中间任何一层出错都很难追踪。Promise 解决的不是网络请求本身的问题而是异步流程控制的问题。它把“回调函数”的传递方式改成了“状态机 链式调用”。pending、fulfilled、rejected三种状态让异步结果有了明确的流向.then()和.catch()让错误处理变得集中。所以封装的核心思路是把 XHR 的事件回调翻译成 Promise 的 resolve/reject让网络请求能够被async/await直接消费同时保留 XHR 的底层能力。1.2 封装到什么程度才算合格我见过很多封装方案有的只是把new XMLHttpRequest()包进new Promise()里就算完事。这种封装只能说“能用”离“好用”还差得远。一个合格的 AJAX 封装至少应该解决这几个问题请求参数统一URL、method、params、data、headers、timeout、responseType 都能通过同一个入口传入。响应体统一处理成功时 resolve 数据失败时 reject ErrorHTTP 状态码和业务状态码分开判断。错误信息可读网络错误、超时、HTTP 错误、业务错误要返回不同的错误类型。支持横向扩展比如加拦截器、加取消机制、加全局 loading 计数而不是每次都要改核心代码。我常用的封装层级是最底层一个request(options)函数负责最原始的 XHR 通信逻辑往上包一层http对象提供 get/post/put/delete 等快捷方法再往上才是业务层直接使用的 api 接口。这样底层的 xhr 逻辑基本不动新增功能都在中间层叠加维护成本最低。2. 核心细节解析手写一个靠谱的 request 函数2.1 基础版实现与关键点注释先看最核心的代码。我简化掉了业务拦截器只保留 XHR 到 Promise 的翻译过程方便看清楚每个关键点function request(options {}) { const { url, method GET, params {}, data null, headers {}, timeout 10000, responseType json, withCredentials false } options // 拼接 URL query 参数 const queryString Object.keys(params) .filter(key params[key] ! undefined params[key] ! null) .map(key ${encodeURIComponent(key)}${encodeURIComponent(params[key])}) .join() const fullUrl queryString ? ${url}${url.includes(?) ? : ?}${queryString} : url return new Promise((resolve, reject) { const xhr new XMLHttpRequest() xhr.open(method, fullUrl, true) xhr.timeout timeout xhr.withCredentials withCredentials // 设置请求头 Object.keys(headers).forEach(key { xhr.setRequestHeader(key, headers[key]) }) // 是否使用 XHR 原生的 abort 事件 xhr.onabort () { reject(new Error(请求已取消)) } xhr.onerror () { reject(new Error(网络连接错误)) } xhr.ontimeout () { reject(new Error(请求超时${timeout}ms)) } xhr.onload () { // HTTP 状态码在 2xx 和 304 视为成功 if (xhr.status 200 xhr.status 300 || xhr.status 304) { let responseData xhr.response if (responseType json typeof responseData string) { try { responseData JSON.parse(responseData) } catch (e) { reject(new Error(响应数据 JSON 解析失败)) return } } resolve({ data: responseData, status: xhr.status, statusText: xhr.statusText, headers: xhr.getAllResponseHeaders() }) } else { reject(new Error(HTTP ${xhr.status}: ${xhr.statusText || 请求失败})) } } // 发送数据 if (data) { // 如果传的是普通对象默认转 JSON if (typeof data object !(data instanceof FormData)) { xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8) xhr.send(JSON.stringify(data)) } else { xhr.send(data) } } else { xhr.send() } }) }这一段代码里有几个非常容易踩坑的点。第一个是 URL 拼接。很多人直接写${url}?${qs}如果调用方传入的 url 本来就带了?page1query 参数就会被覆盖。所以我判断了url.includes(?)用连接。第二个是xhr.onload和xhr.onreadystatechange的区别。onreadystatechange会在每一次状态变化时触发readyState 4才算请求完成而onload天然只在请求成功完成时触发逻辑更简洁。我用onload后就不再判断 readyState。2.2 为什么选择 XHR 而不是 fetch封装 AJAX 时会有不少人问现在都 2025 年了为什么还要用 XMLHttpRequest直接用 fetch 不好吗我在生产环境里的选择是请求封装层用 XHR只是因为它的兼容性和可控性更稳定。fetch 有几个目前仍然不太舒服的点超时要通过 AbortController 实现没有原生的上传进度事件默认不带 cookie需要手动设置credentials而且 fetch 在 HTTP 400/500 时不会 reject Promise必须在then里手动判断response.ok。XHR 则把超时、进度、abort、cookie 行为都内置在对象上事件模型虽然繁琐但原封不动翻译成 Promise 之后这些问题都不存在了。另外很多企业项目需要兼容还在用老浏览器的用户比如部分政务、金融场景XHR 的兼容面更广。如果你没有特殊需求参照上面代码直接用 XHR 封装是最稳妥的选择。2.3 参数处理的几个细节headers、timeout、responseTypeheaders 的处理要留一个口子。有的场景下需要传Content-Type有的不需要。我的做法是只让外部显式传入的 headers 生效对于普通对象的序列化在send()之前动态补上Content-Type。这里要注意顺序如果先调setRequestHeader再open部分浏览器会直接抛错所以必须在open之后设置请求头。timeout 默认 10000 毫秒是我的个人习惯移动端网络环境复杂10 秒是一个比较中庸的值。如果你的接口本身较慢可以把 timeout 放到 options 里让业务层按需覆盖不要全局写死一个 15 秒。responseType 这个参数容易误解它不是设置“期望的响应数据类型”而是告诉浏览器如何解释响应体。设置responseType json的时候如果服务端返回了非 JSON 内容xhr.response 会变成null所以我在onload里加了字符串类型的兜底 JSON.parse防止某些服务端配置失误导致解析失败。还有一个容易被忽略的点兼容xhr.responseType为json时IE 系浏览器不支持。如果你不用兼容 IE这段可以忽略如果要兼容建议统一把 responseType 留空自己 JSON.parse并在onerror和ontimeout里做单独处理。3. 实操过程从核心函数到完整请求层3.1 封装自动携带参数与鉴权 header定好核心 request 函数之后接下来就是把它扩展成真正被业务使用的请求层。我需要保证所有请求自动携带两部分内容一是公共 query 参数比如当前用户的租户 id、渠道来源、请求签名二是公共 headers比如 token、客户端版本号。我建议不要把公共参数写死在业务代码里而是通过一个configure方法动态设置const http { _defaults: { baseURL: , commonParams: {}, commonHeaders: {} }, configure(options) { Object.assign(this._defaults, options) }, async request(options) { const { baseURL, commonParams, commonHeaders } this._defaults const finalOptions { ...options, params: { ...commonParams, ...options.params }, headers: { ...commonHeaders, ...options.headers }, url: options.url.startsWith(http) ? options.url : baseURL options.url } try { const res await request(finalOptions) return res.data } catch (err) { throw normalizeError(err) } } }这里我用了async/await但不代表放弃 Promise 链本质上await就是 Promise 的语法糖底层依然是状态机。configure方法提供一个全局配置入口业务项目一般在启动时调用一次http.configure({ baseURL: /api, commonParams: { version: 1.2.0 }, commonHeaders: { X-Client-Type: web } })注意commonParams和options.params的合并顺序。我要保证业务侧传入的 params 可以覆盖公共参数所以公共参数在前、业务参数在后展开。headers 同理。但要注意一点有些公共 header 不应该被业务随便覆盖比如Content-Type。因此合并时可以单独处理把Content-Type放在最后设置或者用一个内部字段保护起来。3.2 拦截器像 Axios 一样灵活扩展拦截器是我觉得一个 AJAX 封装和另一个 AJAX 封装拉开差距的地方。业务上最常见的需求是请求发出前显示 loading拿到数据后隐藏 loading接口返回 401 时统一跳转登录页导出文件时判断响应类型。这些逻辑如果散落在一个个调用点后续维护会很痛苦。拦截器的本质是一个中间件队列。我用最简单的实现请求拦截器会在实际请求前执行响应拦截器会在拿到数据或错误后执行。代码不复杂const interceptors { request: [], response: [] } http.useRequestInterceptor (fn) { interceptors.request.push(fn) } http.useResponseInterceptor (fn) { interceptors.response.push(fn) }改造 request 逻辑如下async request(options) { let current options // 请求拦截器 for (const interceptor of interceptors.request) { const result await interceptor(current) if (result false) { // 如果拦截器返回 false直接打断请求 return Promise.reject(new Error(请求被拦截器中断)) } if (result) { current result } } const res await request(current) // 响应拦截器可以在这里统一处理错误 for (const interceptor of interceptors.response) { current await interceptor(res) } return res }用的时候很舒服比如统一处理 tokenhttp.useRequestInterceptor((config) { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } return config }) http.useResponseInterceptor((response) { const { status } response if (status 401) { location.href /login throw new Error(未登录或登录过期) } return response })这里有一个设计细节响应拦截器可以抛异常也可以返回新的值。在实际项目里我更倾向于把“统一错误处理”放在响应拦截器里完成业务层面只需要try { await api.getUser() } catch (e) { message.error(e.message) }不需要再判断 HTTP 状态码。3.3 并发请求与取消请求的正确姿势Promise 和 AJAX 结合还有一个优势是可以直接用Promise.all处理并发。比如页面需要同时拉取用户信息和角色权限可以这样写const [userInfo, roleInfo] await Promise.all([ api.getUserInfo(), api.getRoleInfo() ])Promise.all的特点是“一个失败全部失败”这在大多数场景下符合直觉。如果希望互相不干扰每个请求各自返回成功或失败的结果可以用Promise.allSettled。我在做数据看板时就遇到过这个问题三个接口坏了一个整个看板都白屏了后来改成allSettled才恢复部分渲染逻辑。取消请求在真实项目里非常实用。比如快速切换 Tab 的时候上一个 Tab 的慢请求不应该再回来覆盖当前 Tab 的数据。以前用 XHR 的时候大家习惯在外面存一个xhrList然后逐个abort()。现在有 AbortController可以实现更干净的取消const controller new AbortController() api.getList(params, { signal: controller.signal }) // 切走 Tab 时 controller.abort()但要提醒的是XHR 环境下 AbortController 的支持情况在主流浏览器里没什么问题核心 request 函数需要监听signal的abort事件并调用xhr.abort()。如果直接用 fetchsignal是原生支持的写起来更方便。这个能力是封装层应该预留的否则业务就没法优雅取消。4. 常见问题与排查技巧实录4.1 遇到 Uncaught (in promise) Error 怎么定位几乎每个人在用 Promise 封装 AJAX 时都会遇到这个报错Uncaught (in promise) Error: 请求失败这个报错的本质是某个 Promise 被 reject 了但是在业务代码里没有被.catch或await/ try...catch接住。很多新手看到这个报错会误以为是封装代码的问题其实大部分情况下是调用方的问题。排查分三步。第一步找到报错堆栈里指向的业务代码位置重点看是哪个请求发出的。第二步查看这个请求的调用点有没有await以及外层是否包了try...catch。我见过最多的错误写法是// 错误示范 const data http.get(/api/user) // data 是个 Promise不是数据这里直接就是 bug还有一种是函数声明里 return 了 Promise但调用时漏了 catchfunction fetchUser() { return http.get(/api/user) } fetchUser() // 没有 await也没有 catch这种情况下 Promise 被 reject 后没人接浏览器自然报uncaught (in promise)。第三步如果调用点没问题就去排查封装里的响应拦截器。有些响应拦截器对非 2xx 状态直接抛错但抛错的信息没有上下文需要把请求的 url、method、参数一起包装进 Error 对象里否则后端同学拿着报错信息也没法排查。我还习惯在封装层加一个全局监听捕获没有被业务处理掉的 Promise 错误window.addEventListener(unhandledrejection, (event) { console.error([未捕获的 Promise 错误], event.reason) // 这里可以上报到监控系统 })这个监听不是为了解决问题而是为了把漏网之鱼暴露出来。生产环境里能看到这个错误日志就说明哪个调用点没有做异常处理是很好的排查入口。4.2 数据编码格式与乱码问题热词里有一个“ajax请求设置编码格式”实际开发中确实容易被忽略。我这里分了两个层面请求参数的编码和响应内容的编码。请求参数编码方面一个常见问题是把中文直接拼进 URL比如url ?keyword keyword。如果 keyword 是“苹果手机”浏览器会自动编码一部分但特殊字符比如、#、如果没编码会直接破坏 URL 结构。所以在我的封装里统一用encodeURIComponent()对 params 的值编码。如果 params 里原来已经有编码好的字符串再编码就会变成双重编码因此我在内部公共参数里做了约束传入 params 的对象键值都是原始值由封装统一负责编码。还有一个场景是表单提交时application/x-www-form-urlencoded格式。直接JSON.stringify(data)发送后端用$_POST可能是拿不到的。需要把对象转成keyvaluekeyvalue的形式function toFormData(data) { return Object.keys(data) .map(key ${encodeURIComponent(key)}${encodeURIComponent(data[key])}) .join() }响应内容的乱码问题主要是后端返回的字符集与前端预期不一致。设置xhr.responseType为json时浏览器会使用响应头里的Content-Type的charset来解码。如果后端漏了charsetutf-8而实际返回的又是 UTF-8 编码部分浏览器会按 ISO-8859-1 解码出现乱码。这种情况在前端难以彻底解决最好的办法是推动后端统一设置响应头Content-Type: application/json; charsetutf-8。如果后端暂时改不了可以不走 JSON 解析自己拿文本用TextDecoder(utf-8)把xhr.responseText转一下但这种方法只适用于后端确实是 UTF-8 但头写错的情况属于对抗性方案不推荐常驻。4.3 超时、状态码判断和返回值解构的坑超时这块最常见的坑是setTimeout(() reject(...), timeout)和 XHR 的原生timeout属性混用。如果你自己用 setTimeout 实现超时即使请求已经返回那个定时器也会继续存在在 Clean Code 角度是不优雅的而且并发多的时候会有一堆定时器残留。正确做法是利用 XHR 原生的timeout事件同时在onload里不要忘记清除手动设置的定时器。我的封装里直接用原生xhr.timeout没有手动 setTimeout所以不存在这个坑。状态码判断上有两个细节。一是200-299并不包含一切。比如304 Not Modified表示走缓存响应体为空我在成功判断里把它加进去避免缓存场景下误报错。二是xhr.status为0的情况。状态码为 0 常见于本地文件协议或者请求被拦截比如用户直接打开 HTML 文件而没有起本地服务时请求file://协议会得到 status 0。这种情况应该归为错误不能因为0不是 4xx/5xx 就放进成功分支。我在onload里先判断xhr.status ! 0再做范围判断。最后是返回值解构。我在封装里 resolve 的是完整对象{ data, status, ... }但业务层通常只关心data。在中间层http.request里我会统一返回res.data这样业务代码拿到手的就是接口真正的业务数据。这个解构动作要做在封装层而不是让每个业务调用点都去.data解构。否则一旦后端调整了包装结构所有调用点都要改这是封装该控制的变量。5. 从封装到业务一套可直接复用的 api 模块5.1 划分 api 文件和模块封装的最后一公里是把请求层和业务 API 对应起来。我习惯以一个功能域为单位建一个 api 文件比如src/api/user.js、src/api/order.js按模块划分而不是把二十个接口全堆在一个文件里。每个接口定义就是一个函数比如import http from ../http export function fetchUserList(params) { return http.request({ url: /user/list, method: GET, params }) } export function createUser(data) { return http.request({ url: /user, method: POST, data }) }这里有个规律值得说一下GET 请求用paramsPOST/PUT 请求用data。很多项目乱用GET 请求把参数放在 data 里XHR 的send(data)确实也会发送请求体但 GET 请求的请求体一般不规范代理、缓存、日志都可能出问题。规范是GET 用 queryPOST 用 body这也是后端同学普遍接受的标准。5.2 接口返回结构统一化处理我在中间层http.request里已经做了return res.data但res.data本身还有一层后端业务包装。常见的后端返回结构是{ code: 0, message: ok, data: { ... } }这种情况下我还会再做一次“业务包装”转换把code是否等于0作为最终成功条件。这一步应该在响应拦截器的前部做完因为后续拦截器应该拿到的是纯业务数据而不是一层有 code 的壳子。http.useResponseInterceptor((res) { const body res.data if (body typeof body.code ! undefined) { if (body.code 0) { return body.data } throw new Error(body.message || 业务处理失败) } return body })代码看起来很短但要注意分类。code是业务状态码HTTP status是传输层状态码两者不能混为一谈。HTTP 200 不代表业务成功可能后端返回了{code: 5001, message: 库存不足}。如果封装里只判断 HTTP 状态码就 return业务代码还得再判断一次 code这套封装就算没做透。5.3 loading 计数与全局进度态最后分享一个我在后台系统里常用的技巧。每次请求都要手动开 loading 很麻烦而且并发请求时一个 loading 被另一个覆盖。我有两个方案可选一是基于拦截器做全局 loading二是让业务按需传入loading配置。全局 loading 的核心是计数而不是布尔值let pendingCount 0 http.useRequestInterceptor((config) { if (config.loading ! false) { pendingCount showLoading() } return config }) http.useResponseInterceptor((res) { if (res.config.loading ! false) { pendingCount Math.max(0, pendingCount - 1) if (pendingCount 0) { hideLoading() } } return res })这里有个细节如果响应拦截器在请求成功时返回了新的值那请求配置config就得想办法传递到响应阶段。我的做法是在请求拦截器阶段给config打一个标记存到一个模块级变量里响应拦截器再去取。更优雅的是把 config 挂到res.config上但这个取决于你封装的 resolve 对象里有没有保留 config。实际代码里我会在request函数内部把 options 一并 resolve 出去这样拦截器里就能拿到。不过如果页面局部需要 loading最好还是用局部变量控制不要依赖全局。全局 loading 适合顶部进度条这类覆盖型 UI不适合局部按钮的 loading 态。封装时给每个请求都留一个loading开关默认 false按场景开启这是我在多个项目里反复调整后觉得最舒服的用法。写在最后的几个习惯我自己在实际封装过程中最重要的习惯是先把单一请求函数写稳再考虑拦截器和各种扩展。很多同学一上来就想设计一套媲美 Axios 的架构结果被各种边界问题缠住。其实 XHR 到 Promise 的核心代码只需要二十多行难的是后续的细节URL 拼接、编码、状态码、拦截器顺序、取消逻辑、错误还原。其次封装层是全局共享的改一个坑可能影响所有业务接口所以再加新功能时我会先写小 demo 验证再放进正式封装。特别提醒不要在核心 request 函数里直接加业务逻辑比如“如果是某个接口就跳转”这是封装腐化的开始业务逻辑请一律通过拦截器或 api 层处理。如果你看完准备动手改自己的项目建议从最基础的request函数开始替换把现有代码里的$.ajax或axios逐步迁移到自己的封装里。迁移过程中记录每条接口的差异点比如哪些接口需要上传进度、哪些接口需要取消、哪些接口的响应体结构不一样这些记录就是下一版封装迭代的依据。