JavaScript 异步时序问题
场景
死后我们必升天堂,因为活时我们已在地狱。
不知你是否遇到过,向后台发送了多次异步请求,结果最后显示的数据却并不正确–是旧的数据。
具体情况:
- 用户触发事件,发送了第1次请求
- 用户触发事件,发送了第2次请求
- 第2次请求成功,更新页面上的数据
- 第1次请求成功,更新页面上的数据
嗯?是不是感觉到异常了?这便是多次异步请求时会遇到的异步回调顺序与调用顺序不同的问题。
思考
- 为什么会出现这种问题?
- 出现这种问题怎么解决?
为什么会出现这种问题?
JavaScript随处可见异步,但实际上并不是那么好控制。用户与UI交互,触发事件及其对应的处理函数,函数执行异步操作(网络请求),异步操作得到结果的时间(顺序)是不确定的,所以响应到UI上的时间就不确定,如果触发事件的频率较高/异步操作的时间过长,就会造成前面的异步操作结果覆盖后面的异步操作结果。
关键点
- 异步操作得到结果的时间(顺序)是不确定的
- 如果触发事件的频率较高/异步操作的时间过长
出现这种问题怎么解决?
既然关键点由两个要素组成,那么,只要破坏了任意一个即可。
- 手动控制异步返回结果的顺序
- 降低触发频率并限制异步超时时间
手动控制返回结果的顺序
根据对异步操作结果处理情况的不同也有三种不同的思路
- 后面异步操作得到结果后等待前面的异步操作返回结果
- 后面异步操作得到结果后放弃前面的异步操作返回结果
- 依次处理每一个异步操作,等待上一个异步操作完成之后再执行下一个
这里先引入一个公共的wait函数
/** *等待指定的时间/等待指定表达式成立 *如果未指定等待条件则立刻执行 *注:此实现在nodejs10-会存在宏任务与微任务的问题,切记async-await本质上还是Promise的语法糖,实际上并非真正的同步函数!!!即便在浏览器,也不要依赖于这种特性。 *@paramparam等待时间/等待条件 *@returnsPromise对象 */ functionwait(param){ returnnewPromise(resolve=>{ if(typeofparam==='number'){ setTimeout(resolve,param) }elseif(typeofparam==='function'){ consttimer=setInterval(()=>{ if(param()){ clearInterval(timer) resolve() } },100) }else{ resolve() } }) }
1.后面异步操作得到结果后等待前面的异步操作返回结果
- 为每一次的异步调用都声称一个唯一id
- 使用列表记录所有的异步id
- 在真正调用异步操作后,添加一个唯一id
- 判断上一个正在执行的异步操作是否完成
- 如果未完成等待上一个异步操作完成,否则直接跳过
- 从列表中删除掉当前的id
- 最后等待异步操作然后返回结果
/** *将一个异步函数包装为具有时序的异步函数 *注:该函数会按照调用顺序依次返回结果,后面的调用的结果需要等待前面的,所以如果不关心过时的结果,请使用{@linkswitchMap}函数 *@paramfn一个普通的异步函数 *@returns包装后的函数 */ functionmergeMap(fn){ //当前执行的异步操作id letid=0 //所执行的异步操作id列表 constids=newSet() returnnewProxy(fn,{ asyncapply(_,_this,args){ constprom=Reflect.apply(_,_this,args) consttemp=id ids.add(temp) id++ awaitwait(()=>!ids.has(temp-1)) ids.delete(temp) returnawaitprom }, }) }
测试一下
;(async()=>{ //模拟一个异步请求,接受参数并返回它,然后等待指定的时间 asyncfunctionget(ms){ awaitwait(ms) returnms } constfn=mergeMap(get) letlast=0 letsum=0 awaitPromise.all([ fn(30).then(res=>{ last=res sum+=res }), fn(20).then(res=>{ last=res sum+=res }), fn(10).then(res=>{ last=res sum+=res }), ]) console.log(last) //实际上确实执行了3次,结果也确实为3次调用参数之和 console.log(sum) })()
2.后面异步操作得到结果后放弃前面的异步操作返回结果
- 为每一次的异步调用都声称一个唯一id
- 记录最新得到异步操作结果的id
- 记录最新得到的异步操作结果
- 执行并等待返回结果
- 判断本次异步调用后面是否已经有调用出现结果了
是的话就直接返回后面的异步调用结果
否则将本地异步调用id及其结果最为[最后的]
返回这次的异步调用结果
/** *将一个异步函数包装为具有时序的异步函数 *注:该函数会丢弃过期的异步操作结果,这样的话性能会稍稍提高(主要是响应比较快的结果会立刻生效而不必等待前面的响应结果) *@paramfn一个普通的异步函数 *@returns包装后的函数 */ functionswitchMap(fn){ //当前执行的异步操作id letid=0 //最后一次异步操作的id,小于这个的操作结果会被丢弃 letlast=0 //缓存最后一次异步操作的结果 letcache returnnewProxy(fn,{ asyncapply(_,_this,args){ consttemp=id id++ constres=awaitReflect.apply(_,_this,args) if(temp测试一下
;(async()=>{ //模拟一个异步请求,接受参数并返回它,然后等待指定的时间 asyncfunctionget(ms){ awaitwait(ms) returnms } constfn=switchMap(get) letlast=0 letsum=0 awaitPromise.all([ fn(30).then(res=>{ last=res sum+=res }), fn(20).then(res=>{ last=res sum+=res }), fn(10).then(res=>{ last=res sum+=res }), ]) console.log(last) //实际上确实执行了3次,然而结果并不是3次调用参数之和,因为前两次的结果均被抛弃,实际上返回了最后一次发送请求的结果 console.log(sum) })()3.依次处理每一个异步操作,等待上一个异步操作完成之后再执行下一个
- 为每一次的异步调用都声称一个唯一id
- 使用列表记录所有的异步id
- 向列表中添加一个唯一id
- 判断上一个正在执行的异步操作是否完成
- 如果未完成等待上一个异步操作完成,否则直接跳过
- 真正调用异步操作
- 从列表中删除掉当前的id
- 最后等待异步操作然后返回结果
/** *将一个异步函数包装为具有时序的异步函数 *注:该函数会按照调用顺序依次返回结果,后面的执行的调用(不是调用结果)需要等待前面的,此函数适用于异步函数的内里执行也必须保证顺序时使用,否则请使用{@linkmergeMap}函数 *注:该函数其实相当于调用{@codeasyncLimiting(fn,{limit:1})}函数 *例如即时保存文档到服务器,当然要等待上一次的请求结束才能请求下一次,不然数据库保存的数据就存在谬误了 *@paramfn一个普通的异步函数 *@returns包装后的函数 */ functionconcatMap(fn){ //当前执行的异步操作id letid=0 //所执行的异步操作id列表 constids=newSet() returnnewProxy(fn,{ asyncapply(_,_this,args){ consttemp=id ids.add(temp) id++ awaitwait(()=>!ids.has(temp-1)) constprom=Reflect.apply(_,_this,args) ids.delete(temp) returnawaitprom }, }) }测试一下
;(async()=>{ //模拟一个异步请求,接受参数并返回它,然后等待指定的时间 asyncfunctionget(ms){ awaitwait(ms) returnms } constfn=concatMap(get) letlast=0 letsum=0 awaitPromise.all([ fn(30).then(res=>{ last=res sum+=res }), fn(20).then(res=>{ last=res sum+=res }), fn(10).then(res=>{ last=res sum+=res }), ]) console.log(last) //实际上确实执行了3次,然而结果并不是3次调用参数之和,因为前两次的结果均被抛弃,实际上返回了最后一次发送请求的结果 console.log(sum) })()小结
虽然三个函数看似效果都差不多,但还是有所不同的。
- 是否允许异步操作并发?否:concatMap,是:到下一步
- 是否需要处理旧的的结果?否:switchMap,是:mergeMap
降低触发频率并限制异步超时时间
思考一下第二种解决方式,本质上其实是限流+自动超时,首先实现这两个函数。
- 限流:限制函数调用的频率,如果调用的频率过快则不会真正执行调用而是返回旧值
- 自动超时:如果到了超时时间,即便函数还未得到结果,也会自动超时并抛出错误
下面来分别实现它们
限流实现
具体实现思路可见:JavaScript防抖和节流
/** *函数节流 *节流(throttle)让一个函数不要执行的太频繁,减少执行过快的调用,叫节流 *类似于上面而又不同于上面的函数去抖,包装后函数在上一次操作执行过去了最小间隔时间后会直接执行,否则会忽略该次操作 *与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作,而非仅执行最后一次操作 *注:该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值 *注:返回函数结果的高阶函数需要使用{@linkProxy}实现,以避免原函数原型链上的信息丢失 * *@param{Number}delay最小间隔时间,单位为ms *@param{Function}action真正需要执行的操作 *@return{Function}包装后有节流功能的函数。该函数是异步的,与需要包装的函数{@linkaction}是否异步没有太大关联 */ constthrottle=(delay,action)=>{ letlast=0 letresult returnnewProxy(action,{ apply(target,thisArg,args){ returnnewPromise(resolve=>{ constcurr=Date.now() if(curr-last>delay){ result=Reflect.apply(target,thisArg,args) last=curr resolve(result) return } resolve(result) }) }, }) }自动超时
注:asyncTimeout函数实际上只是为了避免一种情况,异步请求时间超过节流函数最小间隔时间导致结果返回顺序错乱。
/** *为异步函数添加自动超时功能 *@paramtimeout超时时间 *@paramaction异步函数 *@returns包装后的异步函数 */ functionasyncTimeout(timeout,action){ returnnewProxy(action,{ apply(_,_this,args){ returnPromise.race([ Reflect.apply(_,_this,args), wait(timeout).then(Promise.reject), ]) }, }) }结合使用
测试一下
;(async()=>{ //模拟一个异步请求,接受参数并返回它,然后等待指定的时间 asyncfunctionget(ms){ awaitwait(ms) returnms } consttime=100 constfn=asyncTimeout(time,throttle(time,get)) letlast=0 letsum=0 awaitPromise.all([ fn(30).then(res=>{ last=res sum+=res }), fn(20).then(res=>{ last=res sum+=res }), fn(10).then(res=>{ last=res sum+=res }), ]) //last结果为10,和switchMap的不同点在于会保留最小间隔期间的第一次,而抛弃掉后面的异步结果,和switchMap正好相反! console.log(last) //实际上确实执行了3次,结果也确实为第一次次调用参数的3倍 console.log(sum) })()起初吾辈因为好奇实现了这种方式,但原以为会和concatMap类似的函数却变成了现在这样–更像倒置的switchMap了。不过由此看来这种方式的可行性并不大,毕竟,没人需要旧的数据。
总结
其实第一种实现方式属于rxjs早就已经走过的道路,目前被Angular大量采用(类比于React中的Redux)。但rxjs实在太强大也太复杂了,对于吾辈而言,仅仅需要一只香蕉,而不需要拿着香蕉的大猩猩,以及其所处的整个森林(此处原本是被人吐槽面向对象编程的隐含环境,这里吾辈稍微藉此吐槽一下动不动就上库的开发者)。
可以看到吾辈在这里大量使用了Proxy,那么,原因是什么呢?这个疑问就留到下次再说吧!
以上就是JavaScript异步时序问题的详细内容,更多关于JavaScript异步时序的资料请关注毛票票其它相关文章!