异步任务相比同步任务的复杂之处,主要在于返回结果的时机不可控,由此带来超时控制、顺序控制、竞态、最大并发等一系列问题。
单个的异步和简单的并发一般在语言层面就获得了支持,就前端而言,Promise 和 Async 是如此普及,以至于有些新人可能连 callback 都不认识了,建议看看《深入浅出nodejs》中关于异步的章节,补补历史知识。
不过有些场景,光靠原生 API 还搞不定,需要用点额外的技巧。本文总结了业务实践中可能遇到的四种场景:两个关于序列(Sequence),两个关于并发(Concurrency)。
1、单一异步任务循环
这类的问题是说:不定数量的同类异步任务如何依次执行。比如按序上传/下载、按序执行某种耗时任务、toast 组件多次调用时先展示完上一个再展示下一个等等。
解决这类问题可以借助某种队列或循环机制,比如:
const f = () => new Promise((resolve) => setTimeout(() => {
console.log(Date.now())
resolve()
}, 1000));
(async function () {
let count = 3
while (count--) {
await f()
}
})();
这篇文章详细介绍了各类 Loop/Iterator + Promise/Async 的写法,市面上也有不少现成的库可以处理这种问题,比如 async.series、deferred-queue、promise-sequence、co 等。
2、异步任务组合序列
这类问题是说:不定数量的非同质异步任务如何依次执行。比如开户流程需要依次执行多种不同的校验、提交一个复杂的表单需要分步校验再分步提交、返回逻辑存在多种拦截提示和跳转分支等等。无论是业务特性决定的,还是接口设计不合理导致的,可能都需要前端自己消化。
直接硬编码,很容易写出低维护性的面条代码,如果还不加节制的使用响应式(比如在 vue 里通过 watch 触发任务)或者用订阅发布 emit 来 dispatch 去,会进一步降低代码的可读性。我们期望每个函数是单一职责的、可测试的,然后有一种独立的调度方式来组织整个任务序列。
比如借鉴 Webpack。Webpack 原本只是一个打包工具,后来逐步演化成前端构建工具。构建的核心是构建过程管理,或者说构建任务管理。任务有同步、异步,过程有并行、串行,看上去很复杂,但是 Webpack 通过 Tapable 这个库对各类同步、异步场景做了很好的抽象,Tapable 提供了一系列被称为 Hook 的 api 来处理各类同步、异步的场景。
{
SyncHook,// 同步序列任务
AsyncSeriesHook, // 异步序列任务
AsyncSeriesBailHook, // 可中断的异步序列任务
AsyncSeriesWaterfallHook // 可传参的异步序列任务
...
}
以 AsyncSeriesHook 为例(单个任务 resolve 即继续下一步,reject 则退出整个流程),可以写出如下代码(在线体验):
import { AsyncSeriesHook } from 'tapable'
const hook = new AsyncSeriesHook()
function a () {
return new Promise((resolve) => {
console.log('aaa')
setTimeout(resolve, 1000)
})
}
function b () {
return new Promise((resolve) => {
console.log('bbb')
setTimeout(resolve, 1000)
})
}
function c () {
return new Promise((resolve) => {
console.log('ccc')
setTimeout(resolve, 1000)
})
}
// 按序注册任务
hook.tapPromise('task a', a)
hook.tapPromise('task b', b)
hook.tapPromise('task c', c)
// 开始执行
hook.promise()
.then(() => console.log('done'))
.catch(() => console.log('error'))
单个任务执行完成后,只有三种结果:继续下一步(next),正常结束整个流程(done)、因异常结束整个流程(exit)。基于 AsyncSeriesBailHook 进一步封装,可以实现如下的效果(在线体验):
task
.push(taskA)
.push(taskB)
.push(taskC)
.run()
.then((res: any) => {
// handle result
})
.catch(err => {
// handle error
})
需注意,使用 Tapable,所有的任务都需要在一开始注册好,然后再 run,不能在中途添加/插入任务。
3、并发任务的“单例”问题
这个问题的典型是 loading 组件该如何设计:假设每个接口发起请求时都会展示 loading,请求结束隐藏 loading。同一时间的接口请求可能有很多,但每时每刻界面上应该只能有一个 loading。比如 a 请求发出,展示 loading,之后 b 请求发出,如果 a 请求结束时,b 还没有结束,那么继续展示 loading,反之则隐藏 loading,这可怎么办呢?
解决这个问题可以采用一种类似信号量的策略:
const loading = {
count: 0,
el: document.createTextNode('loading'),
show () {
if (this.count === 0) {
document.body.appendChild(this.el)
}
this.count += 1
},
hide () {
this.count -= 1
if (this.count === 0) {
document.body.removeChild(this.el)
}
}
}
如果可能同时存在多个局部 loading(比如按模块并行加载的页面),那么最好将上述方法封装成一个类,便于生成多个实例。如果 loading 和 toast 之间还存在互斥关系的话,那么组件的设计还会更麻烦一些。
4、并发任务的竞态问题
这个问题的典型场景是:同一类请求,先后发送,以哪一个的返回为准?比如用户搜索 A 类电影,由于接口迟迟未返回,用户选择搜索 B 类电影,如果 B 的请求还没有返回,A 却返回了,这时能取 A 返回的结果吗?显然不能。
和 Promise.race 不同, Promise.race 是说谁先返回就取谁,上述问题是说谁最后请求就取谁。一种直接的思路是每次发出新请求就把前面的请求 cancel 掉,不过 Promise 没有提供 cancel api,那么另一种容易想到的思路是用时间戳来标识每个请求,然后判断请求的新鲜度:
const map = {
'fetchMovie': 0
}
function fetchMovie () {
const stamp = Date.now()
map.fetchMovie = stamp
fetch(url, params).then(res => {
if (stamp < map.fetchMovie) return null // 该请求已过时
return res
})
}
不过这种方案侵入性比较强,更理想的方案是实现一个类似 redux-saga 的 takeLatest 方法。takeLatest 的基本思路是:只要有最新的请求,就将之前的请求 cancel 掉。虽然某些 Promise 的实现提供了 cancel 能力,但有没有办法用原生 Promise 的同时又能实现 cancel 呢?
// 模拟一个在 t 时间后返回结果的接口请求
function request (t) {
return new Promise(resolve => {
setTimeout(function () {
resolve(t)
}, t)
})
}
const map = {}
let uid = 0
function takeLatest (fn) {
const key = uid++
return function () {
let resolve
let reject
// 嘿嘿
const a = new Promise((_res, _rej) => {
resolve = _res
reject = _rej
})
const t = Date.now()
map[key] = t
fn.apply(null, arguments).then(res => {
if (t < map[key]) return
resolve(res)
}).catch(error => {
if (t < map[key]) return
reject(error)
})
return a
}
}
// 测试
const f1 = takeLatest(request)
const f2 = takeLatest(request)
f1(3000).then(res => {
console.log(res)
})
f2(3050).then(res => {
console.log(res)
})
setTimeout(() => {
f1(1000).then(res => {
console.log(res)
})
f2(1050).then(res => {
console.log(res)
})
}, 1000)
// 返回 setTimeout 里的两个“最新”的请求结果:1000,1050
所谓 cancel 呢,其实就是额外包一层 Promise,这个 Promise 既不 resolve 又不 reject,过段时间就被垃圾回收了。
还有借助 AbortController API 的解决思路,亦可参考。