ES6之Generator和async

一、概述

Generator和async是ES6提供的新的异步解决方案。

Generator函数可以理解为一个可以输出多个值的状态机。它的返回值是一个遍历器对象(Iterator),每次调用该遍历器的next方法就会输出一个值。当有多个异步操作需要按序执行时,只要在完成一个时调一次next方法即可执行下一个。不过想要自动化执行Generator函数则需要借助一些工具。

async函数则是Generator函数的语法糖,它为Generator函数内置了自动执行器。用async函数写出的异步代码几乎与同步代码没有什么差别,使用async函数,不需要任何外部工具,即可写出格式优雅的异步代码。

总的来说,Generator函数定义了一种新的异步模型,而async函数通过对该模型的再封装,提供了一种优雅的异步解决方案。

下面我们分别对两者展开详细探讨。

二、Generator函数

1. 基本原理

众所周知,在JavaScript中,任何函数最多只能有一个返回值(其实几乎所有的语言都是这样)。如果没有显式地使用return返回一个值,那函数的返回值默认是undefined。

与普通函数相比,Generator函数特别的地方在于,它返回的始终是一个遍历器对象。看下面的简单例子:

function* gen(){
  yield 'Hello';
  yield 'World';
}

let iterator = gen();  
iterator.next(); // {value: ‘Hello’, done: false}
iterator.next(); // {value: ‘World’, done: false}
iterator.next(); // {value: undefined, done: true}

关键字function的后面带了一个*,表示这是一个Generator函数。Generator函数内部可以使用特殊的关键字yield,来规定遍历器每次调用next方法时要返回的值(这里可以使用任何有效的表达式,如异步应用中就常返回一个Promise对象)。

为了说明这个函数的原理,我们用一个普通函数来改写上面的Generator函数:

function gen(){
  let index = 0;
  
  return {
    next(){
      switch(index){
        case 0:
          index++;
          return {value: 'Hello', done: false}
        case 1:
          index++;
          return {value: 'World', done: false}
        default:
          return {value: undefined, done: true}
      }
    }
  }
}

let iterator = gen();  
iterator.next(); // {value: ‘Hello’, done: false}
iterator.next(); // {value: ‘World’, done: false}
iterator.next(); // {value: undefined, done: true}

可以看到,执行过程是完全一样的。Generator函数其实可以视为这类返回遍历器对象的普通函数的语法糖。而yield语句就是在定义遍历器的next方法的输出。

从抽象的角度来说,Generator函数是可中断的,而yield语句就像函数内的断点。第一次调用next方法将从函数首部开始执行,并在遇到第一个yield语句时中断,之后引擎将转而执行其他代码。当再次调用遍历器的next方法时,引擎就从上次中断的位置继续向下执行,遇到一个yield语句后将再次输出表达式的值并中断。该过程不断重复,直到走到return语句或执行到函数末尾。

仍以上面的Generator函数为例:

function* gen(){
  yield 'Hello';
  yield 'World';
}

let iterator = gen();  
iterator.next(); // {value: ‘Hello’, done: false}
iterator.next(); // {value: ‘World’, done: false}
iterator.next(); // {value: undefined, done: true}

语句let iterator = gen()得到了Generator返回的遍历器对象,保存在变量iterator中。

第一次调用该遍历器的next方法,函数将从第一句开始执行。由于第一句就是yield语句yield 'Hello',因此函数直接向外输出字符串’Hello’(实际上输出的是封装后的对象{value: ‘Hello’, done: false},这是遍历器规范决定的),并在该处发生中断。

紧接着我们进行了第二次next调用(在这之间你可以插入任何其他语句,甚至把第二次next调用放在一个异步任务里,这完全取决于你的业务逻辑),这次函数将从上一个yield语句开始继续执行,执行到下一个yield语句yield 'World'时,函数向外输出字符串‘World’,并再次中断。

随后我们进行了第三次next调用,函数从上次的断点处继续执行,由于此时函数已经结束,因此函数直接返回对象{value: undefined, done: true},表示Generator函数执行结束。

为什么要使用yield这个关键字呢?这个要从yield的含义说起。yield的中文解释为“生产,产出”,它表示代码解析到这里需要向外产出一个值。

Generator函数也是因此得名。单词Generator的中文解释为“生成器”,表示它是一个可以“产出”多个值的特殊函数,它的这种功能其实是借助遍历器机制实现的(所以如果你完全掌握了遍历器机制,那么Generator函数其实并不神秘,如果你不了解遍历器,推荐你阅读我之前写的文章ES6之遍历器Iterator)。

注意:按照ES6规范,Generator函数的星号只要求位于function关键字后面,不一定要挨着function,如function *gen()function*gen()也是合法的。我们推荐使用function* gen()的写法,因为星号是用来修饰function关键字的。

2. 使用语法

(1)yield表达式

Generator函数中最重要的语法可能就是yield表达式了,它是Generator函数的中断标志。

从开发者的角度来说,yield连同它后面的表达式,构成了一个yield表达式。js引擎执行到一个含有yield表达式的语句时,会直接输出yield关键字后面表达式的值,然后在此中断。下一次调用next方法时,引擎会从上一次中断的位置继续执行,并把传入next方法的参数作为yield表达式的值。如:

function* gen(){
  let a = 'Hello';
  let b = yield a + 'World';
  return 'getMessage: ' + b;
}

let it = gen();
it.next();   //{value: 'Hello World', done: false}
it.next('123'); //{value: 'getMessage: 123', done: true}

当我们执行let it = gen()时,只是得到了一个遍历器对象。

随后我们调用它的next方法,函数从let a = 'Hello';开始执行,在读取第二行语句时发现它包含了一个yield表达式yield a + 'World',于是引擎中断,输出yield关键字后面的表达式a + 'World'的值。因此我们看到,第一次调用next方法时的返回值是:{value: 'Hello World', done: false},它的value就是表达式a + 'World'的值,done为false表示遍历未结束。

再次调用next方法时我们传入了一个字符串参数’123’,它会作为上个yield表达式的值。也就是说,yield a + 'World'的值为我们传入的字符串’123’。于是js引擎接下来就是在执行这样一个赋值语句:let b = '123'。随后继续向下执行,这时遇到了return语句。遇到return语句表示Generator函数执行结束,js引擎返回return后面表达式的值并结束Generator函数遍历。所以该next调用的返回值就是{value: 'getMessage: 123', done: true},value的值为return表达式的值,done为true表示执行结束。

从js引擎的角度来说,yield表达式还有另外一种理解方式。这种思路的主要思想为,js引擎本身并不真正执行yield语句,而是借助它来生成遍历器对象。类似于开始的那个函数,我们可以构造以下这个对象来实现上述Generator函数:

{
  index: 0,
  next(value){
    switch(index){
      case 0: 
        index++;
        let a = 'Hello';
        return {value: a + 'World', done: false};
      case 1:
        index++;
        let b = value;
        return {value: 'getMessage: ' + b, done: true};
      default: 
        return {value: undefined, done: true};
    }
  }
}

实际上有个这个遍历器对象后,Generator函数的作用已经结束了!我们不用再考虑yield语句,也没有什么中断逻辑。我们唯一在做的就是连续调用这个遍历器对象的next方法,每次调用会导致index加一,于是下次函数就会走到下一个case分支。如果我们的Generator函数有非常多的yield表达式,只需要在switch内增加对应数量的case语句,语句的内容就是两个yield表达式中间的语句。

说到这里,yield表达式的原理是不是就更简单了?实际上它对引擎来说只是一个标记,界定了遍历器的next方法每次需要执行的代码范围。js引擎顺序解析Generator函数,通过yield表达式设定每步next方法的行为,Generator函数的功能就结束了。

yield表达式本质上就是一个表达式,所以你可以将它以任意的形式使用,不过当它被放在另一个表达式中时,请添加圆括号。

function* gen(){
  console.log('Hello ' + (yield));
}

let g = gen();
g.next(); //{value: undefined, done: false}
g.next('peter'); //控制台输出"Hello peter"
				 //表达式的值为{value: undefined, done: true}

此时如果yield表达式不带圆括号就会报错。

(2)yield*表达式

yield*语句的主要目的是在一个Generator函数中调用其他Generator函数。比如现在有两个Generator函数:

function* gen1(){
  yield 1;
  yield 2;
}

function* gen2(){
  for(let item of gen()){
    console.log(item);
  }
  yield 3;
  yield 4;
}

for(let item of gen2()){
  console.log(item);
}
//输出:1 2 3 4

我们需要在gen2内部调用gen1,必须通过手动调用for … of循环来实现,这对多层的Generator函数嵌套很不方便。为此ES提供了yield*语法,它可以看作是for … of语句的语法糖。即:

yield* gen()
//等价于
for(let value of gen()){
  yield value;
}

所以yield*语法跟手动实现的for … of没有什么差别,只是写起来要简单很多。另外,嵌套的Generator函数也可以展开成一个Generator函数,即:

function* gen1(){
  yield 1;
  yield 2;
}

function* gen2(){
  for(let item of gen()){
    console.log(item);
  }
  yield 3;
  yield 4;
}

//等价于

function* gen2(){
  yield 1;
  yield 2;  //这两行是从gen1复制过来的结果
  
  yield 3;
  yield 4;
}

也就是说,yield*后面跟一个Generator函数的返回值就相当于直接把这个Generator函数的代码“复制进来”。

本质上,yield*就是在调用它后面跟的那个对象的Iterator接口,因此它可以遍历任何具有Iterator接口的对象。如:

yield* [1, 2, 3]
//等价于
yield 1;
yield 2;
yield 3;

引擎一旦解析遇到yield*表达式,就会调用它后面的对象的Iterator接口,因此只有实现了Iterator接口的对象才可以使用yield*表达式。

(3)for … of循环

Generator函数可以无缝使用for … of循环,这是因为调用Generator函数时返回的就是一个遍历器,而for … of循环本身就是用来“消费”遍历器的。比如下面的例子:

function* gen(){
  yield 1;
  yield 2;
  yield 3;
  return 4;
}

for(let n of gen()){
  console.log(n)
}
// 1 2 3

需要特别注意的是,这里并没有输出return返回的值4,这是为什么呢?

这是for … of的语法特性导致的,它只会在当调用遍历器的next方法返回的对象的done属性为false时才会执行循环。而gen函数依次调用next方法时的返回值如下:

let it = gen();
gen.next();  //{value: 1, done: false}
gen.next();  //{value: 2, done: false}
gen.next();  //{value: 3, done: false}
gen.next();  //{value: 4, done: true}

前三次返回值的done属性都是false,因此会执行循环体。第四次由于是return语句的输出,因此引擎认为Generator函数执行结束,返回的done属性为true。于是for … of语句就直接跳出了循环,没有调用console.log进行输出。

普通的Iterable对象没有出现这个现象是因为,它们必须多遍历一次才能知道是否输出完毕,比如:

let arr = [1,2,3];
let it = arr[Symbol.iterator]();  //获取arr的遍历器

it.next();  //{value: 1, done: false}
it.next();  //{value: 2 done: false}
it.next();  //{value: 3, done: false}
it.next();  //{value: undefined, done: true}

注意,尽管数组只有三个值,但是调用了四次next方法才得到done为true的输出。根本原因是,引擎在输出每个元素时不会去检查它是否为最后一个元素,只有当某次输出时发现已经没有值了,才会确定输出完毕。这种机制使引擎避免了很多次不必要的检查,对for … of的性能有很大提升。

(4)return和throw

关于return,我们上面已经提到了一点,它可以直接结束Generator函数的执行,但是会导致for … of语句无法输出return后面的值。如果最后一个值对你也很重要,请使用yield语句来输出它。如:

function* gen(){
  yield 1;
  
  return 2;
}
//替换为
function* gen(){
  yield 1;
  
  yield 2;
}

另外return也是Generator返回的遍历器原型上的一个原型方法,如:

let g = gen();
g.return(1);  //{value: 1, done: true}

这如同人为在中断处插入了一个return语句,因此会导致Generator函数调用提前结束。

throw方法用于抛出异常,它是Generator函数返回的遍历器对象的原型方法。如:

function* gen(){
  try {
    yield;
  } catch (e){
    console.log("内部:" + e);
  }
}

let g = gen();
g.next();   //现在Generator函数走到第一个yield表达式

try {
  g.throw('a');  //相当于g.next( throw 'a' ),抛出的异常被传递给了Generator函数
  g.throw('b');  //同上,但是由于Generator函数已经执行完毕,这个异常只能在外部捕获
} catch(e){
  console.log('外部', e)
}

//内部:a
//外部:b

所以g.throw(e)可以看做是g.next( throw e )的语法糖。

3. Generator函数的异步应用

上面我们所介绍的都是Generator函数的基本原理和语法。从语言的角度来说,它是js中状态机(可中断,可记录状态)的一种很好的实现(Promise也是一种状态机,但是Generator函数比Promise更加灵活,它可以多次中断),这使得它可以很好地用于处理异步应用。

假设我们现在需要按次序读取三个文件(比如第一个文件存储着第二个文件的路径,第二个文件存储着第三个文件的路径,这时你就必须在前一个文件读取成功后才能读取下一个文件)。由于读取文件是个很耗时的操作,我们不可能采取同步的方式让引擎一直等待文件读取,于是我们采用异步的方式来读取这三个文件。

具体的执行过程是,当引擎发出第一个读文件请求后,就转而去执行其他的代码(在浏览器环境下,读文件是由浏览器层通过发送http请求来完成的,与js引擎无关)。一旦文件读取完毕,引擎会重新回到该处继续执行,从读取到的文件中解析第二个文件的路径,接着发送第二个读文件请求,依次类推。

最初我们使用回调函数的方式来实现上述过程,这也是浏览器提供的最基本、最重要的异步机制。但是后来我们发现,随着异步应用规模的增大,代码嵌套导致程序的可读性变差,可维护性大大降低,形成了所谓的“回调地狱”。

后来ES6推出了Promise语法来解决“回调地狱”,它将嵌套的异步调用改写成了链式结构(感兴趣的话可以参考我之前的前端异步方案之Promise(附实现代码)一文),同时对回调函数方案进行了一定的功能增强。Promise本质上就是一个状态机,不过它只能算一个单状态机,因为它的状态只是能从“pending”转为成功或者失败。由于一个Promise对象只能维护一个异步操作的状态,所以当有多个异步操作时,就必须通过链式语法来实现。比如像下面这样:

let p = new Promise(function(resolve, reject){
  ...
}).then(function(value){
  return new Promise(function(resolve, reject){
    ...
  })
}).then(function(value){
  return new Promise(function(resolve, reject){
    ...
  })
}).then(function(value){
  ...
})

诚然,这种结构比“回调地狱”容易让人接受得多,但是它带来了不少的冗余代码,毕竟那么多的then方法,也会让人看上去头皮发麻。另外,Promise并没有实现对多个异步操作的封装,它只是“把这些异步操作串在一根绳子上,却没有封在一个黑盒里”,因此以面向对象的角度来说,这不是一个很好的解决方案。

当出现了Generator函数后,我们发现,它与异步应用是完美契合的。我们想一下,Generator函数的特点是,可以在每个yield语句处中断,产生一次输出和输入,并且可以记录该中断位置,下一次可以从该位置继续向下执行。是不是和多个异步操作按序执行的要求如出一辙?

不过其实真正用Generator函数来封装异步操作并没有我们想象的那么美好。Generator函数没有自动执行的能力,它需要在Promise的then方法中一步步驱动执行。比如下面的例子:

let fetch = require('node-fetch');  //这是node环境下的模块,类似于ajax、axios等

//这个函数就是我们封装的两个需要按序执行的异步任务
function* gen(){  
  let page1 = yield fetch("https://www.baidu.com");
  console.log(page1);
  let page2 = yield fetch("https://www.csdn.net");
  console.log(page2);
}

//接下来我们要执行这两个异步任务
let g = gen();
let promise1 = g.next();  //函数执行到第一个fetch语句,去取百度首页

promise1.then(function(data){
  let promise2 = g.next(data);  //继续驱动Gnenerator函数执行,取csdn首页,
  								//并将上个异步操作的结果回传到Generator函数
  promise2.then(function(data2)){
    g.next(data2);   //继续驱动Generator函数执行
  }
})

函数gen是我们封装的异步任务,可以看到,除了使用了yield语句外,已经与同步代码没有任何差别,代码逻辑非常容易理解。不过函数的执行过程,难免让人觉得有点“糟心”。

这里的fetch方法返回的其实是一个Promise对象,通过向它注册then方法,我们可以知道fetch任务何时执行完。一旦fetch任务执行完了,我们就驱动Generator函数执行下一个异步任务。好吧,我们封装了近乎完美的异步逻辑,却败给了糟糕的驱动逻辑!

实际上如果异步请求的返回结果不是Promise对象,那么Generator函数的执行将举步维艰。关于如何自动执行Generator函数,有两个非官方的解决方案:Thunk函数和co模块,如果感兴趣可以参考阮一峰 Generator函数的异步应用,这里不再详解。我们直接来看ES工作组给出的解决方案:async函数。

三、async函数

1. 基本原理

上面已经提到,Generator函数可以很好地将异步操作封装成接近同步操作的形式。但是封装归封装,如果函数的执行仍然需要依赖回调或者链式语法,那这种封装仍然很难说完美。

不过如果是下面这样的代码呢?

let fetch = require('node-fetch');

async function gen(){
  let page1 = await fetch("https://www.baidu.com");
  console.log(page1);
  let page2 = await fetch("https://www.csdn.net");
  console.log(page2);
  return page2;
}

gen(); //你只需要这一行代码,就可以执行两个异步任务

对比Generator函数的实现,这里的gen只是把function关键字后面的星号*替换成了前面的async关键字,然后把函数中的yield关键字替换成了await关键字 – 仍然保留了接近同步代码的形式。最不可思议的是,你可以用真正的同步方式来调用这个async函数!

只需要gen()这一行代码,你就可以驱动函数开始执行。函数执行到第一个await语句时将发送获取百度首页的fetch请求,随后引擎将转而执行其他代码。等fetch请求成功后,引擎将自动驱动gen函数向下执行,将fetch获取到的数据保存在page1中,随后执行console.log(page1)语句,接着再发送第二个获取csdn首页的请求。随后又遇到第二个await语句,于是发送第二个fetch请求,并转而执行其他的代码。等第二个请求成功后重新回到gen函数,接着向下执行。

几乎完全接近同步代码的书写方式,以及最简单的调用方式!

显而易见,async函数有着很好的语义。在function前面加上async意味着这个函数内包含了异步操作(async的中文释义就是“异步”),在异步操作前面加await关键字表示需要等待该操作执行完成(await的中文释义为“等待”)。所以如果你有一定的英语基础,那么不用任何人解释,你也应该可以看出这个函数在做什么(之前我在写C#代码时其实也用过async函数,用法跟js中的完全一致,所以我怀疑js的async语法是不是从C#抄的,哈哈)。

实际上async返回的也是一个Promise对象,从这个Promise对象你可以获取多个异步操作的最终结果。从字面上来看,async返回的只是一个普通的值(如上面所返回的变量page2),但是引擎会默认将其封装成Promise,并且将返回值作为then方法的参数传进去,因此你可以像下面这样为async函数注册回调函数:

gen().then(value => {
  ...
})

我们在这里为其注册了then方法,引擎就会把page2作为参数传递给then方法,也就是这里的value,于是我们就可以在async函数全部执行完毕后对结果执行某些操作。同理,如果async函数抛出错误,会被注册的catch回调所捕获。

2. 语法规范

(1)await命令

await命令后面一般会跟一个异步调用,并且这个异步调用的返回值一般是Promise对象。当异步调用执行成功,Promise对象的状态就会变成成功,这样引擎就会自动向下执行async函数。

如果await后面返回的不是一个Promise对象,但是个带有then方法的对象(这样的对象称为thenable对象,它具有和Promise类似的能力),引擎会直接将其按照Promise的规则来处理。如果既不是Promise对象,也不是thenable对象,那么引擎会将其视为一个立即resolved的Promise对象,并直接返回这个值,如:

let a = await 123;
//等价于
let a = 123;

这里123只是个普通的数值,它会被当做一个已经resolved的Promise,并且结果是数值123。实际上你可以认为,在这样的变量前加await关键字会被无视,引擎会当做没有“看到”await关键字而顺序向下执行。

(2)错误处理

如果async中的某个异步操作抛出了异常,那么就等同于整个async函数失败,这会导致async函数返回的Promise对象触发reject,async函数会立即结束执行,并触发我们为async函数的返回的Promise注册的onreject。如:

async function f() {
  //这个Promise抛出的异常导致整个async函数被reject
  await new Promise(function (resolve, reject) {
    throw new Error('出错了');
  });
}

f()
.then(v => console.log(v))
.catch(e => console.log(e))  //捕获到异常后立即执行了这里的catch
// Error:出错了

如果你不希望某个异步操作的失败导致整个async函数直接退出,可以把这个异步操作放在try … catch代码块中,这样当操作失败后,async还可以继续执行。

需要注意的是,多个await语句是同步触发的,也就是说,在前一个await对应的异步操作完成之前,后一个是不会执行的。这对于需要按序触发的异步操作来说很重要,但是如果这些await操作之间不存在继发关系,使用async函数就会导致程序的性能下降。这时请使用Promise.all或Promise.race来封装多个异步任务,以提升程序执行性能。总的来说,async函数的目的是封装多个存在继发关系的异步任务,如果不存在继发关系,请尽量避免使用async函数。

关于async函数是如何实现的,这里我们就不再详解,感兴趣的可以参考阮一峰 async异步函数。本质上来说,async函数就是为Generator函数内置了一个自动执行器,使得函数的自动执行变得更简单。

总结

Generator函数是ES6中一个全新的概念,它是一个借助Iterator实现的状态机。有了Generator函数后,异步应用完全可以以接近同步应用的代码逻辑来实现,不过Generator函数的问题在于不便于自动执行。为此,官方在ES2017中推出了async函数,通过为Generator函数内置自动执行器来解决这个问题。

目前Generator函数和async函数在浏览器环境下的使用还不是很广泛(主要是兼容性问题),不过在nodejs环境下它已经得到广泛应用。一方面,nodejs是服务端的JavaScript环境,它包含了大量的异步操作;另一方面,升级nodejs版本要远远比升级浏览器版本简单,因此受兼容性的影响很小。鉴于nodejs在前端技术栈中的地位越来越高,并且现代浏览器正在快速普及,掌握Generator和async函数这两大利器势在必行。本文只是对两者的简介,希望感兴趣的可以深入研究。

发布了37 篇原创文章 · 获赞 90 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/103570526
今日推荐