async await、Promise闹不清楚?聊聊日常开发中的流程控制逻辑
本文写给对JavaScript的async、Promise语法有所了解,但还用不熟溜的萌新,跟JavaScript关系很熟的老伙计们可以散了。
先讲下写这篇东西的初衷:平时开发的时候我注意到有的小伙伴虽然了解async、Promise基本语法,但是对它们的使用局限于最常见的几种情况,代码或者需求有所变化就容易转不过弯来,因此在这里跟队友们分享一下自己的理解。
平时我们项目代码里最常见的Promise用法:
// 弹出遮罩 httpApi(param) .then(res => { // 根据res做一些数据处理 }) .catch(err => { // 有时候不写错误处理逻辑 }) .finally(() => { // 关闭遮罩 }) 复制代码
最常见的async await写法也是用在http请求:
// 弹出遮罩 const res = await httpApi(param) // 根据res做一些数据处理 // 关闭遮罩 复制代码
有些同学知道上述逻辑有点遗漏,会写成这样:
try { // 弹出遮罩 const res = await httpApi(param) // 根据res做一些数据处理 } finally { // 关闭遮罩 } 复制代码
(然后ta就发现用了async await,代码也不比第一个示例精简,就不明白还有什么必要用这个语法)
当情况稍微复杂一些的时候,流程控制语句读写起来就没那么简单了。下面举个平时开发中较常出现的例子:
例一:先发请求1,请求1结束之后再发请求2
function f1() { // 弹出遮罩 httpApi1(param) .then(res => { // 根据res做一些数据处理 f2() // 请求1成功结束后,发请求2 }) .finally(() => { // 关闭遮罩 }) } function f2() { // 弹出遮罩 httpApi2(param) .then(res => { // ... }) .finally(() => { // 关闭遮罩 }) } 复制代码
上面这种写法会有点小问题:调用f2以后,弹出遮罩,但马上就被f1的finally块里的代码关掉了遮罩,所以f2里的请求还没结束遮罩就关了。
之所以会出现这样的写法,是因为写的小伙伴只理解了:“Promise的then回调里写成功之后的逻辑,catch回调里写失败之后的逻辑,finally回调里写无论成功失败都要做的处理。”所以ta觉得既然请求2要在请求1成功结束以后发出,那么就应写在请求1的成功回调里。但这样写没有理解Promise还有一层作用:对异步操作进行流程控制。
什么是“对异步操作进行流程控制?”比方说,你要在游戏里做一把武器,做这把武器需要若干素材,有的是副本掉落,有的需要你手动合成,用人话来讲这个过程其实挺简单,刷副本直到副本掉落素材集齐;刷合成素材直到合成素材集齐;(有的游戏会要求合成技能到一定等级)把合成技能熟练度刷到,然后合成。
按我们的理解,这是个线性的任务。但在实际操作上,它可能会是“异步”的,你先刷一把副本,然后做做合成任务升一升合成技能熟练度,然后搜集搜集合成素材,然后又刷刷副本……所以“攒副本素材”“攒合成素材”“练合成技能熟练度”三个任务是“并发”的。
而这三个任务内部的具体细节,也需要合适的流程控制逻辑才能把这个事给表达清楚,就拿“攒副本素材”来说,开一把副本不一定能成功打过boss,打过了boss素材也是几率掉落,可能给一两个,可能给五六个,所以攒够素材到底需要开多少把副本是不确定的。
如果“打副本”是个同步任务:
function 攒副本素材() { let materialNum = 0; while (materialNum < 100) { materialNum += 收集一次副本素材(); } } function 收集一次副本素材() { try { let res = 开一把(); return res ? res.material || 0 : 0; } catch { // 打本失败 return 0; } } 复制代码
但刷副本如果是个异步任务呢?用回调来表达这个过程,可读性就有点差:
const materialNeed = 100; let materialNum = 0; function 攒副本素材() { 开一把( function 成功回调(res) { if (res && res.material) { materialNum += res.material; } if (materialNum < materialNeed) { // 如果材料还不够 攒副本素材(); } }, function 失败回调() { if (materialNum < materialNeed) { // 如果材料还不够 攒副本素材(); } } ); } 复制代码
如果能把这个异步的过程直观地表达出来就好了。Promise和async await语法就是为此而存在的。
(回调的问题不仅仅在于它不能把异步逻辑表示得直观易懂,它还有很多缺点,《你不知道的JavaScript-中卷》第二部第2章“回调”里有很详细的解释)
改写:
async function 攒副本素材() { let materialNum = 0; while (materialNum < 100) { materialNum += await 收集一次副本素材(); } } async function 收集一次副本素材() { return 开一把() .then((res) => (res ? res.material || 0 : 0)) .catch(() => 0); } 复制代码
整个造武器的过程:
async function 造武器() { await Promise.all([攒副本素材(), 攒合成素材(), 练合成技能熟练度()]); 合成武器; } 复制代码
假如这个打造武器的过程用回调来表示,就没那么顺了:
let materialNum = 0; const materialNeed = 100; let compositeSkillLevel = 1; const compositeSkillLevelNeed = 10; let compositeMaterial = 0; const compositeMaterialNeed = 200; function 肝() { if (materialNum < materialNeed && 今天还有刷本次数) { 收集一次副本素材(); } else if (compositeSkillLevel < compositeSkillLevelNeed && 今天还有合成技能使用次数) { 刷一次合成技能熟练度(); } else if (compositeMaterial < compositeMaterialNeed) { 打野怪收集合成材料(); } } function 收集一次副本素材() { 开一把( function 成功回调(res) { materialNum += res.material; if (三样都齐了) { 合成武器; } else { 肝(); } }, function 失败回调() { 肝(); } ); } function 刷一次合成技能熟练度() { 做一次合成任务( function 成功回调(res) { compositeSkillLevel = res.currentSkillLevel; if (三样都齐了) { 合成武器; } else { 肝(); } }, function 失败回调() { 肝(); } ); } function 打野怪收集合成材料() { 刷一波野怪( function 成功回调(res) { compositeMaterial += res.material; if (三样都齐了) { 合成武器; } else { 肝(); } }, function 失败回调() { 肝(); } ); } // 合理地对代码进行复用,可以让由回调来表示的异步流程相对地简洁一点,但读起来还是挺痛苦。 复制代码
所以Promise语法、async await语法一个很重要的作用是把异步的过程表达得像同步操作一样。
async await不用细说了,使用之后,代码看起来跟同步版相差无几。(mdn上关于async await的解释)
那怎么理解Promise在流程控制上起的作用呢?Promise不也接收成功回调和失败回调吗,怎么样用它来把流程表达得“顺”、可读性强呢?
这就要小伙伴们把眼光注意到它的链式调用相关的api上来,Promise实例的then、catch、finally方法都会返回一个新的Promise实例,调用新的实例的then、catch、finally方法又会返回新的实例。就是说,对一个异步任务的结果(成功或者失败都算是一个结果)做一些处理,前面那个任务+这堆处理逻辑,可以被视为一个“更大一点的任务”,而这个“更大一点的任务”同样会有它的完成情况,不管是成功还是失败,我们还可以继续基于这个完成情况往下写逻辑。这样,代码逻辑就不是越套越深,而是越拉越长,像一条链子一样往下走了。
举个例子:
一个Promise可以看作是一个任务。
比方说有个任务p1
p1.then(() => { // bala bala }); // -------------等价于:-------------- await p1 // bala bala 复制代码
p1.then((res) => { // 做一些关于res的操作 }); // -------------等价于:-------------- let res = await p1; // 做一些关于res的操作 复制代码
Promise.then返回的是一个新的Promise,怎么理解这个新的Promise?
1.执行p1任务
2.如果成功则做一些处理;如果失败则做另一些处理(要是then方法里传了第二个参的话就有这半句,否则没有)
新的Promise表示的是:第1行和第2行加起来的执行情况。就是说,第一行算是一个任务,但也可以把第1和第2行结合起来看,当作一个大一点的任务,Promise.then方法返回的就是这个“大一点的任务”的执行情况。
p1.then((res1) => { // 做一些关于res1的操作 return 某个值; }) .then((res2) => { // 做一些关于res2的操作 }); // -------------等价于:-------------- let res1 = await p1; let res2 = await 做一些关于res1的操作(res1); // 做一些关于res2的操作 复制代码
p1.then((res1) => { // 做一些关于res1的操作 }) .catch((err) => { // 错误处理。这里的err可能是p1抛出来的,也可能是p1下面那一环节抛出来的 }); // -------------等价于:-------------- try { let res1 = await p1; // 做一些关于res1的操作 } catch (err) { // 错误处理,这里的err可能是try块里的第一行抛出来的,也可能是try块里的第二行抛出来的 } 复制代码
p1.catch((err) => { // 错误处理 }) .then((res1) => { // 做一些关于res1的操作 }); // -------------等价于:-------------- let res1; try { res1 = await p1; } catch (err) { // 错误处理 } // 做一些关于res1的操作 复制代码
综上,看到一个promise后面跟着一串then catch之类的,可以把then里的代码理解为:跟在上一个任务后面的代码;看到catch里的代码理解为:把这个promise链里前面的环节都包进一个try代码块里,这个try块对应的catch块里的代码。
(finally的情况有点说来话长,容我偷懒先省略一下)
回到上面讲的开发中常见的“先发请求1,请求1结束之后再发请求2”,如果只是希望遮罩表现正常,把f1里的finally改成catch可以让遮罩表现正常
function f1() { // 弹出遮罩 httpApi1(param) .then((res) => { // 根据res做一些数据处理 f2(); }) .catch(() => { // 关闭遮罩 }); } function f2() { // 弹出遮罩 httpApi2(param) .then((res) => { // ... }) .finally(() => { // 关闭遮罩 }); } 复制代码
但是这样的写法显然没把Promise的功能发挥出来,因为从逻辑上来讲,f1和f2算是一先一后两个同层面的步骤。
function f1() { // 弹出遮罩 return httpApi1(param) .then((res) => { // 根据res做一些数据处理 }) .finally(() => { // 关闭遮罩 }); } function f2() { // 弹出遮罩 return httpApi2(param) .then((res) => { // ... }) .finally(() => { // 关闭遮罩 }); } f1().then(f2); // 这样它们就成了Promise链上一前一后两个环节了,而不是嵌套关系 复制代码
在项目里使用的时候,async await和Promise要统一一下,两者只选其一吗?
我觉得倒也不必,只要记住一根Promise链可以被视为一个异步任务,不管它只有一环还是有很多环。而一个异步任务总是可以await它一下,表示要等这个任务有结果了再执行后面的语句。理解这两点后,在日常开发中两者一起用也不会迷糊。
再举个平时开发中见到的例子:
function f() { return new Promise((resolve, reject) => { 某api() .then((res) => { resolve(res.xxx); }) .catch(() => { // bala bala reject(); }); }); } 复制代码
写代码的小伙伴希望这个方法能返回一个Promise,ta希望这个方法能表示一个异步的任务,但忘了Promise的then catch方法都会返回新的Promise,一条Promise链本身就可以视作一个异步任务。
// 不浪费Promise api本身会返回的Promise function f() { return 某api() .then((res) => { return res.xxx; }) .catch(() => { // bala bala throw new Error(); }); } 复制代码
关于Promise异步流程控制语句的理解,我能分享的就是这些了,有说得不对的请大家指正。
另外据我日常开发的感受,还有个要注意的小点是:异步任务的结果值,就是await一个异步任务会得到的结果。对这一点理解得不到位也容易掉坑里。这个小话题下次再唠。
最后,祝各位小伙伴们(和我计几)在平时开发中心明眼亮、腰马合一,流程控制拿捏到位,0 bug!
作者:甜甜花酿鸭
链接:https://juejin.cn/post/7028054587902787592