
你害怕的不是未知的未来,而是不断重复错误的过去。
背景
在设计这个方案之前,以往我对于轮询的处理逻辑相对简单,就是基于setTimeout,根据轮询接口的返回结果,判断是否继续执行该轮询接口。
下面是临时写的示例代码(未经过测试),只是一个大致思路哈。
js
let pollTimeout = null
let pollCount = 0
/** 轮询请求
* @param {Object} options 选项
* @param {number} options.timeout 超时时间
* @param {number} options.interval 间隔时间
* @param {Function} options.executor 执行方法
* @param {Function} options.conditionFn 条件方法
* @param {Function} options.successFn 成功方法
* @param {Function} options.errorFn 错误方法
* @return {void}
*/
export const pollRequest = ({timeout = 10000, interval = 1000, executor, conditionFn = () => false, successFn = () => {}, errorFn = () => {}} = {}) => {
// 最大轮询次数
const maxCount = Math.floor(timeout / interval)
if (pollTimeout) {
clearTimeout(pollTimeout)
}
const loop = async () => {
if (pollCount >= maxCount) {
errorFn('timeout')
pollTimeout = null
pollCount = 0
return
}
const res = await executor()
pollCount++
if (conditionFn(res)) {
successFn(res)
pollTimeout = null
pollCount = 0
} else {
errorFn(res)
pollTimeout = setTimeout(loop, interval)
}
}
loop()
}
// 示例接口
const getDemoUserList = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }])
}, 1000)
})
}
const pollTest = pollRequest({
timeout: 10000,
interval: 1000,
executor: getDemoUserList,
conditionFn: (res) => res.length > 0,
successFn: () => {
console.log('success')
},
errorFn: () => {
console.log('error')
}
})
pollTest()
之前一般都是采用callback的方案,通过传入回调方法来获取轮询结果。
直到这次又遇到了需要轮询的需求场景,就是在提交表单或者进入主页时都需要通过轮询一个状态接口,页面执行不同的操作。比如提交表单后,轮询状态接口,如果是正确状态就结束并跳转回主页,错误状态则继续轮询并且展示loading;
其实如果按前面的那种方案也能做,但是需要写成callback的形式,而且涉及改动的地方比较多(有20多处),为了让改动成本最小化,决定重构轮询方案。
该方案预期结果:用户在需要改动的模块中插入全局的状态轮询方法,通过async/await的方法,同步得到轮询结果,并根据结果来处理对应的逻辑。如下图
伪代码
js
某业务模块
...
插入位置
const pollRes = await pollCheckStatus()
if (pollRes.status === 'fail') {
...轮询失败处理逻辑
return
}
...轮询成功处理逻辑
...
设计思路
这次的设计思路中最主要的点是,将轮询操作的控制权提供给调用者,基于promise的resolve,我们可以将轮询处理逻辑进行缓存,之后根据调用者的实际业务逻辑可以更加灵活地处理轮询结果。
-
不需要跟开头的方案那样,通过condition这个方法传入判断逻辑,并且还要规定方法返回结果只能是布尔值,那样做不够灵活。
-
在新的方案中,我们会抛出两个方法,一个是success,一个是fail,这两个方法都可以结束轮询状态,并传入参数(便于在轮询结束后根据不同参数处理对应逻辑)。这个思路参考的promise设计规范 中的状态,该状态只有一次扭转机会,初始状态为pending ,之后只要调用 resolve 或者reject 任意一个方法,该状态就会扭转为最终态且不可再变动。这套逻辑映射到轮询的场景就是,只要处于pengding状态就一直轮询,一旦用户调用了success或者fail任意一个方法,状态扭转并且结束轮询。直到用户重启一轮新的轮询。
代码实现
根据这个思路,我设计了下面这个PollRequest类
js
const PENDING_RESULT = {
PENDING: 'pending', // 轮询中
SUCCESS: 'success', // 轮询成功
FAIL: 'fail', // 轮询失败
}
const FAIL_TYPE = {
TIMEOUT: 'timeout', // 轮询超时
STOP: 'stop', // 轮询停止
}
/** 轮询失败类型对应的消息 */
const FAIL_TYPE_MSG = {
[FAIL_TYPE.TIMEOUT]: '', // 轮询超时
[FAIL_TYPE.STOP]: '', // 轮询停止消息
}
/** 轮询请求
* @param {Object} options 配置参数
* @param {boolean} options.enableTimeout 是否启用超时检测
* @param {number} options.interval 轮询间隔时间 单位:毫秒
* @param {number} options.timeout 轮询超时时间 单位:毫秒
* @param {boolean} options.immediate 是否立即执行轮询
* @return {PollRequest} 轮询请求实例
*/
export class PollRequest {
constructor(options) {
this.enableTimeout = options?.enableTimeout === false ? false : true
this.interval = options?.interval || 1500
this.timeout = options?.timeout || 10000
this.immediate = options?.immediate || true // 是否立即执行轮询
this.timer = null
this.executeCount = 0 // 执行次数
this.startTime = 0 // 记录轮询开始时间
this.runResolve = null // 轮询resolve,后置执行,可以通过用户根据执行结果自行决定是否继续轮询
this.pendingResult = PENDING_RESULT.PENDING // [pending|success|fail]
}
/*
* 设置轮询配置
* @param {Object} options 配置参数
* @param {boolean} options.enableTimeout 是否启用超时检测
* @param {number} options.interval 轮询间隔时间 单位:毫秒
* @param {number} options.timeout 轮询超时时间 单位:毫秒
*/
setConfig(options) {
this.enableTimeout = options?.enableTimeout === false ? false : this.enableTimeout
this.interval = options?.interval || this.interval
this.timeout = options?.timeout || this.timeout
this.immediate = options?.immediate || this.immediate
}
/*
* 成功处理函数
* @param {any} successResult 成功结果
*/
success(successResult) {
if (this.runResolve && this.pendingResult === PENDING_RESULT.PENDING) {
this.pendingResult = PENDING_RESULT.SUCCESS
this.resetPoll()
this.runResolve({ status: PENDING_RESULT.SUCCESS, successResult })
}
}
/*
* 失败处理函数
* @param {any} failResult 失败结果
*/
fail(failResult) {
if (this.runResolve && this.pendingResult === PENDING_RESULT.PENDING) {
this.pendingResult = PENDING_RESULT.FAIL
this.resetPoll()
this.runResolve({ status: PENDING_RESULT.FAIL, failResult })
}
}
/**
* 启动轮询
* @param {Function} executeFunc 执行函数
* @return {Promise} 轮询结果 {status: 'success' | 'fail', successResult?: any, failResult?: any}
*/
run(executeFunc) {
return new Promise((resolve) => {
this.resetPoll()
this.startTime = Date.now()
this.runResolve = resolve
this.pendingResult = PENDING_RESULT.PENDING
const proxyExecuteFunc = () => {
if (this.pendingResult !== PENDING_RESULT.PENDING) return
this.executeCount++
executeFunc(this.executeCount)
}
const loop = () => {
const nowTime = Date.now()
// 计算轮询时长,超过超时时间则停止轮询,如果未启用超时检测,则不进行超时检测
const diffTime = nowTime - this.startTime
if (this.enableTimeout && diffTime >= this.timeout) {
this.resetPoll()
this.fail({failType: FAIL_TYPE.TIMEOUT, msg: FAIL_TYPE_MSG[FAIL_TYPE.TIMEOUT]})
return
}
this.timer = setTimeout(() => {
proxyExecuteFunc()
loop()
}, this.interval)
}
if (this.immediate) {
proxyExecuteFunc()
}
loop()
})
}
/*
* 清除定时器
*/
killTimer() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
/*
* 重置轮询状态,包括杀掉定时器进程
*/
resetPoll() {
this.startTime = 0
this.executeCount = 0
this.killTimer()
}
// 停止轮询,并清除相关状态
stop() {
this.resetPoll()
this.fail({failType: FAIL_TYPE.STOP, msg: FAIL_TYPE_MSG[FAIL_TYPE.STOP]})
}
}
实战场景
有了上面这个PollRequest类,接下来就是应用到开头提到的业务场景中去。需求是这样的,现在我进入小程序的商品详情页,提交订单并调用支付窗口完成支付,由于支付状态是后端从微信服务端获取结果,这个流程是异步的,所以采用轮询接口的方案来获取支付结果。
- 首先定义一个全局的支付状态轮询方法,方便统一管理
js
// 轮询支付状态请求实例
const pollCheckPayStatusRequest = new PollRequest()
/**
* 轮询支付状态请求实例
* @param {string} orderId 订单ID
* @return {Promise} 轮询结果 {status: 'success' | 'fail', successResult?: any, failResult?: any}
*/
pollCheckPayStatus(orderId, options = {
enableTimeout: false, // 默认不启用超时检测
loadingMsg: '',
}) {
// 停止之前的轮询请求
pollCheckPayStatusRequest.stop()
const Http = require("../api/http"); // 兼容解决调用时找不到外层Http问题
pollCheckPayStatusRequest.setConfig({
enableTimeout: options?.enableTimeout || false,
interval: options?.interval,
timeout: options?.timeout
})
return pollCheckPayStatusRequest.run(() => {
const app = getApp();
const Toast = app.$toast()
options.loadingMsg && Toast.loading(options.loadingMsg, true)
Http.apiGetPayStatus({
orderId
}).then(res => {
if (!res.data) {
pollCheckPayStatusRequest.success(res)
}
}).catch(err => {
pollCheckPayStatusRequest.fail(err)
})
})
}
- 在对应模块中插入轮询逻辑
js
// 提交支付逻辑
async submit(orderId) {
Toast.loading('支付中...', true)
try {
// 调用支付窗口
let { status: payStatus, msg: payMsg } = await app.wxPay(orderId)
Toast.hideLoading()
if (status !== 'success') throw(payMsg)
// 调用轮询
const pollRes = await app.pollCheckPayStatus(orderId, {
loadingMsg: '数据更新中...'
})
let _failType = pollRes?.failResult?.failType
let _status = pollRes.status
if (_status === 'fail' && _failType == 'stop') return
if (_status === 'fail') {
throw(pollRes.failResult.msg)
}
Toast.none('支付成功')
this.triggerEvent('update')
} catch ({ msg }) {
Toast.none(msg || '支付失败')
}
}
上面就是在实际场景中的示例应用(伪代码逻辑),通过这种async/await的方式改造,可以提升代码的可读性,改造成本也比较低。
小结
这次的轮询改造方案还是挺有意义的,基于promise的设计,既可以使用链式调用promise.then.catch,也可以使用async/await的语法糖实现异步代码同步化,代码可以写得更加优雅~。另外就是借鉴的promise关于状态扭转的思路,让轮询状态的状态可以后置并抛出给调用者,使其比callback那种受限制的方式要更加灵活。
最后,各位看官可以在评论区发表自己的意见,大家一起友好探讨~~