如何“优雅的”取消异步操作?
前言
首先声明一点:本文说的取消,并非指的是取消Promise,而是在于取消异步任务,这两者是有差别的,希望同学们不要误解。
场景:当有一连串的异步任务作为一个整体在串联执行时:
- 如果这些异步任务中某一个执行时间比较长,用户等不下去了,想要中断这时应该怎么办?
- 如果遇到场景切换,之前的任务不需要执行了,应该怎么中止?
在多线程环境下,我们只需要把相关执行线程给终止掉即可。但JavaScript是单线程语言,那应该如何实现?
异步串联执行
首先我们要弄清楚:异步任务是如何串联执行?
串联执行,也就是说按先后顺序执行一组任务,而且同一时间只能执行一个任务,执行的顺序还不能乱,因为上一个任务的执行结果,可能会影响下一个任务的执行。
回调的方式
执行一组固定的异步任务:
这个模式的重点是把每项任务都包装成一个单元,而且任务内容和数量都需要提前知道。
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 };
}
}
调用结果 这里的关键在于next方法调用必须是异步的,我们在使用generatorToAsync函数包裹handleTasks函数时,已经启动了第一次step函数的执行,到达了
const res1 = yield asyncTask('1');
然后停止executor的执行,返回promise和cancel函数。
这样cancel函数才能在第一个异步任务asyncTask('1')
执行前执行,才能有机会取消第一个异步任务。否则没有办法在第一个异步任务执行前取消执行。
这里设置了100毫秒后才执行取消,也就是说第一个异步任务不取消。第一个异步任务执行也需要100毫秒(当然100毫秒不是精确值),所以说第一个异步任务没完成之前,已经执行取消逻辑,当然不妨碍第一个异步任务的完成,只能影响第二个异步任务。
如果立即执行cancel()
,结果如下:
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()
的结果: 请看,就算立马执行
cancel()
,还是要等到异步任务1完成之后,通过next('异步任务1的结果')
传递结果时,才有机会判断是否继续执行。
完整代码在github。可以尝试断点调试,理清逻辑。