尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用原生 XHR 封装一个轻量 AJAX 库:参数、编码、超时与拦截器全解

用原生 XHR 封装一个轻量 AJAX 库:参数、编码、超时与拦截器全解 作为一个常年跟老项目打交道的前端我对 AJAX 的感情很复杂。它帮我们实现了页面无刷新交互但原生XMLHttpRequest的用法又实在啰嗦——每次请求都要写一大段onreadystatechange处理完又要手动JSON.parse项目里的同款代码能复制出几十份。到了新项目大家都在用 axios、fetch可老项目里既不能随便引库又不值得为几个接口硬塞一个重框架这时候自己动手做一套 ajax 的 js 代码封装反而是最省心、最可控的方案。这篇博文我不打算泛泛讲 AJAX 概念而是从原生 XHR 出发一步步把封装思路、参数处理、编码格式、超时拦截这些细节讲透最终给你一份可以直接复制进项目的ajax.js。同时会把 ajax 请求设置编码格式、参数拼接、返回值格式化这几个高频坑一起解决掉。适合正在从原生写起、想做工具沉淀或者被项目里大量重复请求代码折磨的朋友参考。1. 为什么我坚持用原生 XHR 做代码封装1.1 先看原生 XHR 的真实面目很多人写 AJAX 习惯了抄模板却很少认真想想原生代码到底长什么样。我先把最原始的一个 GET 请求摆出来let xhr new XMLHttpRequest(); xhr.open(GET, /api/user?id1001, true); xhr.onreadystatechange function () { if (xhr.readyState 4) { if (xhr.status 200 xhr.status 300) { try { let data JSON.parse(xhr.responseText); console.log(data); } catch (e) { console.error(数据解析失败, e); } } else { console.error(请求失败, xhr.status); } } }; xhr.send();单看这一个请求还没什么但项目里有登录接口、列表接口、详情接口、提交接口每个接口都要把这段结构重复一遍。更难受的是有的同事会在readyState 3时去读数据有的不判断status直接进回调有的把responseText原样交给页面还有的处理不了异常直接白屏。代码一多光排查这类问题就能耗掉半天。1.2 重复代码背后藏着的四个真实问题第一个问题是重复编码。同样的readystatechange判断逻辑、同样的JSON.parse、同样的错误回调写完一遍又一遍改了 A 接口忘了 B 接口很难维护。第二个问题是错误处理不一致。没有统一封装时每个人对“请求失败”的理解都不一样有人只判断status有人只在catch里弹提示有人干脆什么错误都不弹。线上出问题数据对不上连日志都难查。第三个问题是返回格式不统一。有的接口返回{ code: 0, data: ... }有的直接返回数组有的把数据塞在message里。如果不做统一格式化前端每个页面都要单独适配白写很多兼容代码。第四个问题是参数拼接麻烦。GET 请求要手动拼 query string对象参数要先遍历转成keyvalue再拼上稍不注意就拼接出一个带undefined的脏 URL。POST 时要区分application/x-www-form-urlencoded和application/json写错一个后端就收不到参数。1.3 我给自己定的封装目标既然决定封装就得先立几条规矩不然封到一半准会跑偏。我当时给自己定了四个目标第一调用方式必须是ajax({ url, method, data })这种对象传参不搞多参数堆叠一眼能看懂第二默认按 JSON 处理请求和响应但必须支持覆盖老系统里 URL-encoded 格式也很常见第三统一成功和失败回调所有请求都走同一套错误码处理第四要预留拦截器位置方便以后做登录态校验、token 注入、统一 loading。这几条目标看着简单实现的每一环都有细节要处理。下面我把拆解过程写出来。2. 动手前必须想清楚的几个技术点2.1 为什么 GET 和 POST 在封装里不能只改 method很多人会忽略一个问题GET 和 POST 的参数传递方式完全不同。GET 参数要拼在 URL 上POST 参数放在请求体body里。如果封装时只把method改成POST却依然把数据塞在 URL 上后端从 request body 里取不到任何东西接口必然报错。实际上POST 也分好几种编码风格。x-www-form-urlencoded对应表单提交application/json对应 JSON 字符串multipart/form-data对应文件上传。三种方式后端接收参数的方式完全不同。封装的核心矛盾就在这既要让调用者传“对象”这么方便又要在内部自动判断该用什么方式发送。所以我在设计时定了一条规则非 GET 请求如果传的是普通对象就自动转成 URL-encoded 字符串如果传的是 JSON 字符串就自动设置application/json如果传的是 FormData 就自动让浏览器设置multipart/form-data边界。2.2 ajax 请求设置编码格式Content-Type 的三个关键场景这个点真的是高频问题尤其大家前后端联调时很容易踩坑。我把三种编码格式整理了一下编码格式请求头设置请求体示例适用场景application/x-www-form-urlencodedContent-Type: application/x-www-form-urlencoded;charsetUTF-8name张三age18传统表单提交、PHP/Java 简单接口application/jsonContent-Type: application/json;charsetUTF-8{name:张三,age:18}RESTful API、Vue/React 项目主流multipart/form-data浏览器自动设置带 boundary 分割线FormData 对象文件上传、图片上传、混合表单我遇到过不少同事在 POST 一个普通对象时直接JSON.stringify然后不加请求头后端解析不出来就说是前端问题。其实后端框架不同期望的编码格式也不同封装时统一处理好能少一半沟通成本。2.3 给 ajax 请求参数赋值的坑对象转查询字符串真正开始写代码时你还会碰到一个麻烦对象转查询字符串并不是简单 for-in 拼接。{ name: 张三, tags: [a, b] }这种带数组的值序列化后应该是什么name张三tagsatagsb还是name张三tags%5B%5Datags%5B%5Db不同后端约定不同。为了稳妥我的方案是数组直接重复 key也就是tagsatagsb这是大多数后端框架能直接解析的格式。另外嵌套对象我选择降级成 JSON 字符串虽然保守但至少不会丢失数据。还有一点很多人会漏掉null、undefined和空字符串要区别对待我一般在序列化时直接跳过undefined其他值正常拼上。2.4 超时、取消与同步异步的处理AJAX 请求在弱网下可能长时间挂着用户点了提交按钮没反应还以为是卡死了。所以我的封装里默认设置了 15 秒超时通过xhr.timeout和xhr.ontimeout事件来统一处理。如果有特殊接口确实超过 15 秒就在调用时单独传timeout: 30000覆盖。取消请求可以用xhr.abort()。在表格加载、搜索联想等场景里用户频繁操作时应该把上一次请求 abort 掉避免旧请求返回后覆盖新数据。这个能力我也暴露在返回值里方便调用方按需取消。至于同步请求我在封装里直接不鼓励使用同步请求会阻塞主线程导致页面卡顿、按钮点不了体验非常差。2.5 状态码与业务码两套逻辑不能混为一谈HTTP 状态码200表示网络请求成功但不代表业务成功。很多后端接口无论业务对错都返回 HTTP 200只是把业务状态放在 JSON 的code字段里。因此封装必须区分两层处理第一层网络层成功xhr.status在 200~299 区间进入“数据返回”环节第二层业务层判断通过传入的successCode来匹配如果后端返回的code不等于successCode就当作业务失败走统一错误回调。这样的好处是调用方写代码时思路清晰success回调里只处理真正成功的数据error回调里统一弹提示或上报日志。不用每个页面都写if (res.code ! 0)这种重复判断。2.6 返回值统一封装里别直接返回 responseText这算是我踩过的一个坑。早期封装我图省事直接resolve(xhr.responseText)让调用方自己JSON.parse。结果某次后端返回了一段 HTML 错误页页面直接报错。后来我改成统一做 JSON 解析如果解析失败data里保留原文同时抛一个“响应格式异常”的错误标记。这样调用方永远拿到的都是格式化后的对象结构统一排查问题也方便。3. 手把手实现 ajax 的 js 代码封装3.1 第一步参数序列化工具函数先写一个通用的对象转查询字符串函数。这段代码独立封装方便复用后面拼接 URL 和 POST 请求体都能用它function stringifyQuery(obj) { if (!obj || typeof obj ! object) return ; var parts []; for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { var value obj[key]; if (value undefined || value null) continue; if (Array.isArray(value)) { value.forEach(function (item) { parts.push(encodeURIComponent(key) encodeURIComponent(item)); }); } else if (typeof value object) { parts.push(encodeURIComponent(key) encodeURIComponent(JSON.stringify(value))); } else { parts.push(encodeURIComponent(key) encodeURIComponent(value)); } } } return parts.join(); }这个函数的几个细节encodeURIComponent必须加避免中文、特殊字符导致 URL 解析出错数组用重复 key 的方式兼容大部分后端嵌套对象 JSON 序列化虽然简单粗暴但保证不丢数据。实测下来后端用 Spring、Express、Flask 都能正确解析这种格式。3.2 第二步核心 ajax 函数这部分是整套封装的心脏。我把逻辑写完整注释也标清楚方便你直接搬进项目function ajax(options) { var url options.url || ; var method (options.method || GET).toUpperCase(); var data options.data || {}; var timeout options.timeout || 15000; var successCode options.successCode ! undefined ? options.successCode : 0; var headers options.headers || {}; var onSuccess typeof options.success function ? options.success : function () {}; var onError typeof options.error function ? options.error : function () {}; var onComplete typeof options.complete function ? options.complete : function () {}; // 请求拦截器用于模板场景可自行扩展 if (typeof ajax.beforeRequest function) { var beforeResult ajax.beforeRequest(options); if (beforeResult false) { return; } if (beforeResult typeof beforeResult object) { url beforeResult.url || url; data beforeResult.data || data; method (beforeResult.method || method).toUpperCase(); } } var xhr new XMLHttpRequest(); // GET 请求参数拼到 URL if (method GET || method DELETE) { var queryString stringifyQuery(data); if (queryString) { url (url.indexOf(?) -1 ? : ?) queryString; } } xhr.open(method, url, true); // 默认请求头 xhr.setRequestHeader(X-Requested-With, XMLHttpRequest); var body null; if (method POST || method PUT || method PATCH) { if (data instanceof FormData) { // FormData 由浏览器自动设置 Content-Type body data; } else if (typeof data string) { xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8); body data; } else if (options.json true) { xhr.setRequestHeader(Content-Type, application/json;charsetUTF-8); body JSON.stringify(data); } else { xhr.setRequestHeader(Content-Type, application/x-www-form-urlencoded;charsetUTF-8); body stringifyQuery(data); } } // 自定义请求头补充 Object.keys(headers).forEach(function (key) { xhr.setRequestHeader(key, headers[key]); }); xhr.timeout timeout; xhr.onload function () { var responseText xhr.responseText; var result null; var parseError false; try { result responseText ? JSON.parse(responseText) : null; } catch (e) { parseError true; result responseText; } var isHttpOk xhr.status 200 xhr.status 300; var isBizOk result typeof result object result.code successCode; if (isHttpOk isBizOk) { onSuccess(result, xhr); } else { onError({ status: xhr.status, result: result, parseError: parseError, message: (result result.message) || 请求出错 }, xhr); } onComplete(result, xhr); }; xhr.onerror function () { onError({ status: xhr.status, result: null, message: 网络异常请求失败 }, xhr); onComplete(null, xhr); }; xhr.ontimeout function () { onError({ status: xhr.status, result: null, message: 请求超时请稍后重试 }, xhr); onComplete(null, xhr); }; xhr.send(body); // 返回取消方法 return { abort: function () { xhr.abort(); } }; }这个版本的逻辑比网上很多简化封装要完整得多。它同时处理了网络层错误、超时、业务码判断、编码格式切换、拦截器钩子。successCode默认是 0如果你的后端用code: 200表示成功在调用时传successCode: 200就行了。如果后端没有业务码所有 HTTP 2xx 都算成功也可以配置successCode: null把isBizOk判断改为兼容逻辑这块按项目情况微调即可。3.3 第三步Promise 化与调用示例回调函数用起来没问题但多人协作时还是容易写出多层嵌套。我一并做了 Promise 封装调用方可以用.then()链式处理也可以用 async/awaitajax.get function (url, data, options) { var opts Object.assign({}, options, { url: url, data: data, method: GET }); return new Promise(function (resolve, reject) { opts.success resolve; opts.error reject; ajax(opts); }); }; ajax.post function (url, data, options) { var opts Object.assign({}, options, { url: url, data: data, method: POST }); return new Promise(function (resolve, reject) { opts.success resolve; opts.error reject; ajax(opts); }); };调用方式就清爽多了ajax.get(/api/user/list, { page: 1, size: 10 }) .then(function (res) { console.log(用户列表, res.data); }) .catch(function (err) { console.error(加载失败, err.message); });这里要提醒一下Promise 化以后success和error其实是同一个回调函数里的分支只传resolve和reject不会破坏上面onSuccess/onError里封装的业务判断逻辑。实测在 Vue 项目里配async/await非常顺手。3.4 第四步全局配置与拦截器扩展系统里如果有统一的 token、统一的登录失效跳转逻辑封装里留的钩子就能派上用场。我不搞复杂的设计就提供两个全局挂载点够用且好理解// 统一设置 token ajax.beforeRequest function (options) { var token localStorage.getItem(token); if (token) { options.headers Object.assign({}, options.headers, { Authorization: Bearer token }); } return options; };第二个是响应拦截场景。比如后端返回{ code: 401, message: 登录已过期 }时希望统一跳转登录页。这个逻辑在error回调里处理最方便但没必要每个调用方都写一遍。我会在ajax函数外加一个全局错误处理函数ajax.onGlobalError null; // 在上面的 onError 调用处加上一行 if (typeof ajax.onGlobalError function) { ajax.onGlobalError(errorObj, xhr); } // 项目启动时统一注册 ajax.onGlobalError function (err) { if (err.result err.result.code 401) { window.location.href /login; } };这种“全局默认 局部覆盖”的思路在日常业务里已经足够。真要追求拦截器链式组合那直接上 axios 更省事但我们的目标是轻量零依赖所以尽量保持简单。3.5 配套几个高频工具函数封装 AJAX 的过程里我顺手把一些高频辅助函数也沉淀了下来。比如 js 判断字符串是否包含子串老项目里你可能还在用indexOf -1写成工具函数更语义化function strContains(str, search) { if (!str) return false; return String(str).indexOf(search) -1; }再有 js json 转换成数组。接口经常返回一个 JSON 字符串你需要先 parse 再转数组做遍历function jsonToArray(jsonStr) { try { var data JSON.parse(jsonStr); return Array.isArray(data) ? data : []; } catch (e) { return []; } }还有 js 验证 url 有效性虽然是老需求但每次现写都麻烦function isValidUrl(url) { try { var u new URL(url); return u.protocol http: || u.protocol https:; } catch (e) { return false; } }这几个函数跟 AJAX 组合起来基本覆盖了列表页加载、详情页渲染、提交校验的大部分日常场景。4. 高频实战场景把封装用起来4.1 表单提交与 URL 编码格式实战老系统中典型的表单页面前端收集用户信息POST 给后端。如果后端是 Java 的RequestParam或者 PHP 原生$_POST接收请求体必须是 URL-encoded 格式。我们的封装里非 GET 请求默认就走这个格式所以直接调用即可function submitUserForm(formDataObj) { ajax({ url: /api/user/save, method: POST, data: formDataObj, success: function (res) { showToast(保存成功); }, error: function (err) { showToast(err.message || 保存失败); } }); }如果你要提交 JSON 格式传json: true后端用RequestBody接收ajax({ url: /api/user/saveJson, method: POST, json: true, data: { name: 张三, age: 18 }, success: function (res) { /* 处理成功 */ } });有些同事在联调时会问为什么我传了json: true还是收到null排查时优先看请求头里 Content-Type 是否真的是application/json;charsetUTF-8再看请求体是不是标准的 JSON 字符串。用浏览器开发者工具一眼就能定位。4.2 上传文件必须用 FormData文件上传和普通表单提交不一样它要传二进制数据必须用 FormData 对象。浏览器会自动设置multipart/form-data请求头并带上 boundary 分割线手动设置反而会出错。所以封装里我保留了data instanceof FormData的判断分支什么都不用额外处理function uploadAvatar(fileInput, callback) { var formData new FormData(); formData.append(file, fileInput.files[0]); formData.append(type, avatar); ajax({ url: /api/upload, method: POST, data: formData, success: function (res) { callback(null, res.data); }, error: function (err) { callback(err); } }); }实测截图上传时最大的坑在于请求头不能手动加Content-Type否则会把 boundary 覆盖掉后端解析不了文件流。这个封装分支直接帮我们避开了。4.3 省市区三级联动数据加载项目里做省市区选择器一般是一次性加载全部数据前端做联动。我第一次做的时候直接用ajax.get拉取数据然后本地组装ajax.get(/api/region/all) .then(function (res) { var provinces res.data || []; renderProvince(provinces); // 选中省后过滤市 function filterCity(provinceId) { return provinces.filter(function (item) { return item.parentId provinceId; }); } }) .catch(function (err) { console.error(err.message); });这种场景很考验封装的返回值稳定性。接口返回的 JSON 结构如果不统一前端过滤逻辑就很容易崩。有了统一封装res.data永远是一个可预期的字段省了很多undefined边界判断。省市区数据量大一般用get请求会有缓存问题。我的方案是给 URL 加一个版本参数/api/region/all?v1.0.2或者后端接口设置缓存头这样能避免老数据长期驻留。4.4 与 echarts 结合动态刷新图表做报表页面通常是一个 echarts 实例 定时器轮流拉数据。用封装后的ajax.get配合async/await非常舒服async function refreshChart() { try { var res await ajax.get(/api/dashboard/trend, { days: 7 }); var trendData res.data || []; var dates trendData.map(function (item) { return item.date; }); var values trendData.map(function (item) { return item.value; }); chart.setOption({ xAxis: { data: dates }, series: [{ data: values }] }); } catch (e) { console.error(图表数据加载失败, e.message); } } // 每 30 秒刷新一次 setInterval(refreshChart, 30000);这里能看出 Promise 化的价值map处理数据、await链式拿值代码行数少、逻辑清晰。没有封装时回调里再嵌for循环处理数据那酸爽谁写谁知道。4.5 iframe 关闭 jquery 刷新父页面并加载新数据这是老系统里非常经典的一个场景。嵌套 iframe 做编辑弹窗编辑完关闭弹窗父页面要刷新列表。前端如果用 jquery 操作通常是这样的流程iframe 内部编辑保存后调用父页面的刷新方法父页面拿到回调后用我们封装的ajax.get重新请求列表数据再重新渲染表格。// 父页面暴露的刷新方法 window.refreshParentList function () { ajax.get(/api/list, { page: 1 }) .then(function (res) { renderTable(res.data || []); }); }; // iframe 子页面里保存成功后触发 function saveAndClose() { ajax.post(/api/edit, editData) .then(function () { // 关闭当前 iframe 层 layer.close(editLayerIndex); // 调用父页面刷新 if (window.parent typeof window.parent.refreshParentList function) { window.parent.refreshParentList(); } }); }封装之后数据请求逻辑集中在父页面iframe 内部只需要关心“用户操作成功”这个动作。不会出现子页面里面再复制一份 ajax 冗长代码的情况处理干净了很多。4.6 js 实现复制粘贴和 URL 校验的小工具除了主流程我在实际项目里还碰到一些高频场景需要辅助函数配合。比如复制回执单号老方法是用document.execCommand(copy)虽然过时但在兼容性要求高的老项目里仍然扛打function copyText(text) { var textarea document.createElement(textarea); textarea.value text; textarea.style.position fixed; textarea.style.left -9999px; document.body.appendChild(textarea); textarea.select(); try { document.execCommand(copy); console.log(复制成功); } catch (e) { console.error(复制失败, e); } finally { document.body.removeChild(textarea); } }再配合 URL 校验在提交外部链接前先做一次检查var link $(#linkInput).val(); if (!isValidUrl(link)) { alert(请输入正确的链接); return; }工具函数虽小但散落在各个业务代码里之后很难复用所以我在封装 ajax 时把它们统一放进一个utils.js跟请求模块分开保持职责单一。5. 常见问题与排查技巧实录5.1 高频问题速查表大量项目实践下来我整理出几个最普遍的问题和对应排查思路现象可能原因排查与解决请求一直停在 pending最后超时接口地址错误、后端未启动、跨域配置不对看 Network 面板请求是否发出检查 URL 和 CORS状态码为 0进入 error 回调请求被 abort、跨域被拦截、网络断连确认是否手动调用 abort检查跨域配置HTTP 200 但业务失败后端返回 code 不是配置的 successCode看响应体里的 code 值调整 successCode 参数响应 JSON 解析报错后端返回 HTML 或空内容检查 responseText 原文联调时让后端返回标准 JSONPOST 后后端收不到参数Content-Type 不匹配确认是x-www-form-urlencoded还是application/json中文参数乱码URL 拼接时未 encode或后端编码不对前端用 encodeURIComponent后端统一 UTF-8请求头 Authorization 没生效拦截器里 headers 未正确合并检查 beforeRequest 返回值是否被正确应用GET 请求带数组参数异常后端解析tagsatagsb失败在 stringifyQuery 里改为tags[0]a或直接 JSON 字符串5.2 跨域与代理生产环境遇到最多的坑自测环境下最常见的其实是跨域问题。浏览器直接请求不同端口的接口会被同源策略拦下。排查时先看 Network 面板里请求状态是什么。如果响应里有Access-Control-Allow-Origin相关报错就是后端 CORS 没配置好。开发环境推荐用 Webpack 的devServer.proxy或者任意一个代理插件转发让前端请求同源地址由代理转发到后端。这个方案比在后端开 CORS 更安全也更接近生产环境。如果是老项目没有构建工具那就只能后端配合打开 CORS或者用 nginx 做一层反向代理。前端这边只要封装统一后面换代理方式改一个url前缀就能全部生效这也是封装带来的额外好处。5.3 我的几个避坑经验踩过不少坑以后我总结了几条经验。第一统一successCode不要硬编码在业务代码里。一定有不少项目里写着if (res.code 0)但后端某天把 code 改成 200你就要全局搜索替换。封装时把successCode做成可配置项全局默认放一处遇到特殊情况单点覆盖后面维护会轻松很多。第二不要在 success 回调里再依赖全局 loading 状态。我的封装只负责请求loading 由调用方控制。用计数器管理多个并发请求的 loading避免一个请求返回就把 loading 关掉导致其他请求还没结束就闪屏。这不是封装本身的功能但封装统一了回调入口之后做 loading 聚合也更容易。第三abort 方法一定要暴露。搜索框输入场景用户连续输入几个关键字旧请求还没返回新请求又发出去了。我在封装里返回{ abort }配合 debounce 用效果很好var currentReq null; input.addEventListener(input, function () { if (currentReq) currentReq.abort(); currentReq ajax.get(/api/search, { keyword: input.value }) .then(function (res) { renderList(res.data || []); }); });如果不暴露 abort多请求竞态问题很难干净地处理。第四接口返回的数据先打印再操作。虽然封装统一了返回值但每个接口的data内部结构还是不同。联调阶段我建议所有接口在.then第一行先console.log(res)看清楚了再写业务逻辑能省掉大量“咦这里怎么是数组”的疑惑。第五旧项目做封装要一步步来。如果老项目有几百个地方直接写了原生 XHR不建议一次性全部替换。先新增ajax.js工具模块新功能用封装旧功能逐步迁移。这样风险可控出问题也能快速回退。6. 封装的边界与后续扩展思路封装做到现在已经能满足大部分日常业务场景。但我还是要说一句封装不是无线扩能。如果你发现自己在一路给ajax()加参数、加分支、加钩子那不如直接评估一下项目能不能引入 axios。我自己在实际项目里的体会是原生封装特别适合三种情况一是老项目不能引入新依赖二是项目体积敏感不想为一个请求库增加几十 KB 的包体积三是团队想统一请求层规范但又不想被框架绑定。在这些场景下这套轻量封装是真的能顶住生产流量。如果后续还想扩展我建议优先补两个能力。第一个是请求缓存对 GET 请求做一个 Map 缓存相同 URL 和参数命中直接返回 Promise避免重复请求。第二个是并发控制比如统一限制最多同时发出 5 个请求防止低端设备瞬间打爆网络连接。这两个功能在现有封装上都不难实现但加之前要想清楚业务是否真的需要否则又是过度设计。代码封装不是目的让团队里每个人都按同一套规则把请求写对、写稳、写好维护才是我们做这件事的最终价值。
返回列表