Promise + async/await 推荐实践
异步任务是我们日常开发中离不开的一环,例如用户操作后的网络请求、动画延时回调、node.js 中各种异步 IO/进程操作等等。
过去通常是通过传递回调函数的形式使用,如今我们通常使用 Promise,配合 async/await,让日常这些异步处理方便了很多。
不过对于刚接触 Promise 的新同学来说,日常可能只接触和使用过其中比较基础的使用形式,又没有花时间去了解其中的实现原理,这就可能会导致一些错误理解和反模式实践。
这里将平时遇见过的问题列举出来,结合自己的理解,希望能帮新同学们绕开一些可以避免的坑。
1. 简要介绍
(1) 什么是 Promise
个人认为,Promise 是一种 可链式触发的单向异步任务单元。
- 它基于 异步任务 进行封装,内部维护一个任务进行状态:
进行中
、已完成
、已拒绝
。 - 初始状态为
进行中
,可 单向流转:进行中
→已完成
/已拒绝
;不可以逆向流转。 - 一个 Promise 实例在
进行中
状态下,可以通过它的then(onResolved?, onRejected?)
函数指定 完成/拒绝状态回调函数。 - 异步任务执行完毕时,可以执行以下 A/B 操作之一:
- (A) 给 完成状态回调函数 传递一个 结果值,进入
已完成
状态; - (B) 给 拒绝状态回调函数 传递一个 理由,进入
已拒绝
状态。
- (A) 给 完成状态回调函数 传递一个 结果值,进入
上面是 Promise 基本概念,看起来似乎“平平无奇”。然而它又通过以下机制实现了链式触发的效果:
then
函数中,将自动创建另一个临时 Promise 实例:- 它将在 完成/拒绝状态回调函数 执行完毕时变为
已完成
状态。 - 状态回调函数的同步返回值将被作为其 结果值。
- 它将在 完成/拒绝状态回调函数 执行完毕时变为
- 若一个 Promise 完成时的 结果值 也是一个 Promise 时:
- 结果值的 Promise 将被当作 后续任务 处理。
- 直到后续任务被 完成/拒绝 后,当前任务才会真正被 完成/拒绝。
而其中 then
函数的状态回调函数还存在特殊情况:
then
的两个回调函数参数中,不存在对应当前 Promise 状态的回调函数时:- 当前 Promise 被完成,却没有 完成状态回调函数 时,临时 Promise 将被以相同的 结果值 完成。
- 当前 Promise 被拒绝,却没有 拒绝状态回调函数 时,临时 Promise 将被以相同的 理由 拒绝。
这样,我们就可以在日常开发中通过 then
不断地链式创建临时 Promise,让我们的多个异步任务按照预期地逐个触发了。
(2) 什么是 async/await
async/await 被我们日常作为 Promise 状态回调函数函数的语法糖使用。
1 | function createTask(factor) { |
上面的 work
可以使用 async/await 改写为:
1 | const work = async () => { |
只需要对 Promise 实例使用 await
操作符,就可以将异步任务的后续处理方式从嵌套的回调函数,彻底改变成仿佛是顺序执行的相同层级语句。甚至还可以使用 try/catch 同时捕获异步任务前后的异常。
尤其是对于多个异步任务逐个执行的情况,代码会简单和清晰很多,减轻业务开发中不必要的思维负担。
而对于暂时不支持 async/await 的浏览器环境,可以通过 babel+regeneratorRuntime 对项目代码进行转换,从而在日常开发中放心的使用这项新语法糖。
2. 不良实践与改进
(1) 嵌套的 Promise 回调
对于初次使用 Promise 的新手,可能会因为不知道可以在 then
回调内直接传递新的 Promise 作为 结果值,从而把 Promise 当作过去的回调函数使用,重新陷入回调地狱:
1 | // Bad: |
得益于 Promise 递归等待的机制,我们可以直接在最外层的 then
后面链式追加后续任务,并不需要反复嵌套:
1 | new Promise((rs) => { |
当然,还可以使用 async/await 处理:
1 | (async () => { |
(2) 忽视异常处理
新同学使用日常使用 Promise 时,可能并不会留心给每次 Promise 调用的最后加上 catch()
进行异常捕获。
或者直接使用 try/catch
尝试捕获 Promise 异步任务和状态回调内的异常,发现没能如预期地捕获到。
这是由于 Promise 的异步函数执行时,已经脱离创建时的调用栈,其内部发生的错误没法直接被调用时的 try/catch
捕捉到。
可以通过以下例子模拟类似的情形:
1 | function doItLater(fn, delay) { |
将
doItLater()
中的setTimeout(fn, delay)
改为fn()
同步调用,就能在外层捕获到异常。而 Promise 的状态回调并非同步执行,所以无法在外层直接捕获异常。
对于异步任务,我们需要通过 catch()
进行异常捕获,以便在外层做好任务被拒绝或者其它意外的处理:
1 | new Promise((rs) => { |
不过 catch()
只能捕获到 Promise 内部的异常,如果需要同时捕获异步任务之前的某些同步处理异常,还得把相同的异常处理再用 try/catch
写一遍:
1 | try { |
相同的异常处理写了三遍,有些可怕……不过上面的例子有点刻意了,doSomePreprocessing()
其实可以放在 Task start 相同的 try/catch 里。
但有时候也不一定能这样重新组织代码,不如直接使用 async/await
避免这样的冗余情况:
1 | (async () => { |
(3) await 一把梭
日常开发中,如果涉及到多个异步任务的情况,新同学可能没有多想就直接使用 await
让它们逐个执行了:
1 | (async () => { |
然而稍微观察就会发现,上面的请求的数据中可能存在前后依赖关系的情况,但也有不少可以并行处理的数据。
而让所有请求一股脑排队串行处理,既浪费现在日新月异的终端性能,又浪费用户宝贵的等待时间,未免有些暴殄天物。
对于并行处理的任务,我们可以使用 Promise.all()
方法:
- 它接收一个 Promise 数组参数,返回一个新的 Promise;
- 同时启动其中的异步任务,直到它们全部结束时转为 已完成 状态。
让我们用它重新组织上面的异步任务,提高一下页面效率吧:
1 | (async () => { |
(4) race 与 any
除了 Promise.all()
,还有两个类似的 Promise.race()
和 Promise.any()
方法。
Promise.race():
- 参数中的所有 Promise 同时启动,并进行竞赛。
- 任何一个异步任务 发生状态改变时,当前 Promise.race 封装的任务转为其相同的 已完成/已拒绝 状态。
Promise.any():
- 参数中的所有 Promise 同时启动。
- 其中任何一个异步任务完成时,当前 Promise.any 转为 已完成。
- 如果所有异步任务最终都未完成,则转为 已拒绝 并返回它们的异常集合,亦即所有 拒绝理由。
注意! Promise.any() 方法依然是实验特性,尚未被浏览器完全支持。
3. 更多
(1) 复杂任务
对于类似 IO 任务的情况,可能需要反复确认完成进度的情况。
直接封装为只有开始结束态的 Promise 的话,会让用户长时间等待中无法获得任何感知,用户体验较差。
需要配合传统回调函数,结合具体的业务需求和页面交互进行实现。
(2) 宏任务与微任务
推荐仔细阅读:Jiasm 的 《微任务、宏任务与Event-Loop》 - https://juejin.cn/post/6844903657264136200
在 Promise/A+ 的规范中,Promise 的实现可以是微任务,也可以是宏任务。不过普遍的共识一般将 Promise.then
的状态回调作为微任务实现。
相比之下,setTimeout
的宏任务将会在同一批创建的 Promise.then
微任务之后执行。