本文已同步至同名 微信公众平台 以及同名 掘金平台 。 由于本站不做SEO, 故可通过前往平台阅读以达到推荐此文的目的。

前言

本文为本人最近在学习前端过程中遇到问题后,结合已有文章转载+整理后的回答方案,参考文章及作者在文末标明。

如果您也是想尝试学习使用下前端技术的服务端工程师, 千万不要像我似的先入为主的把前端的异步概念当作并发哈哈。

因为前端编程中的异步概念, 更类似于我们c语言编程中使用的epoll这种IO多路复用的轮询机制,同样类似go语言中的用来轮询通道(channel)的select之类的概念。

那么接下来,和我一起跟着本文有浅入沉的交流学习下,前端编程中的异步及其运行机制吧。

async和await 概念

先从字面意思来理解。async 是“异步”的简写,而 await 可以认为是 async wait 的简写。所以应该很好理解 async 用于申明一个 function 是异步的,而 await 用于等待一个异步方法执行完成。

另外还有一个很有意思的语法规定,await 只能出现在 async 函数中。然后细心的朋友会产生一个疑问,如果 await 只能出现在 async 函数中,那这个 async 函数应该怎么调用?

如果需要通过 await 来调用一个 async 函数,那这个调用的外面必须得再包一个 async 函数,然后……进入死循环,永无出头之日……

再来说说async有什么作用。

async的作用

这个问题的关键在于,async 函数是怎么处理它的返回值的!

用return吗?那await做什么呢?试一下。

看结果,我们知道返回的是一个promise对象

所以我们从中知道,async 函数返回的是一个 Promise 对象。async 函数(包含函数语句、函数表达式)会返回一个 Promise 对象,如果在函数中 return 一个直接量,async 会把这个直接量通过 Promise.resolve() 封装成 Promise 对象。

async 函数返回的是一个 Promise 对象,所以在最外层不能用 await 获取其返回值的情况下,我们当然应该用原来的方式:then() 链来处理这个 Promise 对象,试一下

输出结果如下

如果async函数没有返回值会报错吗?该返回什么?

输出结果如下

不会报错,直接返回undefined。

在没有 await 的情况下执行 async 函数,它会立即执行,返回一个 Promise 对象,并且,绝不会阻塞后面的语句。这和普通返回 Promise 对象的函数并无二致。

那await是做什么用的?

可以认为 await 是在等待一个 async 函数完成。await 等待的是一个表达式,这个表达式的计算结果是 Promise 对象或者其它值(换句话说,就是没有特殊限定)。

因为 async 函数返回一个 Promise 对象,所以 await 可以用于等待一个 async 函数的返回值——这也可以说是 await 在等 async 函数,但要清楚,它等的实际是一个返回值。注意到 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果,所以,await 后面实际是可以接普通函数调用或者直接量的。找一个例子试试看。

输出结果如下

await等到结果之后呢?

await 等到了它要等的东西,一个 Promise 对象,或者其它值,然后呢?我不得不先说,await 是个运算符,用于组成表达式,await 表达式的运算结果取决于它等的东西。

如果它等到的不是一个 Promise 对象,那 await 表达式的运算结果就是它等到的东西。

如果它等到的是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。

其实这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。

async/await帮我们做了啥?

先做个简单的比较吧 ⬇

之前已经说明了 async 会将其后的函数的返回值封装成一个 Promise 对象,而 await 会等待这个 Promise 完成,并将其 resolve 的结果返回出来。

举个例子,用 setTimeout 模拟耗时的异步操作,先来看看不用 async/await 会怎么写

在试试async/await

我们看到 setTimeout() 没有申明为 async。实际上,setTimeout() 本身就是返回的 异步 对象(script->整体脚本代码、setTimeout、setInterval、UI交互事件、postMessage、Ajax等 属于异步的宏任务),加不加 async 结果都一样。

又一个疑问产生了,这两段代码,两种方式对异步调用的处理(实际就是对 Promise 对象的处理)差别并不明显,甚至使用 async/await 还需要多写一些代码,那它的优势到底在哪?

async/await的优势在于处理 then

单一的 Promise 链并不能发现 async/await 的优势,但是,如果需要处理由多个 Promise 组成的 then 链的时候,优势就能体现出来了(Promise 通过 then 链来解决多层回调的问题,现在又用 async/await 来进一步优化它)。

假设一个业务,分多个步骤完成,每个步骤都是异步的,而且依赖于上一个步骤的结果。我们仍然用 setTimeout 来模拟异步操作:

先不用async/await:

输出结果

接下来看一下使用async/await后, 代码的可读性:

输出结果

结果和之前的 Promise的 then()链写法 是一样的,但是用了async/await后,这个代码看起来是不是清晰得多,几乎跟同步代码一样

async 基本错误处理

1
2
3
4
5
6
7
async function add(){
// console.log(x); //未定义变量
throw new Error("fail"); //手动抛出异常
}
add().catch((error) = > {
console.log(error);
});

await 错误处理

处理异常的机制常用try-catch块实现。(还有个finally语句在try和catch之后无论有无异常都会执行, 注意: catch和finally语句都是可选的,但在使用try语句时必须至少使用其中之一)

1
2
3
4
5
6
7
8
9
10
11
12
13
async function sum() {
try {
await new Promise(function(resolve, reject){
//throw new Error("error") // throw 并非异步任务, 属于同步范畴, 故其执行结束前,其后面的代码会暂时阻塞
throw "some error" //或者reject("some error") // reject() 代表Promise执行错误时会返回一个带有拒绝原因的Promise对象, 可被catch()函数捕获,但由于其异步的微任务属性,不会阻塞之后的任务执行。
console.log("111222") // 当使用 throw时, 此行由于未经过错误处理,故不会执行。 但是,当使用 reject("some error")时, 由于其属于异步微任务, 则会在此行的当前异步宏任务处理完毕后,才会清空当次宏任务阶段的微任务队列。
})
} catch (err) { // 异步任务中 Promise相关的resolve,reject以及Promise.then catch finally、 MutaionObserver、process.nextTick(Node.js 环境) 等, 都属于微任务。执行于每次处理完当前宏任务时的异步回调中。(Event Loop事件循环机制是主线程中存在一个"任务队列",在异步任务执行队列中,先执行宏任务,然后清空当次宏任务中的所有微任务, 然后进行下一个tick如此形成循环)
console.log(err); // 此处是又一次异步的宏任务了, 会在上一次异步宏任务执行完并处理清空其阶段的微任务后, 才同步执行。
}
console.log("错误已处理");
}
sum();

同样, 也可以效仿async中的错误处理方式, 直接使用异步的.catch()来进行错误处理,如 Promise.catch() (两种写法结果是一致的,看个人习惯)。

1
2
3
4
5
6
7
8
9
10
11
async function sum() {
await new Promise(function(resolve, reject){
//throw new Error("error") // throw 并非异步任务, 属于同步范畴, 故其执行结束前,其后面的代码会暂时阻塞
throw("some error") //或者reject("some error") // reject() 代表Promise执行错误时会返回一个带有拒绝原因的Promise对象, 可被catch()函数捕获,但由于其异步的微任务属性,不会阻塞之后的任务执行。
console.log("111222") // 当使用 throw时, 此行由于未经过错误处理,故不会执行。 但是,当使用 reject("some error")时, 由于其属于异步微任务, 则会在此行的当前异步宏任务处理完毕后,才会清空当次宏任务阶段的微任务队列。
}).catch( (err) => { // 异步任务中 Promise相关的resolve,reject以及Promise.then catch finally、 MutaionObserver、process.nextTick(Node.js 环境) 等, 都属于微任务。执行于每次处理完当前宏任务时的异步回调中。(Event Loop事件循环机制是主线程中存在一个"任务队列",在异步任务执行队列中,先执行宏任务,然后清空当次宏任务中的所有微任务, 然后进行下一个tick如此形成循环)
console.log(err); // 此处是又一次异步的宏任务了, 会在上一次异步宏任务执行完并处理清空其阶段的微任务后, 才同步执行。
})
console.log("错误已处理");
}
sum();

下面, 粘上执行结果的图片, 来证实下注释中的内容

首先是使用 throw 时的执行结果

然后是使用 reject 时的执行结果

看到了执行结果, 可以验证注释中的正确性, 下面粘上执行结果中的其他相关代码, 同样通过注释解释为何sum()的执行结果会穿插在doIt()的执行结果中间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
function num(e:number){
return new Promise(resolve => { // 异步微任务 (具体原因请再次阅读sum()函数中的注释)
setTimeout(() => resolve(e+200),e)
})
}

async function text1(e:number){ // 异步宏任务 (由于其不在异步微任务范畴中)
console.log(`text1 with ${e}`) // 异步宏任务中需要同步从上到下执行的代码(宏任务中的同步代码执行完毕后(直到遇到一个微任务及代表当前宏任务完毕), 清空当前宏任务下的异步微任务)
return await num(e) // num()中存在异步微任务 (需要清空的异步微任务)
}

async function text2(e:number){ // 同text1
console.log(`text2 with ${e}`)
return await num(e)
}

async function text3(e:number){ // 同text1
console.log(`text3 with ${e}`)
return await num(e)
}

async function doIt(){ // 异步宏任务 (由于其不在异步微任务范畴中)
console.time("doIt")
const time1 = 300
text1(time1).then(time2 => text2(time2 as number)).then(time3 => text3(time3 as number)).then(result => {console.log(`result is ${result}`); console.timeEnd("doIt")})
}

doIt() // 异步宏任务 (执行过程中, 遇到一个微任务,代表一个宏任务段, 处理完当前异步宏任务中的同步代码后,需要清理其宏任务段下的所有微任务;而text1()函数中num为第一个宏任务段的微任务,且由于text2与text3的参数需要顺序依赖与text1的结果,故此时doIt函数仅能识别至此, 而随后的sum宏代码段可以直接识别, 所以进入下一个宏任务段的识别时, sum所代表的异步宏任务段 会 先于 内部暂时未能直接识别的text2,text3宏任务段 向任务队列 注册 )

async function sum() {
await new Promise(function(resolve, reject){
//throw new Error("error")
reject("some error") //或者reject("some error") // reject() 代表Promise执行错误时会返回一个带有拒绝原因的Promise对象, 可被catch()函数捕获。
console.log("111222") // 当使用 throw时, 此行由于未经过错误处理,故不会执行。 但是,当使用 reject("some error")时, 由于其属于异步微任务, 则会在此行的当前异步宏任务处理完毕后,才会清空当次宏任务阶段的微任务队列。
}).catch( (err) => { // 异步任务中 Promise相关的resolve,reject以及Promise.then catch finally、 MutaionObserver、process.nextTick(Node.js 环境) 等, 都属于微任务。执行于每次处理完当前宏任务时的异步回调中。(Event Loop事件循环机制是主线程中存在一个"任务队列",在异步任务执行队列中,先执行宏任务,然后清空当次宏任务中的所有微任务, 然后进行下一个tick如此形成循环)
console.log(err); // 此处是又一次异步的宏任务了, 会在上一次异步宏任务执行完并处理清空其阶段的微任务后, 才同步执行。
})
console.log("错误已处理");
}
sum();

总结

async 和 await 的实质

Promise一般用来解决层层嵌套的回调函数

async/await 是消灭异步回调的终极武器

JS还是单线程,还是得有异步,还是得基于 event loop(轮询机制)

async/await 只是一个语法糖

Event Loop事件循环机制

同步任务

使用async以及默认为异步任务的除外, 都属于同步任务。
执行: 代码从上到下顺序执行

异步任务

  • 宏任务
    script->整体脚本代码、setTimeout、setInterval、UI交互事件、postMessage、Ajax等 属于异步的宏任务script->整体脚本代码、setTimeout、setInterval、UI交互事件、postMessage、Ajax等 属于异步的宏任务
    执行: 宏任务内部代码段会像同步任务一样由上到下顺序执行(使用await或是默认为异步任务的除外)

  • 微任务
    异步任务中 Promise相关的resolve,reject以及Promise.then catch finally、 MutaionObserver、process.nextTick(Node.js 环境) 等, 都属于微任务。
    执行: 执行于每次处理完当前宏任务时的异步回调中。

运行机制
Event Loop事件循环机制是主线程中存在一个”任务队列”,在异步任务执行队列中,分运行阶段按识别出的宏任务段依次执行,宏任务段中,先执行宏任务,然后清空当次宏任务中的所有微任务, 然后进行下一个tick(宏任务段)如此形成循环。

如何区分宏任务段: 即宏任务的代码段中,从上到下,从外到内, 若遇到第一个微任务,即找到了一个宏代码段中需要执行的所有的宏任务,然后分析除了遇到的那第一个微任务外,其后是否存在其他微任务,全部搜寻完毕后即找全了一个代码段->这些宏任务和其下存在的所有微任务共同组成一个代码段。

注意: 若靠前的宏代码段,需要依赖其他宏代码段的结果,需分运行阶段依次往后地向任务队列注册,永远保证现阶段可以直接识别的宏代码段的优先注册原则。

参考

[1] async和await - 呆瓜瓜瓜

[2] async与await - LLiYYa

[3] 学习Vue3 第三十五章(Evnet Loop 和 nextTick)- 小满zs