如何“优雅的”取消异步操作?

前言

首先声明一点:本文说的取消,并非指的是取消Promise,而是在于取消异步任务,这两者是有差别的,希望同学们不要误解。

场景:当有一连串的异步任务作为一个整体在串联执行时:

  • 如果这些异步任务中某一个执行时间比较长,用户等不下去了,想要中断这时应该怎么办?
  • 如果遇到场景切换,之前的任务不需要执行了,应该怎么中止?

在多线程环境下,我们只需要把相关执行线程给终止掉即可。但JavaScript是单线程语言,那应该如何实现?

异步串联执行

首先我们要弄清楚:异步任务是如何串联执行?

串联执行,也就是说按先后顺序执行一组任务,而且同一时间只能执行一个任务,执行的顺序还不能乱,因为上一个任务的执行结果,可能会影响下一个任务的执行。 image.png

回调的方式

执行一组固定的异步任务:
这个模式的重点是把每项任务都包装成一个单元,而且任务内容和数量都需要提前知道。

function task1 (cb) {
    setTimeout(() => {
        task2(cb)
    })
}
function task2 (cb) {
    setTimeout(() => {
        task3(cb)
    })
}
function task3 (cb) {
    setTimeout(() => {
        cb();
    })
}
task1(() => {
    console.log('结束')
})

使用setTimeout模拟异步操作。

迭代集合中的元素:
该模式适合迭代集合中的任务,原理和上面是一样的。

function iterator (idx) {
    if (idx === tasks.length)
        return callback();
    const task = tasks[idx];
    task(() => {
        iterator(idx++);
    })
}
function callback () {}
iterator(0)

Promise

使用Promise实现顺序迭代更加简单明了,只需链式调用即可,也就是.then().then().then()...

const prmose = tasks.reduce((prev, task) => {
    return prev.then(() => {
        return task();
    })
}, Promise.resolve())

还有终极方案:async/await

async function asyncTasks () {
    for (const task of tasks) {
        await task();
    }
}

顺便说说,应该有不少前端er都尝试过使用Array.protptype.forEach()或者Array.protptype.map()来迭代异步任务集合以实现顺序执行。通常表示为以下这种模式:

tasks.forEach(async (task) => {
    await task();
})

尝试过的同学都知道,这样写的效果其实是平行执行。这段代码针对tasks数组中的每一个任务都会调用一个匿名箭头函数,该匿名函数会对task所返回的Promise做await。但问题在于,并没有对匿名函数本身返回的Promise做await,也就是说这些个匿名函数都会在事件循环的一个回合里触发,所以就变成了平行执行。

取消异步操作

了解异步任务是如何串联执行之后,我们就来开始如何“优雅的”取消异步操作。原理其实很简单,就是每执行一次异步操作就判断一下,是否要继续执行。

async function handleTasks () {
    if (cancel) {
        throw new Error();
    }
    const res1 = await asyncTask1();
    
    if (cancel) {
        throw new Error();
    }
    const res2 = await asyncTask2();
}

可取消的包装器

使用工厂模式设计一个包装器,把异步执行与取消执行判断包装在一起:

export function createWrapper () {
  let executeCancel = false

  function cancel () {
    executeCancel = true
  }

  function executorWrapper (fn, ...args) {
    if (executeCancel) {
      return Promise.reject(new Error())
    }
    return fn(...args)
  }

  return { executorWrapper, cancel }
} 

非常简单,逻辑一目了然。简单来说工厂返回两个函数:

  • executorWrapper函数包装异步任务。
  • cancel函数用于取消执行。
import { createWrapper } from './utils.js'
const { executorWrapper, cancel } = createWrapper();
async function handleTasks () {
    // asyncTask方法是返回一个promise
    const res1 = await executorWrapper(asyncTask1);
    const res2 = await executorWrapper(asyncTask2);
}
handleTasks().catch(() => {
    console.log('Cancelled')
})
setTimeout(cancel, 500)

出于简单考虑,这里我并没有分辨是调用cancel函数而导致的reject,还是异步执行出错而导致的reject。

这个模式很简单,使用包装函数给每一个异步函数都包一层,在里面判断是否要取消执行。优点是可以与业务代码解构,逻辑重用。

但缺点也很明显,需要将每一个异步任务都包装一下。

使用生成器

我们将目光移到async/await上,为什么async/await将代码写成顺序执行的样子呢?

不故弄玄虚了,原理前端er们应该都很清楚,就是Promise+Generator。所以我们要重现async/await语法,并往里面加入取消执行的判断。

第一步将handleTasks函数采用generator表示:

function* handleTasks() {
    const res1 = yield asyncTask1();
    const res2 = yield asyncTask2();
}

因为generator不常用,首先对generator做一个简单的说明。如果是手动执行的话,首先调用handleTasks函数生成一个迭代器

const gen = handleTasks();

然后第一次调用next,执行到第一个yield这里,也就是:

const taskPromise = gen.next() // const res1 = yield asyncTask1();

得到的taskPromise,它的value值是asyncTask1所返回的promise,这里请注意res1的值还没被确定的,要到调用下一个next方法并往里面传参,这个参数才是res1的值。也就是说:

gen.next('res1')// res1 = 'res1'

如此往复,直到next方法返回的值中done属性为true方可结束。

利用这个特性,我们只需编写一个自动执行的函数,在其中判断done值是否为true与判断是否取消执行即可。

大体的调用流程如下

import { generatorToAsync } from './utils.js'
function asyncTask (No) {
    console.log(`Starting async task ${No}`)
    return new Promise(resolve => {
        setTimeout(() => {
            console.log(`Async task ${No} completed`)
            resolve(`Async task ${No} result`)
        }, 100)
    })
}
const executor = generatorToAsync(
     function* handleTasks() {
        const res1 = yield asyncTask('1');
        console.log(res1);
        const res2 = yield asyncTask2('2');
        console.log(res2);
    }
)
const { promise, cancel } = executor();
promise().catch(() => {
    console.log('Cancelled')
})
setTimeout(cancel, 100)

有了基本思路,实现这个函数那就易如反掌:

export function generatorToAsync(genFn) {
    return (...args) => {
        let executeCancel = false;
        function cancel () {
            executeCancel = true;
        }
        const gen = genFn(...args);
        const promise = new Promise((resolve, reject) => {
            function step(args) {
                if (executeCancel)
                    return reject(new Error('cancel'));
                const { value, done } = args;
                if (done) {
                    return resolve(value);
                }
                return Promise.resolve(value).then(val => step(gen.next(val)), err => reject(err))
            }
            step({});
        })
        return { promise, cancel };
    }
} 

调用结果 image.png 这里的关键在于next方法调用必须是异步的,我们在使用generatorToAsync函数包裹handleTasks函数时,已经启动了第一次step函数的执行,到达了const res1 = yield asyncTask('1');然后停止executor的执行,返回promise和cancel函数。

这样cancel函数才能在第一个异步任务asyncTask('1')执行前执行,才能有机会取消第一个异步任务。否则没有办法在第一个异步任务执行前取消执行。

这里设置了100毫秒后才执行取消,也就是说第一个异步任务不取消。第一个异步任务执行也需要100毫秒(当然100毫秒不是精确值),所以说第一个异步任务没完成之前,已经执行取消逻辑,当然不妨碍第一个异步任务的完成,只能影响第二个异步任务。

如果立即执行cancel(),结果如下: image.png

next方法是同步
下面说说next方法是同步的情况,将generatorToAsync改成下面形式:

export function generatorToAsync(genFn) {
    return (...args) => {
        ...
        const promise = new Promise((resolve, reject) => {
            function step(args) {
                if (executeCancel)
                    return reject(new Error('cancel'));
                let res;
                try {
                    res = gen.next(args);
                } catch (err) {
                    reject(err);
                }
                const { value, done } = res;
                if (done) {
                    return resolve(value);
                }
                return Promise.resolve(value).then(val => step(val), err => reject(err))
            }
            step({});
        })
        ...
    }
}

只改变Promise的executor的逻辑,其他与异步的一样。然后无论是同步执行cancel()还是异步调用,都只能阻止第二个及以后异步任务的执行。

立即执行cancel()的结果: image.png 请看,就算立马执行cancel(),还是要等到异步任务1完成之后,通过next('异步任务1的结果')传递结果时,才有机会判断是否继续执行。

完整代码在github。可以尝试断点调试,理清逻辑。