请先拒绝轮询技术方案,而应该采用服务端推送,前端只需当做普通事件注册即可,因为:
- 轮询很难设计,设计不好有『自我 DDOS』风险,其次轮询请求命中效率不高,需要发送多次请求方可命中。
- 服务端推送的实时性是很高的。 总结:轮询方案实时性和命中率都不高 🤕。 如果实在要轮询可以参考以下方案。
需求
我们总会面临类似异步长时间才能拿到操作结果的情景,假定有个操作预期 1s 左右方可拿到回执,为了更好的用户体验只要能拿到就展示已到账,否则继续请求,用户最多等待 1.5s。第一眼转换成技术需求就是:在1.5s 内不断轮询直到成功为止。
本文从特定需求触发,总结通用思路和方法。 总共 5 种方案。下面依照不断进化的思路铺开讲述。
方案一:固定超时 & 固定请求次数
前端限定每次请求设置超时 500ms,得不到结果继续下一轮,三次都不行直接结束轮询。
分析
该方案是串行
优点
代码简单易懂。但是依赖了请求平均耗时在 500ms 内。
缺点
这种策略可能会造成接口可能每次都是 600ms 才返回结果,而我们却限制了 500ms,导致无论发送多少次请求都无法拿到回执,在规定时间内肯定得不到结果。
方案二:限制轮询总时长 & 不限制次数
前端限制轮询总时长 1500ms,在总时长内,无论调用多少次接口,只要轮询时长超过 1500ms,就结束轮询。
分析
该分案是串行
缺点
请求快速失败的情况会导致发送过多无谓的请求。假若需要在第 800ms 的时刻才能拿到回执,一次请求耗时 100 ms,则前面 7 次请求都浪费了。
1500ms 内到底请求多少次合适,万一接口 50ms 返回一次,这不得一次提交调用多次该接口。。。
网络通顺的情况下:3次?
网络不通的情况下:最多两次?
方案三:固定间隔离散轮询 & 单个请求设置递减超时
第一个轮询的方案的优化版:我们分别在 0s 0.5s 1s 这 3 个时刻发送请求,总共 3 个,超时分别设置 1.5s 1s 0.5s,只要有一个返回了已到账,则提示用户已到账。解决了快速失败的问题,同时请求打散,保证一定有一个请求能拿到回执的概率最大化。
分析
该分案不是纯粹的串行,串中有并。
优点
- 将请求打散,保证各个时间段均匀分布提高请求命中率
- 没有定时器不用考虑关闭定时器,没有内存泄漏风险
- 没有 recursion,无需考虑无限循环或无限次请求
- 必定是有限次数。解决方案二的痛点
缺点
- 请求有超时,还是有整体失败的概率
具体方案
一、使用 Promise 实现
第一步:先简化下,只要拿不到回执就认为请求失败。
// 1 先简化下,只要非到账就认为请求报错
function fetchList(params) {
return request(params).then((resp) => {
if (resp.status === 'SUCCESS') {
return resp;
}
throw resp;
});
}
第二步:不考虑通用,定死 3 个请求每隔 0.5s 发送一个,3 个请求的超时时间依次是 1.5 1 0.5。
Tips: 不考虑通用能让思路变得更清晰
function fetchLoop() {
// 该 Promise constructor 不可能报错,故可以用 async
// https://stackoverflow.com/a/43050114
return new Promise(async (resolve, reject) => {
let times = 0;
const r1 = handle(fetchList({ timeout: 1500 }));
await delay(500)
const r2 = handle(fetchList({ timeout: 1000 }));
await delay(500)
const r3 = handle(fetchList({ timeout: 500 }));
function handle(promise) {
times++;
promise.then(resolve).catch((error) => {
if (times >= 3) {
reject(error);
}
});
}
})
}
代码 Tips:
promise.then(resolve)
利用了Promise
的『终态不可逆转性』
第三步:在第二步的基础上泛化,考虑通用情况,用 for 循环解决不定次请求。
// 考虑通用情况,用 for 循环解决不定次请求
function loopInLimitTime(asyncFn, { timeLimit, interval }) {
return new Promise(async (resolve, reject) => {
const loopCnt = Math.ceil(timeLimit / interval);
// timeLimit=3 interval=2 loopCnt = ceil(3/2) = 2
// #i moment timeout
// #1 0 3-0*2=3
// #2 2 3-1*2=1
// timeLimit=1.5 interval=0.5 loopCnt = ceil(1.5/0.5) = 3
// #i moment timeout
// #1 0.0 1.5-0*0.5=1.5
// #2 0.5 1.5-1*0.5=1.0
// #3 1.0 1.5-2*0.5=0.5
for (let index = 0; index < loopCnt; index++) {
if (succeed) {
// DO NOTHING
// no need to re-request
return;
}
// resume on not succeed
handle(asyncFn({ timeout: timeLimit - index * interval }));
await delay(interval);
}
function handle(promise) {
times++;
promise.then(resolve).catch((error) => {
if (times >= loopCnt) {
// 只有超过次数才结束,否则让其继续接下来的请求
reject(error);
}
});
}
})
}
使用
Page({
async onLoad() {
const [error, resp] = await loopInLimitTime(fetchList, {
timeLimit: 1500, interval: 500,
});
if (error) {
// 上报监控
rc.error(`未在1.5s内返回明确回执`, { code: xxx })
return;
}
this.setData({ status: resp.status });
},
})
使用『发布-订阅者』模式 - 事件机制
如果想知道进度和更详细的过程描述,比如是在第几次请求获得了回执。可使用『发布-订阅者』模式,即前端熟悉的事件机制,不是『我等你,而是你回调我』。
Hollywood Principle – Don't Call me, I'll Call You! 好莱坞法则 / 好莱坞原则
function loopInLimitTime$(asyncFn, { timeLimit, interval, eventName }) {
const loopCnt = Math.ceil(timeLimit / interval);
for (let index = 0; index < loopCnt; index++) {
handle(asyncFn({ timeout: timeLimit - index * interval }));
await delay(interval);
}
function resolve(resp, index) {
event.emit(`${eventName}:success`, resp, index)
}
function reject(error) {
event.emit(`${eventName}:failed`, error)
}
function progress(index) {
event.emit(`${eventName}:progress`, { index, loopCnt })
}
function handle(promise) {
times++;
promise
.then((resp) => { resolve(resp, index) })
.catch((error) => {
if (times >= loopCnt) {
reject(error);
}
});
}
}
使用
let offSuccessEvent;
let offFailedEvent;
let offProgressEvent;
Page({
onLoad() {
// 需提前注册
const eventName = 'operation-result'
offSuccessEvent = event.on(`${eventName}:success`, ({ status }, index) => {
rc.info(`回执在第${index}个请求返回`);
this.setData({ status });
});
offProgressEvent = event.on(`${eventName}:progress`, ({ index, loopCnt }) => {
rc.info(`共${loopCnt}次轮询,已发起第${index}次轮询`);
})
offFailedEvent = event.on(`${eventName}:failed`, (error) => {
// 上报
rc.error(`未在1.5s内返回明确回执`, { error, code })
})
loopInLimitTime$(fetchList, { timeLimit: 1500, interval: 500, eventName })
},
// 记得页面 / 组件卸载时解除监听,防止内存泄漏
onUnload() {
offSuccessEvent?.();
offFailedEvent?.()
offProgressEvent?.()
}
})
代码分析
?.
写法见关于 JavaScript 的几个冷知识。
方案分析
该方案越来越像 Sync 模式了 😄。
优点
过程全透明,可更细粒度的控制整个过程,甚至页面可以显示倒计时 😎。
缺点
API 不明确,需要查看源代码,或者有很好的文档。
使用 RxJS 实现
Promises are not able to work on multiple events. RxJS Observable not only works like promises but can accomplish even more.
function poolInLimitTime$(
asyncFn: (...args: any[]) => Promise<any>,
{ timeLimit, interval }
) {
return new Observable((subscriber) => {
const loopCnt = Math.ceil(timeLimit / interval);
let succeeded = false;
const startTimestampOfAllRequests = Date.now();
// Must fill the array otherwise the reduce cb wont be called!
new Array(loopCnt).fill(0).reduce((acc, cur, index) => {
return acc.then(() => {
if (succeeded) {
// DO NOTHING
// no need to re-request
return;
}
// continiue request on failed
handle(asyncFn({ timeout: timeLimit - index * interval }), index + 1);
return delay(interval);
});
}, Promise.resolve());
function resolve({ resp, index, totalRequestsCosts, singleRequestCosts }) {
subscriber.next({
type: `success`,
payload: { resp, index, totalRequestsCosts, singleRequestCosts },
});
succeeded = true;
subscriber.complete();
}
function reject({ index, error, totalRequestsCosts, singleRequestCosts }) {
subscriber.error({
index,
error,
totalRequestsCosts,
singleRequestCosts,
});
}
function progress(index) {
subscriber.next({
type: `progress`,
payload: { index, loopCnt },
});
}
async function handle(promise: Promise<any>, requestIndex) {
progress(requestIndex);
const start = Date.now();
try {
const resp = await promise;
const singleRequestCosts = Date.now() - start;
resolve({
resp,
index: requestIndex,
totalRequestsCosts: Date.now() - startTimestampOfAllRequests,
singleRequestCosts,
});
} catch (error) {
const singleRequestCosts = Date.now() - start;
subscriber.next({
type: `error`,
payload: {
index: requestIndex,
loopCnt,
error,
singleRequestCosts,
},
});
if (requestIndex >= loopCnt) {
reject({
index: requestIndex,
error,
totalRequestsCosts: Date.now() - startTimestampOfAllRequests,
singleRequestCosts: singleRequestCosts,
});
}
}
}
});
}
详细代码见 Polling Use RxJS
代码分析
使用 reduce 代替 for await,是一种常用模式,但主要是因为 Observable 的构造函数不能是 async 函数,否则代码会更简单,更容易理解,如果大家有好的方法欢迎留言。
for (let index = 0; index < loopCnt; index++) {
handle(asyncFn({ timeout: timeLimit - index * interval }));
await delay(interval);
}
二者等价
new Array(loopCnt).fill(0).reduce((acc, cur, index) => {
return acc.then(() => {
handle(asyncFn({ timeout: timeLimit - index * interval }), index + 1);
return delay(interval);
});
}, Promise.resolve());
方案四:固定间隔轮询 & 单个请求不加超时控制
结束条件是整体超时 1.5s。
优点
适合中长时间的轮询
缺点
方案五:递增间隔轮询
如果很长时间轮询,而且网络条件普遍很差的情况下,采用间隔递增方案可以进一步减少请求的浪费。
递增规则可以是指数型,比如 2 的阶乘 1 2 4 8 16 32 64 ...。
附录
"Don't call me; I'll call you." 好莱坞原则
通常,Client即you调用下层Server即me天经地义,但是,对于某些方法, 请你不要轮询/骚扰我,我通知你。
现实生活中乘客/you打的士到某地,沿途问司机/me某个景点天经地义;但是不要从上车的第一秒开始,时刻或每隔5秒问司机到了打的的目的地没有,这也太烦人了。
好莱坞原则的核心:以通知替代轮询。
服务端推送即『好莱坞原则』的体现。
参考文档
- 好莱坞法则 don't call me i'll call you
- BELIEVING IN ANTI-PATTERNS IS AN ANTI-PATTERN 这个回答很有趣。stackoverflow.com/questions/4…