阅读 102

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

文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐