ajax 实现的基本原理是 XMLHttpRequest 或 fetch api。简单的 ajax 请求,只需要几行代码即可实现:
const url = '/rest/xxx';
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
// xhr.open('POST', url);
xhr.responseType = 'json';
xhr.addEventListener('load', function() {
console.log('sucess:', xhr.response);
});
xhr.send();
// xhr.send(formdata)
使用 fetch API 则更为简单:
window.fetch(url).then((res) => {
res.json();
}).then((result) => {
console.log(result);
}, (err) => {
console.log(err);
})
但是在实际项目开发中,很少会直接这么用,因为存在兼容性问题,易用性/通用性也不够。例如 GET/POST 请求、文件上传、图片获取等,实现方式都有较大的差异。
为什么要做更进一步的 ajax 封装
前端开发者应该都很熟悉 jQuery 中的 $.ajax/$.post/$.get 这些方法,在各种框架或库中也存在着不尽相同的 ajax API 方法实现。类似这样的 xhr api 封装屏蔽了不同平台的差异性,简化了 xhr 的使用,在一些简单的应用中似乎已经可以满足需求了,但在中大型应用中还远远不够。
这些实现满足了易用性的需求,却难以完成基于业务特性和接口约定的通用逻辑处理。我在实际的项目中,看到了太多相似而重复的类似如下的代码:
function getAAA(callback, errCallback) {
$.get('/rest/aaa').then( function (result) {
if (result.code !== 200) {
errCallback(result);
return;
}
callback(result);
}, function (err) {
errCallback(err);
});
}
function getBBB() {
//...
}
这样的代码有什么问题呢?getAAA、getBBB…… 这样的样板代码产生了相似的大量逻辑,特别是在 ajax 请求特别多的中后台系统中,这使得逻辑实现有失优雅,又难以维护。想象一下需要在 ajax 过程中增加一个统一参数处理与上报的情况,你需要动多少地方。
在一个 ajax 请求的生命周期中,你可能需要做的事情有很多。如果你有考虑过如下列举的一些需求,那么应该已经在做更符合项目约定的 ajax 方法封装了:
更简洁的代码逻辑
统一的数据获取方式、环境切换、数据 mock 处理
接口约定与出错处理
前端数据缓存(memory 与 session、local 级别)
通用的回调处理 (成功/失败处理,xss 注入等数据预处理)
按钮状态等处理
公共参数获取与上报
api 埋点性能收集(如超长耗时、网络超时、50x 出错等)
……
简单来说,在请求执行前,你可能需要加载动画、禁用按钮、组装通用参数等等;在请求执行过程中,需要禁止继续操作;在执行成功/失败后,需要对数据作正确性验证、对加载动画作恢复处理、性能收集等等。这些繁多又相似的操作,如果在每一个涉及 ajax 请求的地方都去硬代码实现,那是多么烦人的一件事情。
相同或相似功能应尽量书写可复用代码实现,函数化、模块化、参数化是实现代码可复用的关键技巧,也是高质量编码的基本要求。简单的复用实现可以是一个函数,更复杂完善一些就成了通用性组件、库或框架。
基于项目约定的 ajax 封装实现
对比 xhr 与 $.ajax 方式,jQuery 包装了的代码显然更为简洁。这样做的好处也是显而易见的,去除冗余,调用简单,更易维护。
下面简单阐述我们针对前述需求的理解和实现。
我们封装了一个通用性的 ajax api,在 ajax 请求发起前、请求过程中、请求成功和失败这几个阶段和状态,通过抽象各种参数的方式,将基于业务特性的不同功能需求交给具体项目应用来定义和处理,由此以取得广泛的适用性。
前端数据缓存 (memory 与 session、local 级别)
基于 ajax 的应用,进入页面时可能同时发起了多个请求,这对服务器的并发性增加了更高的要求。其实很多万年不变的元数据类信息,完全可以在前端缓存起来。对于已知在一定时间范围内不会变化的数据(如离线计算的隔日数据结果集),也可以缓存起来。这样在一定程度上避免了 API 请求浪费,也使得复杂网络环境下的前端响应更为迅速。至于缓存到什么程度,则需要根据具体的数据应用来设定。一般来说:
对于单页应用,可能缓存到内存就够了。
对于多页应用,可以缓存到 sessionStorage。
对于确实万年不变的数据,可以缓存到 localStorage [或加上个过期时间]。
我们在通用性封装中做了这样的处理:
设置 cache 和 fromCache 参数:cache 约定是否缓存本次请求,fromCache 本次请求是否优先尝试从缓存中获取。其类型可为:memory、sessionStorage 和 localStorage。
这样只需要在需要的时候配置对应参数,就可以简单实现数据的缓存读写。示例:
import adm from 'ajax-data-model';
const data = {typeId: 2};
const param = {
data,
url: '/rest/user/list',
cache: 'localStorage', // 获取到数据后,缓存到 localStorage
fromCache: 'localStorage', // 优先尝试从 localStorage 获取数据
cacheName: 'userlist_type_' + data.typeId // 缓存读写时使用的 key,以请求参数区分
};
adm.get(param).then((result) => {}, (err) => {});
// 从 localStorage 中删除缓存
adm.delete('userlist_type_' + data.typeId, 'localStorage');
通用的预处理、公共参数上报处理
很多业务都涉及到公共参数的上报。我们设置了一个 fnAjaxBefore 参数方法,用于在 ajax 前回调处理请求相关参数。示例:
/**
* ajax 请求开始前回调方法
* @param {Object} config - ajax 请求配置,由于是引用传参,可在这里通过修改它实现 mock 数据等功能
* @return {Object} 返回修改后的 config
*/
fnBeforeAjax(config) {
// 示例:增加通用上报参数
cofig.data = $.extend({}, config.data, {
username: window.Cookie.get('userName'),
now: new Date().getTime()
});
// 还可以针对 config 中特定约定的参数做更多的约定处理
return config;
}
通过 fnAjaxBefore 参数的回调方式,还可以方便地实现环境切换、钩子调用、数据模拟等功能需求。
接口约定与出错处理
ajax 请求失败了可以统一处理:你可以发送上报日志,或者直接忽略它。如果是网络中断,也可以在 localStorage 里缓存它,在下次有成功的网络链接时再进行上报。反正具体做什么和怎么做由你说了算。
当得到了请求成功了,但它是否正确呢?后端 API 的返回数据对于前端来说是不可信的,如果你还无法理解这一句话,那么想想因接口问题发生的各种报错甚至导致单页应用功能失效的情况。如果没有一个基于约定的统一的数据验证和容错处理,那么业务代码中肯定会处处存在各种相似的 if else。
我们为此设置了 fnAjaxDone 参数方法,当请求完成时回调,在该方法中实现接口约定,以及更多的自定义验证行为。
举个例子,我们约定接口返回信息中包含 code 字段,当其值为 200 时表示数据处理成功,否则为其他对应的状态,需由后端返回 message 字段,告诉前端到底哪里出了问题。示例:
fnAjaxDone(result, callback, errCallback, config) {
let success = false;
// code 为 200 认为是成功数据
if (result && result.code === 200) {
// 你也可以在 config 自定义参数中设置更多更严格的验证方式,需求由你来定
// if (config.fnValidate && config.fnValidate(result)) {}
if (callback) {
callback(result);
}
success = true;
} else {
if (errCallback) {
errCallback(result);
}
// 全局性系统提示,设置为 false,则不提示,适合由用户自定义错误处理的情况
if (config.tipConfig !== false) {
result.message = result.message || '系统错误';
// 错误数据 alert 通知
this.alert(result.message);
}
}
return success;
}
有同学可能会问到,如果网络或者服务器挂了(一般为 30x/40x/50x 错误),获取不到数据,又该怎么办呢?我们为这种状态设置了一个 fnAjaxFail 参数:
fnAjaxFail(err, config) {
let msg = err.responseText || err.statusText || '';
if (msg.length > 200) {
msg = msg.slice(0, 200) + '...';
}
// API 异常上报
if (window.__MZBH) {
window.__MZBH({
msg,
mg: msg,
fi: window.location.origin + config.url, // 文件、路径
li: 0,
stk: '' //报错代码所在的函数片段,不一定都有
}, 2);
}
if (0 === err.status) {
this.alert('登录超时');
window.location.reload();
} else if (false !== config.errAlert) {
// errAlert = false 时禁止 40x/50x 等错误的全局提示
this.alert('数据请求失败: ' + msg);
}
}
状态处理、耗时等性能收集
为了防止多次点击导致的重复提交,或在 ajax 执行时进行进度处理,你总是需要去根据实际情况写不同的逻辑代码。
在通用性请求封装中,我们提供了一个 fnWaiting 参数方法,结合具体请求中的参数,就可以制定符合业务需求的状态处理。还可以在该方法中统计和上报 ajax 超长请求耗时的情况。
例如,我们约定通过具体请求中的 waiting 参数的配置,在请求发起时禁用它并启用按钮的 loading 动画,在请求完成时恢复它。实现示例:
/**
* ajax 开始/结束时的状态处理
* 例如单击按钮后,在开始时禁用按钮,结束时恢复它;
* 再例如,在 ajax 开始时启用页面动画,结束时关闭页面动画。
* @param {Object} config.waiting - 参数内容可根据 `fnWaiting` 具体的处理来设置。例如这里为:
* `{$btn:$btn, text:"请求中..", defaultText: "提交"}`
* @param {Number} time - 存在值时在 ajax 结束调用,值为 ajax 消耗的时间;省略时在 ajax 开始调用
* @return {void}
*/
fnWaiting(config, time) {
const waiting = config.waiting;
// 开发状态下打印日志
if ('development' === process.env.NODE_ENV && time) {
console.trace('ajax 请求消耗时间:', time);
}
// 示例:根据 time 区分 ajax 开始与结束,分别做不同的处理
// 这里以处理按钮状态为例,waiting 参数则为关键: {$btn, text, defaultText}
if (!waiting || !waiting.$btn || !waiting.$btn.length) {
return;
}
// 超长请求耗时的日志上报...
// if (time > waiting.timeout || 5000) {
// // 启动 api 上报...
// window.__MZBH('ajax_long_time', time, config.url);
// }
if (!time) {
// 请求前,启动点击按钮的动画
waiting.$btn.data('defaultText', waiting.$btn.html())
.html(waiting.text || ' 请求中...')
.addClass('disabled').prop('disabled', true);
} else {
// 请求结束后,恢复按钮状态
setTimeout(function () {
// 连续提交延时处理,两次连续提交不能超过 200 ms
waiting.$btn.html(waiting.defaultText || waiting.$btn.data('defaultText'))
.removeClass('disabled').prop('disabled', false);
}, 200);
}
}
adm 项目及实现核心 API
adm 项目地址在这里: https://github.com/lzwme/ajax-data-model/
该实现基于 jQuery,使用了 jQuery.Deferred 实现 Promise 风格的回调接口。后续会考虑无 jQuery 依赖的实现版本。
通过全局性参数的方式,我们解决了通用性问题。从易用性的角度,API 应当尽量简单。核心 API 参考:
setSettings 设置全局性参数。在应用初始化时,全局设置,以实现基于接口约定的规则。
get 获取数据方法(GET 方式)
save 保存数据方法(POST 方式)
delete 删除数据
clear 清空数据
post save 方法的简化
getJSON get 方法的简化
具体用法可参见:
USAGE.md(https://github.com/lzwme/ajax-data-model/blob/master/USEAGE.md)
API(https://lzw.me/pages/demo/ajax-data-model/api/)