先下结论:async/await是生成器(Generator)的应用,是Promise的语法糖,因此要深入了解async/await,就必须深入了解生成器的原理,而在JS底层中生成器是协程的一种实现。如果你不知道什么是Promise或者你不会async/await,那么这篇文章可能不适合你,建议先去学习后再来看。
首先来回顾一下:
一个Promise
实例对象代表一次异步操作,通过调用Promise
对象的then
方法,可以监听到异步操作的成功状态。同理调用Promise
对象的catch
方法可以捕获到异步操作的失败状态,而调用finally
方法则无论异步操作是否成功还是失败都会执行。对于学过前端的小伙伴都知道,Promise对象的出现就是为了解决连续异步操作下的回调地狱问题,然而在使用Promise对象时,仅使用then
、catch
方法还是会不可避免的出现类似的问题,所以在ES2017(ES8)中推出了async/await语法,只需要在Promise
实例对象之前使用await
方法,同时在Promise
实例对象所在函数的function
关键字之前加上async
关键字,就可以直接接收到Promise对象异步操作成功的返回结果,而不需要通过Promise.then()
来获取成功的结果,如下示例:
// 在function前加上async关键字
async function fn() {
// 在Promise实例对象前加上await关键字
const res = await new Promise((resolve, rejected) => {
setTimeout(() => {
console.log("数据");
}, 3000);
});
console.log('res: ', res);
}
fn();
关于更多用法和详细解释可以去看我的笔记文档:async和await
刚才说了async/await是生成器的应用,那么生成器又是什么呢?
要了解生成器,我们就不得不先了解迭代器(遍历器)。
ES6新增了Map
和Set
两种数据类型,而这两个数据类型是可迭代的,加上JS中还有其他很多数据类型支持迭代,为了给这些可迭代的数据类型提供一个统一的接口,就有了迭代器。即迭代器(iterator
)是一种接口,任何数据类型只要部署(实现)了iterator
接口,就可以实现遍历操作。
ES6新增了
for ... of ...
遍历循环的方法,该方法主要提供给iterator
使用。只要一个对象部署了iterator
迭代器,就可以使用for...of...
实现遍历操作。在JS中原生具备
iterator
接口的数据类型有:Array
、arguments
对象、Set
、String
、Map
、TypeArray
、NodeList
。
那么如何实现一个迭代器?迭代器规定:数据结构的Symbol.iterator
方法必须返回一个对象,这个对象中必须要有一个next()
方法,并在对象中定义遍历成员的逻辑。最后这个next()
方法要返回一个包含value
和done
属性的对象。其中value
就是当前遍历到的成员,而done
是一个布尔值代表当前遍历是否结束。
下面是一个例子:
设计一个类Team
,可以在实例化时接收若干参数。有一个members
属性用来存储成员,并将接收到的参数作为成员添加到members
属性中。实例化对象可以使用for...of...
遍历members
属性的成员。
class Team {
constructor(name, ...args) {
this.name = name;
this.members = args;
}
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
if (index < this.members.length) {
return {
value: this.members[index++],
done: false
}
} else {
return { value: undefined, done: true };
}
}
}
}
}
let myTeam = new Team("myTeam", 1, 2, 3, 4, 5);
// console.log(myTeam.members);
for (key of myTeam) {
console.log(key);
} // 依次输出 1 2 3 4 5
迭代器返回的对象中必须包含next()
方法,另外还有两个可选的函数分别是return()
和throw()
,其中return()
方法 主要用途是在for...of...
循环时如果遇到return或者break提前退出循环时,就会被调用。而throw()
方法主要配合生成器来使用,迭代器用不到它。我将在使用生成器实现async
/await
一节中展示它的用法。
OK,点到为止,更加深入的用法大家可以自行学习或者看我的笔记文档:迭代器。既然知道了迭代器,那么生成器又是个啥呢?简单理解就是生成器是基于迭代器实现的一种特殊函数。
生成器是一种特殊的函数,它是一种全新的异步编程解决方案,而它的一大优点就是可以自由控制函数的执行和暂停/继续。在JS中,普通的函数一经调用就只能等到代码遇到return语句或者函数执行到末尾,否则我们没有任何办法随意的控制函数的启停,而生成器却可以做到,语法如下:
function * gen() {
...
yield 语句1;
...
yield 语句2;
...
yield 语句3;
...
}
生成器函数有两大特征:1. function
后面的*
号。2. yield
语句。
生成器函数的返回结果就是一个迭代器,调用next()
方法使函数开始运行。注意,不调用next
方法是不会开始执行函数的。
let iterator = gen();
iterator.next();
其中yield
关键字将函数分割成了几个部分,每调用一次next
方法就会依次执行每一部分的代码,而next()
方法的返回对象中的value
值就是yield
关键字后面的语句的执行结果。
let iterator = gen();
iterator.next();
console.log(iterator.next()); // { value: 语句1, done: false }
生成器函数可以传递实参,也可以在代码块中使用传递进来的形参。
当然生成器函数的next()
方法也可以传递实参,而传递过去的参数就是上一次调用next()
方法执行的yield
语句的返回结果。例如第二次调用next()
方法传递的实参就是第一个yield
语句的返回结果,示例如下:
function* gen() {
console.log(11);
let one = yield 111; // 将next()函数的参数作为yield语句的返回结果
console.log(one); // hello
yield 222;
console.log(33);
yield 333;
}
let iterator = gen();
console.log(iterator.next());
console.log(iterator.next("hello"));
console.log(iterator.next());
// for … of … 遍历生成器函数
for (let v of gen()) {
console.log(v);
}
执行结果:
// 调用第一个next方法
11
{value: 111, done: false}
// 调用第二个next方法
hello
{value: 222, done: false}
// 调用第三个next方法
33
{value: 333, done: false}
// for ... of ... 遍历生成器函数
11
111
undefined
222
33
333
案例
有三个定时器,必须等第一个定时器走完之后才能执行第二个定时器,以此类推。
function one() {
setTimeout(() => {
console.log(111);
iterator.next();
}, 1000);
}
function two() {
setTimeout(() => {
console.log(222);
iterator.next();
}, 2000);
}
function three() {
setTimeout(() => {
console.log(333);
}, 3000);
}
// 定义一个生成器
function* gen() {
yield one();
yield two();
yield three();
}
// 代码从这里开始执行
let iterator = gen();
iterator.next();
调用第一个next
方法开始执行生成器函数中的第一个yield
语句,调用one()
函数,等第一个定时器走完之后,会输出111
,并执行第二个next
方法,从而开始执行生成器函数中的第二个yield
语句,调用two()
函数,以此类推。只有上一个定时器走完之后才会调用下一个next()
方法。
用伪代码实现一个逻辑:先去获取服务器上的用户数据,获取到用户数据之后才开始获取用户的订单数据,获取到订单数据后才能去获取商品数据。注意,后一次获取的数据必须依托于上一次获取到的数据,这是一个先后递进的关系。
// 获取用户数据
function getUsers() {
setTimeout(() => {
let users = "用户数据";
iterator.next(users); // 这个参数会传递给第一个yield语句的返回值
}, 1000);
}
// 获取订单数据
function getOrders(users) {
setTimeout(() => {
let orders = "订单数据";
iterator.next(orders); // 这个参数会传递给第二个yield语句的返回值
}, 1000);
}
// 获取商品数据
function getGoods() {
setTimeout(() => {
let goods = "商品数据";
iterator.next(goods);
}, 1000);
}
function* gen() {
let users = yield getUsers(); // 第一个next方法执行这句代码
if (!users) return; // 如果用户数据为空则停止执行
console.log(users);
let orders = yield getOrders(users); // 调用第二个next方法执行这部分的代码
if (!orders) return;
console.log(orders);
let goods = yield getGoods(); // 调用第三个next方法执行这部分的代码
// 调用第四个next方法执行这部分往后的代码
if (!goods) return;
console.log(goods);
}
let iterator = gen();
iterator.next(); // 第一次调用next方法
执行过程剖析:
1. 第一次调用next()
方法,执行yield getUsers()
,执行完getUsers
函数的异步操作后调用第二个next()
方法,并将用户数据传递给第一个yield
语句的返回值,赋值给users
变量。
2. 调用第二个next()
方法,执行第二个yield
语句往上部分的代码,执行完getOrders
函数的异步操作后调用第三个next()
方法,并将订单数据传递给第二个yield
语句的返回值,赋值给orders
变量。
3. 调用第三个next()
方法,执行第三个yield
语句往上部分的代码,执行完getGoods
函数的异步操作后调用第四个next()
方法,并将商品数据传递给第三个yield
语句的返回值,赋值给goods
变量。
4. 调用第四个next()
方法,执行第三个yield
语句往后的所有代码。
所以最后的执行结果为:一秒钟过后输出用户数据
,两秒钟过后输出订单数据
,三秒钟过后输出商品数据
。
经过上面的学习想必大家已经对迭代器和生成器都有了一定的了解,那么async/await
又是如何实现的呢?
async/await
的作用就是通过同步的方式来执行异步操作,这一点大家应该也能感受到。当同一个函数里面存在多个await语句时,必须要等上一个await语句执行完成才能执行下一个await语句,同时两个await语句之间的代码会在第一个await语句执行完成后一起执行,细心的你可能会发现,这和生成器当中的yield语句怎么这么像呢?
这不是你的错觉!现在来总结一下 async/await
和 生成器yield
的相似之处:
暂停和恢复
await
会暂停async
函数的执行,直到Promise
完成才会恢复。yield
会暂停生成器函数的执行,直到调用next()
方法恢复。
顺序执行
在
async
函数中,多个await
语句会按照顺序执行。在生成器函数中,多个
yield
语句也会按照顺序执行。
代码结构
async/await
的代码结构类似于同步代码,易于阅读和维护。生成器函数也可以通过
yield
实现类似同步代码的结构。
既然已经初窥门径,那何不趁热打铁?我们来看看如何通过生成器函数实现类似async/await
的效果。
为了能成功实现效果,我需要补充说明迭代器返回对象的throw()
方法,其作用是在生成器外部抛出一个异常,然后在生成器内部捕获,原理就是调用迭代器对象的throw()
方法时,和调用next()
方法一样会恢复生成器继续执行,但不同的是throw()
会在生成器内部抛出一个错误,从而使生成器停止执行。
现在我们就可以来实现一个具有async
/await
功能的生成器函数,不过我们需要在外部实现一个工具函数,而这个工具函数正是JS底层中async
/await
实现所引用的co
库:
co 函数库是著名程序员 TJ Holowaychuk 于2013年6月发布的一个小工具,用于 Generator 函数的自动执行。 — 摘自 《阮一峰的网络日志》
简单实现如下:
// co库控制生成器的自动执行
function co(generator) {
const gen = generator(); // result是一个迭代器
function handle(result) {
// 生成器执行结束,生成器的返回值将作为最终结果
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value).then(
res => handle(gen.next(res)),
err => handle(gen.throw(err))
);
}
return handle(gen.next());
}
function* example() {
const result1 = yield task1(); // 类似于await task1()
const result2 = yield task2(); // 类似于await task2()
return result1 + result2; // 这里的计算结果对应了handle函数的if (result.done)语句的result.value
}
function task1() {
return new Promise(resolve => {
console.log(1);
resolve(1);
});
}
function task2() {
console.log(2);
return 2;
}
co(example).then(res => {
console.log("res", res); // res 3
});
上面的代码会依次打印:
1
2
res 3
这里的co
函数其实是在递归调用自身,并且每次会判断生成器函数是否执行完成(next().done
),如果迭代器完成就会将生成器函数的返回值用promise包装并作为最终返回结果,否则就继续用Promise包装next().value
,等待完成后将结果传递给生成器的指定yield
语句,如果报错就调用迭代器的throw()
方法抛出一个异常,而这个异常就体现在生成器函数(被async修饰的function)中。如果这里没理解可以参考前面讲过的生成器案例,多看几遍就懂了。
当然,我们不可忽视的便是co
函数使用了多处的Promise.resolve()
,因为要保证co
函数始终都返回一个Promise,这才符合一个被async
修饰的函数特征。我们不确定yield
(await)后面是不是一个Promise,如果不是就需要通过Promise.resolve()
将其包装成Promise,如果yield
后面是Promise,那就直接通过Promise.resolve(next().value)
返回这个Promise。
如果你能看到这里,那么恭喜你已经基本掌握了async/await
的实现原理。
接下来我将聊一聊生成器到底是如何自由控制函数启停的。正如我在开头说的:“在JS底层中生成器是协程的一种实现”。如果你对技术有追求,一定很想知道吧!
如何理解协程呢?
在计算机系统中,进程和线程的概念更被大家所熟知。一个进程就是一个程序执行的实例,而一个进程中可以有多个线程,每个线程都处理不同的任务并且独立运行。而协程并非计算机系统中的概念,它是一种程序实现,通俗且不恰当的说法就是协程是一种编程思想,它完全由程序实现,因此不受操作系统控制。只是在更多时候都被编程语言在底层实现了,我们并不知晓。
一个线程中可以存在多个协程,但是与线程不同的是,一个线程中同时只能有一个协程在运行。当一个协程启动了另一个协程,那么该协程就是父协程,被启动的协程就是子协程。当父协程执行完后将控制权交给子协程,子协程执行完后再将控制器交给父协程。因此子协程在运行时父协程是被暂停的,这也是为什么生成器能够自由暂停和恢复的原因。图示如下:好吧,协程好像也没有想象中的那么复杂,总结一下:
协程是由程序自主实现,不受操作系统控制。
协程实现成本低,效率高。
协程可以进行异步编程,生成器就是这么实现的。
父协程和子协程之间可以双向通讯,而生成器的yield和next()起到了关键作用。
okk,不知不觉写了这么多呢,篇幅有限就不多说了,大家可以自行拓展学习,如果觉得我说的不对欢迎指出, 当然觉得我讲的不错也可以点个赞哈