先下结论:async/await是生成器(Generator)的应用,是Promise的语法糖,因此要深入了解async/await,就必须深入了解生成器的原理,而在JS底层中生成器是协程的一种实现。如果你不知道什么是Promise或者你不会async/await,那么这篇文章可能不适合你,建议先去学习后再来看。

首先来回顾一下:
一个Promise实例对象代表一次异步操作,通过调用Promise对象的then方法,可以监听到异步操作的成功状态。同理调用Promise对象的catch方法可以捕获到异步操作的失败状态,而调用finally方法则无论异步操作是否成功还是失败都会执行。对于学过前端的小伙伴都知道,Promise对象的出现就是为了解决连续异步操作下的回调地狱问题,然而在使用Promise对象时,仅使用thencatch方法还是会不可避免的出现类似的问题,所以在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新增了MapSet两种数据类型,而这两个数据类型是可迭代的,加上JS中还有其他很多数据类型支持迭代,为了给这些可迭代的数据类型提供一个统一的接口,就有了迭代器。即迭代器(iterator)是一种接口,任何数据类型只要部署(实现)了iterator接口,就可以实现遍历操作。

  1. ES6新增了for ... of ... 遍历循环的方法,该方法主要提供给iterator使用。只要一个对象部署了iterator迭代器,就可以使用for...of...实现遍历操作。

  2. 在JS中原生具备iterator接口的数据类型有:Arrayarguments对象、SetStringMapTypeArrayNodeList

那么如何实现一个迭代器?迭代器规定:数据结构的Symbol.iterator方法必须返回一个对象,这个对象中必须要有一个next()方法,并在对象中定义遍历成员的逻辑。最后这个next()方法要返回一个包含valuedone属性的对象。其中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

案例

  1. 有三个定时器,必须等第一个定时器走完之后才能执行第二个定时器,以此类推。

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()方法。

  1. 用伪代码实现一个逻辑:先去获取服务器上的用户数据,获取到用户数据之后才开始获取用户的订单数据,获取到订单数据后才能去获取商品数据。注意,后一次获取的数据必须依托于上一次获取到的数据,这是一个先后递进的关系。

// 获取用户数据
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的相似之处:

  1. 暂停和恢复

    • await 会暂停 async 函数的执行,直到 Promise 完成才会恢复。

    • yield 会暂停生成器函数的执行,直到调用 next() 方法恢复。

  2. 顺序执行

    • 在 async 函数中,多个 await 语句会按照顺序执行。

    • 在生成器函数中,多个 yield 语句也会按照顺序执行。

  3. 代码结构

    • 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底层中生成器是协程的一种实现”。如果你对技术有追求,一定很想知道吧!

如何理解协程呢?
在计算机系统中,进程和线程的概念更被大家所熟知。一个进程就是一个程序执行的实例,而一个进程中可以有多个线程,每个线程都处理不同的任务并且独立运行。而协程并非计算机系统中的概念,它是一种程序实现,通俗且不恰当的说法就是协程是一种编程思想,它完全由程序实现,因此不受操作系统控制。只是在更多时候都被编程语言在底层实现了,我们并不知晓。
一个线程中可以存在多个协程,但是与线程不同的是,一个线程中同时只能有一个协程在运行。当一个协程启动了另一个协程,那么该协程就是父协程,被启动的协程就是子协程。当父协程执行完后将控制权交给子协程,子协程执行完后再将控制器交给父协程。因此子协程在运行时父协程是被暂停的,这也是为什么生成器能够自由暂停和恢复的原因。图示如下:
好吧,协程好像也没有想象中的那么复杂,总结一下:

  1. 协程是由程序自主实现,不受操作系统控制。

  2. 协程实现成本低,效率高。

  3. 协程可以进行异步编程,生成器就是这么实现的。

  4. 父协程和子协程之间可以双向通讯,而生成器的yield和next()起到了关键作用。

okk,不知不觉写了这么多呢,篇幅有限就不多说了,大家可以自行拓展学习,如果觉得我说的不对欢迎指出, 当然觉得我讲的不错也可以点个赞哈

自我介绍