Javascript异步详解(三)-Generator

1,493 阅读6分钟

1. 概述

在上一个章节里,我们讨论了如何通过Promise范式解决两个回调函数的重要问题:

1. 由于控制反转,回调并不是可信任或可组合的。

2. 基于回调的异步不符合大脑对任务步骤的规划方式

可以说Promise很好的解决了第一个问题,但是对于第二个问题,Promise是通过不断的链式调用一定程度上的优化了回掉函数的地狱回调的问题。但是不断的链式调用依然不是大脑最好理解的任务的执行方式,所以ES6提出 Generator 生成器,通过一种看似同步的方式来控制异步流程的方法。

2. Generator语法

执行generator生成器的时候会返回一个迭代器对象,通过迭代器对象可以来控制生成器函数的执行(暂停或执行)。generator在形式上是一个特殊的函数,与普通函数不同的是generator函数是:

1. 会在函数名前面加上一个* 号

2.函数体内可以用yield表达式来定义不同的内部状态

var x=1;

function *foo() {
  x++;
  yield; //暂停
  console.log('x: ', x);
}

function bar() {
  x++;
}

var it = foo(); //it是foo的迭代器
it.next(); // x = 2
bar();  //x=3
it.next(); //x: 3

(1)it = foo()运算并没有执行生成器*foo(),而只是构造了一个迭代器(iterator),这个 迭代器会控制它的执行。后面会介绍迭代器。

(2) 第一个 it.next() 启动了生成器 *foo(),并运行了 *foo() 第一行的 x++。

(3) *foo() 在 yield 语句处暂停,在这一点上第一个 it.next() 调用结束。此时 *foo() 仍在运行,但处于暂停状态。

(4) 我们查看 x 的值,此时为 2。

(5) 我们调用 bar(),它通过 x++ 再次递增 x。

(6) 我们再次查看 x 的值,此时为 3。

(7) 最后的 it.next() 调用从暂停处恢复了生成器 *foo() 的执行,并运行 console.log(..)语句,这条语句使用当前 x 的值 3。 

3. 生成器异步编程

利用生成器异步编程的方式叫做协程,它的运行方式大致如下:

  • 第一步,协程A开始执行。
  • 第二步,协程A执行到一半,进入暂停,执行权转移到协程B
  • 第三步,(一段时间后)协程B交还执行权。
  • 第四步,协程A恢复执行。

一个异步读取文件的协程抽象如下所示:

function* asyncJob() {
  // ...其他代码
  var f = yield readFile(fileA);
  // ...其他代码
}

其关键点在于yield表达式,它暂停了asyncJob并把执行权交给了readFile函数 ,等到readFile函数执行完成之后再从这里开始执行后续的代码。

下面开个用generator函数来实现协程的例子:

function *gen(x) {
  var y = yield x + 2;
  return y;
}

var g = gen(1);
g.next() // { value: 3, done: false }
g.next() // { value: undefined, done: true }

上述代码中,gen是一个生成器函数,g是对应的迭代器,迭代器调用next方法后会开始执行gen函数,并执行到yield处暂停,这时y=3,再次调用next函数继续将gen函数执行完成。

生成器异步编程还有个好处是同步的异常处理,生成器 yield 暂停的特性意味着我们不仅能够从异步函数调用得到看似同步的返回值,还可以同步捕获来自这些异步函数调用的错误。 

function *main() {
    var x = yield "Hello World";
    yield x.toLowerCase(); // 引发一个异常!
}
var it = main();
it.next().value;
try {
  it.next( 42 );
} catch(err) {
  console.log(err);
}

4.生成器+Promise

生成器的优势在于可以用看似同步的方式来控制异步流程,而Promise解决异步调用的信任问题,将两者结合起来能取长补短。

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

function* gen(){
  var url = 'https://api.github.com/users/github';
  var result = yield fetch(url);
  console.log(result.bio);
}

var g = gen();
var result = g.next();

result.value.then(function(data){
  return data.json();
}).then(function(data){
  g.next(data);
});

上述例子中 fetch函数是一个Promise的方法实现,其运行方式是:

  • 定义generator函数
  • 生成迭代器g,执行g.next()会执行gen函数,并在yield处暂停将控制权交给fetch函数
  • fetch函数是一个Promise,接收到异步执行完成信息后会执行then方法中挂载的回调函数
  • 再次执行next函数完成gen函数后续操作。

5.async函数

ES2017 标准引入了 async 函数,使得异步操作变得更加方便。async函数其实就是生成器的语法糖,使用async函数可以使生成器异步编程更为简洁。

如果我们要用生成器实现一个异步读取文件的代码:

const fs = require('fs');

const readFile = function (fileName) {
  return new Promise(function (resolve, reject) {
    fs.readFile(fileName, function(error, data) {
      if (error) return reject(error);
      resolve(data);
    });
  });
};

const gen = function* () {
  const f1 = yield readFile('/etc/fstab');
  const f2 = yield readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

如果我们使用async函数来实现:

const asyncReadFile = async function () {
  const f1 = await readFile('/etc/fstab');
  const f2 = await readFile('/etc/shells');
  console.log(f1.toString());
  console.log(f2.toString());
};

可以看出在async函数编程模式里async就相当generator中的*,而await关键字则类似于generator中的yield。那么async函数在generator的基础上做了什么呢?主要是以下几点:

(1)内置执行器。

Generator 函数的执行必须靠执行器,所以才有了co模块,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。

asyncReadFile();

上面的代码调用了asyncReadFile函数,然后它就会自动执行,输出最后结果。这完全不像 Generator 函数,需要调用next方法,或者用co模块,才能真正执行,得到最后结果。

(2)更好的语义。

asyncawait,比起星号和yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。

(3)更广的适用性。

co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时会自动转成立即 resolved 的 Promise 对象)。

(4)返回值是 Promise。

async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。

6.总结

生成器是 ES6 的一个新的函数类型,它并不像普通函数那样总是运行到结束。取而代之 的是,生成器可以在运行当中(完全保持其状态)暂停,并且将来再从暂停的地方恢复运行。 

在异步控制流程方面,生成器的关键优点是:生成器内部的代码是以自然的同步 / 顺序方式表达任务的一系列步骤。其技巧在于,我们把可能的异步隐藏在了关键字 yield 的后面, 把异步移动到控制生成器的迭代器的代码部分。

换句话说,生成器为异步代码保持了顺序、同步、阻塞的代码模式,这使得大脑可以更自然地追踪代码,解决了基于回调的异步的两个关键缺陷之一。 

async函数则是ES2017提供的基于generator的语法糖,对于generator函数进行了增强。