async/await 原理
起因
我们知道 Promise 的出现极大地解决了 回调地狱,但是如果使用流程非常复杂的话,就非常容易过多地调用 Promise 的 then() 方法,这样也不利于使用和阅读。
例如: 我希望在请求 www.baidu.com 后先输出请求的结果,再去请求 www.taobao.com 后再输出请求结果,如果只用 Promise 实现,那么代码就是下面的样子:
fetch("https://www.baidu.com")
.then((res) => {
console.log(res);
return fetch("https://www.taobao.com");
})
.then((res) => {
console.log(res);
})
.catch((err) => {
console.log(err);
});这只是去请求两个网站,如果流程一旦多了,那么 then() 方法的调用也是随之增加的,虽然整个过程较为线性,但是代码的阅读性依然很差。所以,在 es7 中提出了 async/await 来让我们能够用同步的方式来实现异步,但是要记住 async/await 只是一个语法糖,其本质依然还是异步的,不过是让我们可以用同步的方式来书写而已。我们可以看一下下面的代码,同样是解决上面的的问题:
async function main() {
try {
const res = await fetch("https://www.baidu.com");
console.log(res);
const res2 = await fetch("https://www.taobao.com");
console.log(res2);
} catch (err) {
console.log(err);
}
}
main();使用 async/await 让代码的书写就和同步一样,并且还可以使用 try catch 来捕获错误。试问一下,这两种书写方式,对于程序员来说会选择哪一个呢?
原理
在我看来,async/await 的使用就好像是把函数暂停了一样,当执行到 await 时,函数暂停执行,直到 await 等待的 Promise 状态改变了,才会回到这个函数执行。这种方式是不是很眼熟?是的,这简直就跟生成器的工作方式一模一样,所以要弄懂 async/await 的原理前,我们要先了解一下生成器是如何工作的。不过在了解生成器之前,我先介绍一下协程这个概念。
协程
协程(coroutine)是一种程序组件,允许在单个线程中执行多个任务。协程允许我们暂停和恢复执行,而无需使用线程切换。协程是轻量级的,因为它们不需要分配单独的线程或堆栈。相反,它们共享相同的线程和堆栈空间。
协程带来的好处就是可以提升性能,协程的切换并不会像线程切换那样过多地消耗资源。了解了协程的存在,我们就可以知道为什么会有生成器了。
生成器
生成器(generator)是一种特殊的函数(就是前面带有*的一个函数),它返回一个迭代器(iterator)。迭代器是一个对象,它允许我们在不使用循环的情况下遍历数据集合。生成器函数使用 yield 关键字来暂停和恢复执行。在外部函数中可以通过调用 next() 方法来恢复函数的执行,当生成器函数被调用时,它返回一个迭代器对象,该对象允许我们在不使用循环的情况下遍历生成器函数生成的数据。
function* generator() {
console.log("step 1");
yield 1;
console.log("step 2");
yield 2;
console.log("step 3");
yield 3;
}
const iterator = generator();
console.log(iterator.next().value);
console.log(iterator.next().value);
console.log(iterator.next().value);执行上述代码,你就会发现这个函数不是立马就被执行完的,而是在分布执行,我们可以分析一下:
执行生成器 generator,此时主线程执行 generator 函数,打印 step 1
执行到 yield 关键字,generator 函数暂停执行,转到外部函数协程,并返回 yield 关键字抛出的值
外部函数协程执行 next()方法,该方法的返回值的 value 属性值就是 yield 抛出的值,打印 1,并切换协程,主线程执行 generator 函数协程。
打印 step 2, 执行到 yield 关键字,generator 函数协程暂停执行,转到外部函数协程,并返回 yield 关键字抛出的值。
外部函数协程执行 next()方法,该方法的返回值的 value 属性值就是 yield 抛出的值,打印 2,并切换协程,主线程执行 generator 函数协程。
打印 step 3, 执行到 yield 关键字,generator 函数协程暂停执行,转到外部函数协程,并返回 yield 关键字抛出的值。
外部函数协程执行 next()方法,该方法的返回值的 value 属性值就是 yield 抛出的值,打印 3,并切换协程,主线程执行 generator 函数协程
generator 函数执行完毕,该协程销毁,转到外部函数协程继续执行代码。
通过上面生成器与协程的分析,想必 async/await 的原理已经要呼之欲出了,那么让我们来正式开始 async/await 的原理揭秘。
async
async 在 MDN 上的定义就是一个通过异步执行并隐式返回一个 Promise 作为结果的函数。 我们需要关注两个地方,异步执行和隐式返回 Promise, 异步执行先放到后面说,对于隐式返回 Promise,我们可以通过代码来一窥究竟。
async function foo() {
return 1;
}
console.log(foo()); // Promise {<resolved>: 1}执行这段代码,我们可以看到调用 async 声明的 foo 函数返回了一个 Promise 对象,状态是 resolved。
await
await 关键字用于等待一个 Promise 对象,它只能在 async 函数内部使用。
我们来通过一段代码来分析 await 到底做了什么
async function foo() {
console.log(1);
const res = await 2;
console.log(res);
}
console.log(3);
foo();
console.log(4);
打印结果:
// 3
// 1
// 4
// 2为什么是 3 1 4 2 呢?明明是先执行了 foo 函数再打印的 4,为什么 4 会在 2 前面呢?这就要提到我们之前说的生成器了,await 正是在这基础上做到的。下面我们站在生成器和协程的角度来看下这段代码是如何执行的。
首先由于 foo 函数被 async 标记过,所以当进入该函数的时候, JavaScript 引擎会保存当前的调用栈等待信息。
全局执行上下文在主线程上执行,我们这里暂且将全局执行上下文叫做父协程,打印 3
foo 函数执行,主线程控制权由父协程转为 foo 函数协程,打印 1
执行到 await 2,在这里做了两件事,第一是新建一个 Promise
let Promise_ = new Promise((resolve, reject) => {
resolve(2);
});在该 Promise 的创建中我们看到在执行器中调用了 resolve 函数,这是 JavaScript 引擎会将该任务放入 Promise 的微任务(何为微任务下篇讲解)队列中,等待执行。
第二是暂停 foo 函数的执行,也就是做了 yield 关键字的事情,切换协程,将主线程的控制权交给父协程,并向父协程返回 Promise 对象。
当父协程恢复执行时,会通过调用返回的 Promise 对象的 then 方法来监听 Promise 的状态,当这个 Promise 状态改变时会再次切换协程,将主线程控制权交给 foo 函数协程。 我们可以通过一段代码进行模拟:
Promise_.then((res) => {
// 因为async返回的是Promise,所以没有next方法,但是内部实现原理是一样的,可以当做参考
foo().next(res);
});父协程在主线程上运行,打印 4
父协程的代码都执行完毕,到达微任务队列的检查点,发现了微任务队列中有 reslove(2) 需要执行,执行这个任务的时候,会执行该 Promise 的 then 方法注册的所有回调函数,也就是上述的代码,这时会再次切换协程,主线程上执行 foo 函数协程,打印 2。
以上就是 async/await 的执行流程。正是因为 await 的存在,才使得 async/await 可以用同步的方式书写出异步代码,同时 await 也使得 async/await 的执行流程变得清晰。
总结
Promise 的出现帮助我们解决了回调地域,但是也带来一个问题————then 链,同样使得代码不易阅读。而 async/await 的出现解决了这个问题,它使得异步代码看起来像同步代码一样,同时它也使得异步代码的执行流程变得清晰极大程度上提高了代码的易读性。而 async/await 的实现离不开生成器和协程的概念,正是通过生成器可以自由地切换协程才使得我们可以暂停和恢复一个函数的执行。同样,浏览器 V8 引擎也做了许多事,内部做了大量的语法封装才使得我们能够使用 async/await 语法糖。