ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript异步请求封装:从Promise原理到手写AJAX与fetch实践

JavaScript异步请求封装:从Promise原理到手写AJAX与fetch实践 1. 为什么要封装裸写 AJAX 的阵痛1.1 裸写 XHR 的三种痛刚入行那阵子我写 AJAX 请求基本都是这种风格const xhr new XMLHttpRequest() xhr.open(GET, /api/user/list) xhr.setRequestHeader(Content-Type, application/json) xhr.onreadystatechange function () { if (xhr.readyState 4) { if (xhr.status 200 xhr.status 300) { const data JSON.parse(xhr.responseText) renderList(data) } else { console.error(请求失败, xhr.status) } } } xhr.send()写多了之后三个问题非常突出。第一是重复代码铺天盖地。打开方式、请求头、状态判断、响应解析每个请求都要把这一整套流程重新走一遍。哪怕只是把某个请求的 URL 从/a改成/b这段模板代码依然要原样复制一遍。第二是回调地狱。当时很多业务是“请求用户信息再根据用户角色请求菜单再根据菜单请求权限”。用原生 XHR 写出来onreadystatechange 的嵌套起码三层起步中间任何一层出错错误都要手工往上抛代码的可读性差到让人想辞职。第三是错误处理完全失控。网络错误、超时、HTTP 状态码异常、JSON 解析失败这四种错误在裸写时各有各的触发点处理方式也各不相同。有人只处理了状态码有人只处理了网络错误更多人是一个都没处理生产环境报错了只能靠用户截图。这些痛点本质上不是什么技术难题纯粹是工程化缺失。你需要一个东西把所有请求的公共逻辑收敛起来把异步流程改造成可读、可维护的结构——这就是 Promise 封装 AJAX 的核心价值。1.2 封装解决的不只是代码量更是契约很多人以为封装就是抽个函数把 XMLHttpRequest 包一层。我见得太多这种半吊子封装了——确实减少了点重复代码但团队里每个人调用的方式还是五花八门错误处理依旧各写各的。真正可靠的请求封装解决的是契约问题。什么契约就是你规定好所有请求函数都返回 Promise所有成功都走 resolve所有失败都走 reject返回的数据结构是统一的。这个约定一旦建立上层业务代码就不需要关心“这个请求到底怎么发出去的”只需要关心“拿到数据之后干什么”和“出错之后怎么兜底”。举个例子。后端返回的数据包装格式一般是{ code: 0, data: {...}, message: ok }但总有那么一两个接口不走寻常路。如果没有统一封装每个页面都要自己判断 code 字段等于把后端的接口规范问题下放给了每个前端开发。有封装之后你可以在响应拦截器里统一解包code 非 0 就直接 reject业务层拿到的永远是干净的 data。这种统一约定的价值在多人协作的项目里会被无限放大。新人来了不用问你“咱们项目请求怎么写”看一眼封装就知道怎么用接口报错不用你去每个页面里加 console统一打印一份就够了。1.3 一个好的 Promise 封装应该具备什么在动手写之前先明确目标。我的标准很朴素一共六条返回 Promise支持 async/await彻底告别回调嵌套支持常见 HTTP 方法GET、POST、PUT、DELETE支持请求头、超时、响应类型配置统一错误分类网络错误、超时错误、HTTP 状态错误、解析错误用不同错误类型或错误码区分支持请求拦截和响应拦截方便做登录态注入、token 刷新、统一错误提示支持取消请求防止竞态和组件卸载后更新状态做到了这些基本就是一个小型 axios 了。别被“小型”两个字迷惑实际项目中 90% 的场景一个 100 行左右的封装完全够用根本不需要引第三方库。2. 原理剖析XMLHttpRequest 与 Promise 是怎么耦合的2.1 XHR 是事件驱动Promise 是状态驱动想写出好的封装先得把两个东西的原理吃透。XMLHttpRequest 的工作方式可以概括为一个状态机它内部有 5 种状态由readyState属性表示readyState含义触发时机0UNSENT刚创建open() 未调用1OPENEDopen() 已调用2HEADERS_RECEIVED已收到响应头3LOADING响应体下载中4DONE请求完成你通过监听onreadystatechange事件在 readyState 变为 4 的时机去读取响应。换句话说XHR 把异步结果“推送”给你你得自己去订阅这个事件。Promise 就不一样了。它内部也有三种状态pending进行中、fulfilled已成功、rejected已失败。关键约束是状态一旦从 pending 变为 fulfilled 或 rejected就永远不可逆转且只能变一次。你不需要订阅任何事件而是通过.then()和.catch()注册回调Promise 会在状态确定后自动调用对应的回调。一个是“事件来了你自己处理”一个是“状态定了我主动通知你”。这两种模型天然可以桥接。2.2 桥接方法在事件回调里 resolve 和 reject桥接的核心思路非常直接把 XHR 的异步事件转成 Promise 的状态变更。当请求成功时调用resolve让 Promise 进入 fulfilled 状态当请求失败时调用reject让 Promise 进入 rejected 状态。这样XHR 的事件模型就被“翻译”成了 Promise 的状态模型。这里要注意一个点XHR 的失败有多个出口。onerror对应网络错误ontimeout对应超时状态码非 2xx 属于 HTTP 错误JSON.parse失败属于解析错误。这四个出口在封装里都要一一对应到 reject。还有一个很多人容易忽略的地方resolve和reject是幂等的同一个 Promise 只会接受第一次调用。所以即使 XHR 事件被触发了多次Promise 的状态也只会变更一次。这正好利用上了 Promise “状态不可逆”的特性避免了裸写 XHR 时的重复处理问题。3. 手写一个 Promise 版的 AJAX 封装3.1 最小骨架new Promise 包裹 XHR先看最核心的骨架理解这段代码后面的增强都是在这个基础上堆功能function request(options) { return new Promise((resolve, reject) { const xhr new XMLHttpRequest() xhr.open(options.method || GET, options.url) xhr.onreadystatechange function () { if (xhr.readyState ! 4) return if (xhr.status 200 xhr.status 300) { resolve(xhr.responseText) } else { reject(new Error(HTTP ${xhr.status}: ${xhr.statusText})) } } xhr.onerror function () { reject(new Error(Network Error)) } xhr.send(options.data || null) }) }这段代码虽然简陋但已经把核心逻辑讲清楚了readyState 变为 4 时读取结果2xx 走 resolve其他情况走 reject网络错误单独处理。你用 async/await 调用它就能彻底摆脱回调地狱async function loadUser() { try { const text await request({ url: /api/user, method: GET }) console.log(text) } catch (err) { console.error(请求失败, err.message) } }3.2 参数归一化与查询字符串拼接骨架有了接下来补全工程细节。第一个要解决的是参数问题。GET 请求的参数要拼在 URL 上POST 的参数要放到请求体里而且格式可能是 JSON也可能是表单。这部分的处理是封装里最容易出乱子的地方。我常用的做法是写两个工具函数一个负责把对象拼成查询字符串一个负责准备请求体function buildQuery(url, params) { if (!params) return url const query Object.keys(params) .map((key) ${encodeURIComponent(key)}${encodeURIComponent(params[key])}) .join() return url.includes(?) ? ${url}${query} : ${url}?${query} } function buildBody(method, data) { if (!data) return null if (method GET || method DELETE) return null return typeof data string ? data : JSON.stringify(data) }注意encodeURIComponent这个必须用。我见过太多人直接用模板字符串拼 URL参数里一旦出现中文或者特殊字符请求直接乱码或 400。用encodeURIComponent编码之后中文参数才能安全传输。3.3 超时与错误分类超时处理的原生 API 很简单设置xhr.timeout毫秒单位监听xhr.ontimeout事件。但这里有几个隐藏细节。第一timeout属性必须在open()之后、send()之前设置。写在其他地方有些浏览器不生效。第二超时之后 XHR 已经终止但为了避免其他事件继续触发导致重复回调最好手动abort()一下。第三错误要分类。我习惯自己定义一个小的错误体系至少能区分网络错误、超时错误、HTTP 错误、解析错误。这样上层业务可以根据错误类型做不同处理——网络错误可以提示“请检查网络”超时错误可以提示“请求超时请重试”HTTP 错误则把状态码暴露出去方便排查。3.4 完整实现一个够用的 ajax 函数把上面的思路全部合并写一个我实际项目里常用的版本完整代码如下function ajax(options) { const { url, method GET, data null, params null, headers {}, timeout 10000, responseType json, } options return new Promise((resolve, reject) { const xhr new XMLHttpRequest() const requestURL buildQuery(url, params) xhr.open(method, requestURL) xhr.timeout timeout xhr.responseType responseType // 设置请求头 Object.keys(headers).forEach((key) { xhr.setRequestHeader(key, headers[key]) }) xhr.onreadystatechange function () { if (xhr.readyState ! 4) return if (xhr.status 200 xhr.status 300) { try { // 如果 responseType 是 jsonxhr.response 会被自动解析 // 但保险起见对于 text 类型手动解析 const data responseType json ? xhr.response : JSON.parse(xhr.responseText) resolve(data) } catch (err) { reject(new Error(Response Parse Error)) } } else { const httpError new Error(HTTP ${xhr.status}: ${xhr.statusText}) httpError.name HttpError httpError.status xhr.status reject(httpError) } } xhr.onerror function () { const error new Error(Network Error) error.name NetworkError reject(error) } xhr.ontimeout function () { xhr.abort() const error new Error(Request Timeout (${timeout}ms)) error.name TimeoutError reject(error) } const body buildBody(method, data) xhr.send(body) }) }为了方便调用再导出几个语义化方法export const http { get: (url, params, config {}) ajax({ url, method: GET, params, ...config }), post: (url, data, config {}) ajax({ url, method: POST, data, ...config }), put: (url, data, config {}) ajax({ url, method: PUT, data, ...config }), delete: (url, params, config {}) ajax({ url, method: DELETE, params, ...config }), }到这里一个基础但完整的 Promise 封装就落地了。3.5 使用示例实际调用是这样的// GET 请求 const list await http.get(/api/product/list, { page: 1, size: 20 }) // POST JSON 请求 const result await http.post(/api/order, { productId: 1024, count: 2, }, { headers: { Content-Type: application/json }, })所有请求函数都返回 Promise配合 async/await代码从头到尾都是同步阅读的节奏。出错时catch 里能拿到带有name和status的异常一眼就能分辨是哪类错误。4. 进阶落地拦截器、取消请求与 fetch 对比4.1 用类实现请求拦截器上面的函数式封装已经能应付大部分场景了但如果你要处理登录态注入、token 刷新、统一错误提示这些需求还需要一个拦截器机制。最早听说过 axios 的拦截器其实原理不复杂在请求发出之前先把配置对象过一遍拦截器链在 Promise resolve/reject 之前把响应过一遍拦截器链。用类来实现这个思路最清晰class HttpClient { constructor() { this.requestInterceptors [] this.responseInterceptors [] } useRequestInterceptor(fn) { this.requestInterceptors.push(fn) } useResponseInterceptor(fn) { this.responseInterceptors.push(fn) } request(options) { let config options // 请求拦截可以修改配置 this.requestInterceptors.forEach((fn) { config fn(config) || config }) return ajax(config).then( (response) { // 响应拦截可以解包数据 this.responseInterceptors.forEach((fn) { response fn(response) || response }) return response }, (error) { // 响应错误拦截可以做统一提示 this.responseInterceptors.forEach((fn) { error fn(error) || error }) throw error } ) } } // 使用 const client new HttpClient() client.useRequestInterceptor((config) { const token localStorage.getItem(token) if (token) { config.headers { ...config.headers, Authorization: Bearer ${token} } } return config }) client.useResponseInterceptor((response) { if (response.code response.code ! 0) { throw new Error(response.message || 业务错误) } return response.data })拦截器的价值在于token 过期需要统一跳登录页、接口报错需要统一 toast这类横切关注点不用在每个业务页面里重复处理写一处就够了。4.2 取消请求的正确姿势在很多场景下用户点了按钮发起请求又立刻点了另一个按钮这时候前一个请求的响应其实已经没有意义了。如果它晚到一步就可能把新数据覆盖掉。取消 XHR 请求非常简单调用xhr.abort()。但要配合 Promise 封装还得考虑清楚一件事——abort 之后 Promise 应该怎么处理。我的做法是抛出一个专门的错误让调用方知道这个请求是“被取消的”而不是“失败的”。// 在封装里增加一个 ctrl 参数 function ajax(options) { const xhr new XMLHttpRequest() if (options.ctrl) { options.ctrl.aborter () { xhr.abort() const error new Error(Request Cancelled) error.name CancelError reject(error) } } // ... 原逻辑 } // 使用 const ctrl {} http.get(/api/search, { keyword }, { ctrl }) // 需要取消时 ctrl.aborter()如果项目已经用了 fetch那就简单得多直接使用标准的AbortController就行这个在 4.3 里会一起说。4.3 fetch 版封装async/await 与 AbortController聊到 Promise 封装绕不开 fetch。fetch 本身返回 Promise语法上比 XHR 简洁得多但它在实际使用中有三个坑网络错误才 rejectHTTP 4xx、5xx 不会 reject需要手动检查response.ok没有内置超时机制要用 AbortController 模拟默认不带 cookie需要手动设置credentials基于 fetch 的封装核心代码如下async function requestFetch(options) { const { url, method GET, data null, headers {}, timeout 10000, } options const controller new AbortController() const timer setTimeout(() controller.abort(), timeout) try { const response await fetch(url, { method, headers: { Content-Type: application/json, ...headers, }, body: data ? JSON.stringify(data) : undefined, signal: controller.signal, credentials: same-origin, }) if (!response.ok) { throw new Error(HTTP ${response.status}) } return await response.json() } catch (err) { if (err.name AbortError) { throw new Error(Request Timeout) } throw err } finally { clearTimeout(timer) } }这里有个细节controller.abort()会使 fetch 抛出一个名字为AbortError的异常所以 catch 里要判断一下把取消/超时场景翻译成可以被上层理解的错误。4.4 为什么我不直接推荐所有人用 fetch这是个很实际的问题。我见过很多团队宣称“全面拥抱 fetch”过了俩月又默默退回 XHR 封装。原因无非两点。第一fetch 的核心优势在浏览器端并没有想象中明显。XHR 封装一遍之后调用方式同样是 async/await两者的代码表达差距不大但 XHR 的兼容性和进度事件如上传进度是 fetch 短期内追不上的。第二axios 这类库至今统治力不减说明 XHR 方案的工程沉淀足够深厚。如果你所在项目只需要发请求、收数据我建议直接用 XHR 封装而不是 fetch因为它少了一层“响应流”的抽象心智负担更小。如果项目高度依赖流式处理或者需要和 Service Worker 配合那再上 fetch 也不迟。5. 踩坑记录常见问题与排查思路5.1 “uncaught (in promise)”到底哪来的很多人在控制台见过Uncaught (in promise) Error第一反应是封装写错了。其实这个报错的意思是有一个 Promise 被 reject 了但没有任何地方去接住这个 reject。最常见的原因是 async 函数里的await没有包在try/catch中或者调用封装时只用了.then()没写.catch()。比如下面这种async function fetchData() { const data await http.get(/api/user) // 请求失败时异常往上抛 } fetchData() // 没有 catch控制台就报 Uncaught (in promise)这个报错本身不是封装的锅而是调用方没有尽到处理错误的义务。但好的封装可以给一层兜底在全局监听unhandledrejection事件把没有处理的 Promise 异常统一接手至少打印出清晰的堆栈信息避免误以为代码静默失败。5.2 请求参数编码与中文乱码热搜词里有一条“ajax请求设置编码格式”这确实是个高频问题。GET 请求中文乱码十有八九是 URL 拼接时没有编码。上面封装里用了encodeURIComponent这个能解决大部分问题。但要注意服务端如果用的是application/x-www-form-urlencoded解析后端拿到的中文是 UTF-8 编码。如果后端默认用 ISO-8859-1 解析就会乱码。这属于前后端约定不一致前端能做的就是统一 UTF-8并在请求头里带上charsetutf-8。POST 请求乱码则要看 Content-Type。application/json本身默认就是 UTF-8但如果你用 jQuery 或者原生 XHR 发送表单格式编码就可能出问题。一个比较稳妥的写法是xhr.setRequestHeader(Content-Type, application/x-www-form-urlencoded; charsetUTF-8)懒得手写序列化的话用URLSearchParams生成 body 最省事const body new URLSearchParams() body.append(name, 张三) body.append(age, 18) xhr.send(body)5.3 超时到底有没有生效一个很隐蔽的坑xhr.timeout 3000设置了 3 秒超时但请求发出去后明显超过了 3 秒才回调感觉超时没起作用。排查方向有三个。先看是不是设置位置不对。timeout必须在open()之后设置这是浏览器强制要求的。再看是不是同步请求。open(method, url, false)表示同步请求同步请求不支持超时设置浏览器会直接忽略。最后看是不是请求已经被缓存命中如果浏览器直接从缓存里返回了结果ontimeout不会触发。另外超时时间的语义也需要厘清它是“从请求开始到收到响应的总时限”不是“从服务端接到请求到返回数据的时限”。如果网络本身很慢即使服务端处理只要 1 秒客户端也可能超时。5.4 竞态条件老请求覆盖新数据这是异步请求里最经典的问题之一。用户快速输入关键词搜索接口发出 A、B、C 三个请求由于响应速度不同C 可能先回来B 后回来A 最后回来。最后页面上展示的是 A 的结果但用户明明输入的是 C 的关键词。解决思路有三种。最简单的方案是请求序号发新请求时把旧的请求取消掉或者忽略旧请求的返回。配合上面的取消机制直接在发新请求前调用上一次的ctrl.aborter()。还有一种方案是时间戳对比let lastRequestId 0 async function search(keyword) { const requestId lastRequestId const results await http.get(/api/search, { keyword }) if (requestId ! lastRequestId) return // 过期响应直接丢弃 render(results) }这种兜底思路值得写入通用封装里因为很多时候调用方忘记了取消时间戳对比是最后一道防线。5.5 组件卸载后还能 setState 吗React 项目里的经典报错组件卸载后调用setState控制台报 “Cant perform a React state update on an unmounted component”。本质上就是请求还没返回组件已经被销毁了返回后仍然执行了 setState。除了在组件里用useEffect清理函数配合 AbortController 取消请求之外封装层面也可以做一点事情——给请求挂一个 destroyed 标记。但说实话这个坑最好的解法是在业务层。封装只负责提供取消能力不强制取消组件的生命周期管理是 React 或 Vue 层面的事情。所以我强烈建议请求封装和组件层做一个约定凡是进入页面发起的请求组件卸载时必须主动触发取消。这个约定不写进代码里也要写进团队规范里。最后分享一个我自己的体会这套封装前前后后迭代了好多版从最早的裸 XHR 加回调到函数式 Promise 封装再到引入拦截器的类封装每一次改动的核心驱动力都不是“技术更酷”而是“业务层代码能再简单一点”。写封装最忌讳一开始就追求大而全把拦截器、取消、重试、缓存全都堆进去。我实际的经验是先写一个能解决 90% 场景的基础版上线跑一段时间遇到真实痛点再迭代。毕竟封装这东西过度设计的危害比代码不够优雅大得多——团队没人敢动你的请求层那才是最可怕的事。另外如果你在的团队规模不大其实 3.4 小节那个函数式封装就完全够用了。类封装和拦截器是锦上添花属于“需要的时候再加”的扩展点而不是必须从一开始就搭好的框架。等到团队里开始出现统一的 token 注入、统一的错误提示需求时再往拦截器方向演进不仅代码自然同事也更容易接受。
返回列表