连续同源异步操作队列

问题的发现

来自同源的多个异步操作可能引起异步冲突问题,特别是在网络请求时。同源操作产生了两个ajax请求,它们的请求结果将用于渲染同一个区域,然而由于网络问题,先发出的请求后返回,导致最终得到的界面是错误的。

解决这个问题的最好办法,是利用原生XHR的abort方法,在后一次操作时,将前一次操作引起的ajax请求给cancel掉。

但是在现实条件下,异步操作并非都有cancel操作。js原生的Promise没有,原生的fetch基于Promise也没有。基于Promise的很多工具都没有cancel操作。这种情况下怎么解决这个问题呢?

其实方法是有的,就是直接丢弃Promise的推送,不执行它的resolve回调即可。这样,虽然异步操作已经执行了,但不会对现有的环境造成任何副作用。(虽然这样看上去浪费了异步操作这个资源。)

问题的思考

如何来判断是否要丢掉它的回调?我们可以创建一个队列,每次产生一个异步操作时,就将它加入到队列中,当队列中存在操作对象时,每次只取最后一个,等待它推送结果,执行它的回调,排在它前面的操作全部丢弃掉。

基于这样的想法,我写了一个工具deferer-queue。它为异步操作创建队列,并根据不同的场景实现不同的队列操作形式。你可以通过这里阅读它的源码和文档。它提供了4种可供选择的场景:

并联

异步操作进入队列的时候立即开始执行,它们的回调将按照它们进入队列的顺序执行。

这个图中有这些元素:

  • 时间轴
  • 圆圈:一个异步操作被push进队列
  • 向下箭头:队列中开始执行某个异步操作
  • 虚线:执行顺序(执行流)
  • 方框:每个异步操作的回调
  • 竖线:队列周期(从开始到停止的过程)分割线

上图表现的是,1-4这4个异步操作在不同的事件点被加入到队列中。它们在一个周期内,也就是从1开始执行,到队列中所有的异步操作执行完毕,进入停滞状态。1-4这些操作,一进入队列就开始执行。它们执行完之后,就会告知队列执行完毕状态,回调就会执行。但是由于回调一定是按照异步操作进入队列的顺序执行的,因此,即使4已经完成了,但它的回调也必须等到3结束后才会执行。

在deferer-queue中,所有的回调一定遵循先进队列先执行,后进队列后执行的顺序规则。

串联

异步操作进入队列后并不立即执行,它从队列中取出第一个,执行,等待这个异步操作完成(成功或失败都算完成)并执行它的回调,然后再取出下一个,继续执行。

上图表现的是,1-4这4个异步操作,进入队列后,按照顺序依次执行。但是,在一个周期内,只有1是立即执行的,其他操作必须等到上一个操作结束(promise resolved或rejected)之后,才会开始执行。

尾调

异步操作进入队列后并不立即执行,它从队列中取出最后一个,执行,并把前面所有的操作丢弃,这个操作执行完后直接进入回调,队列结束。

这张图表现的是,1-4这个4个异步操作被按顺序加入到队列中,但是,在一个周期内,只有4(最后一个)被执行。1-3全部被丢掉了。你可能会问,假如4进入队列的时候,1已经在执行了怎么办?答案是,1会被丢掉,只要在一个周期内,只会有一个执行的回调会被执行。

连续

异步操作进入队列后并不立即执行,它从队列中取出第一个,执行,等待这个异步操作完成(成功或失败都算完成)并执行它的回调,之后取出最新的一个执行,执行完之后再看队列中是否有新的,如果有新的,再取出最新的一个,继续执行,如此往复下去。

这幅图表面的是一种连续不断的执行队列的模式,当1-4被push进队列之后,1被执行,2-4被丢掉。当1完成之后,检查队列是否加入了新的异步操作,如果检查到5-6,那么取6进行执行,把5丢掉。进入后续过程后,感觉上和尾调非常像,但是,尾调是在一个周期内,只会执行最后一个回调,而连续不是,它会不断的检查队列是否已经被执行完,如果没有,那么就会执行一个类似尾调的过程,这个过程结束,又会再做一次。队列开始的时候,取第一个异步操作执行,当第一个操作结束后,后面之后去取最后一个执行。这些执行之间是串联关系,只有正在执行的异步操作已经完成的情况下,新的操作才会被执行。这是一种比较奇葩的场景,但是它可以解决一些需要连续不断做某些事情的应用。

注意:当一个周期结束后,队列处于停滞状态,但是,一旦有新的defer被push进队列,它又会开始一个新的周期。

问题的解决

基于上述的思考,我最终发布了deferer-queue,你可以通过npm安装这个包,在自己的项目中使用它。它的操作模式超级简单,首先实例化一个queue对象,然后往这个queue push异步操作,异步操作被装在一个函数中被push进队列,它的回调函数一定是按照push的顺序执行。

import DefererQueue from 'deferer-queue'

const queue = new DefererQueue()

const defer1 = () => new Promise((resolve, reject) => { ... })
const defer2 = () => axios.get(...)
const defer3 = async () => ...

queue.push(defer1).then(() => { console.log(1) })
queue.push(defer2).then(() => { console.log(2) })
queue.push(defer3).then(() => { console.log(3) })

无论defer1-3中的谁,执行后谁先返回结果,控制台输出的结果永远是1 2 3(defer都成功的前提下)。

要使用上述的四种模式,只需要在实例化的时候,传入一个options对象,将mode设置为parallel/serial/switch/shift中的一个即可:

const queue = new DefererQueue({
  mode: 'switch',
})

其他的用法一样。这样,你的队列就会按照“尾调”的模式运行。

关于具体的API使用细则,你可以阅读它的文档。其中,利用axios的cancel能力那个地方非常有借鉴意义。