本文写给对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!