Generators 简介

Generator 函数是 ES6 提供的一种异步编程解决方案,是一个生成器,用于生成一个遍历器的函数,语法行为与传统函数完全不同。

Iterator 遍历器

JavaScript 原有的表示 “集合” 的数据结构,主要有 ArrayObject,在 ES6 中又加入了 SetMap,这样就有了四种数据集合,还可以组合使用它们,如数组的成员是 MapObject,这样就需要一种统一的接口机制,用来处理所有不同的数据结构。

遍历器 Iterator 就是这样一种机制,它是一种接口,为不同的数据结构提供统一的、简便的访问机制,任何数据结构只要部署了 Iterator 接口,就可以完成遍历操作,即依次处理该数据结构的所有成员。

Iterator 遍历器其实就是一个指针对象,上面有 next 方法,第一次调用 next 指针指向数据结构的第一个成员,第二次 next 调用指针指向第二个成员,直到指针指向最后一个成员。

我们可以使用 ES6 的展开运算符 ...for...of... 去遍历带有 Iterator 接口的数据结构,需要注意的是,Object 本身不具备 Iterator 接口,所以我们无法通过 ... 把一个对象扩展到一个数组中,并且会报错,我们可以通过代码手动将 Object 类型实现 Iterator 接口。

/* 给对象扩展 Iterator 接口 */
// 通过 Generator 函数给 Object 扩展 Iterator 接口
Object.prototype[Symbol.iterator] = function* () {
  for (var key in this) {
    yield this[key];
  }
}

// 测试 Iterator 接口
const obj = {
  a: 1,
  b: 2,
  c: 3
};

const arr = [...obj];

console.log(arr); // [1, 2, 3]

上面我们其实是通过 ES6Generator 函数简单粗暴的给 Object 类型实现了 Iterator 接口,后面我们会简单模拟 Generator 生成器。

模拟 Generator

Generator 函数是一个生成器,调用后会返回给我们一个 Iterator 遍历器对象,在对象中有一个 next 方法,调用一次 next,帮我遍历一次,返回值为一个对象,内部有 valuedone 两个属性,value 属性代表当前遍历的值,done 属性代表是否遍历完成,如果遍历完成后继续调用 next,返回的对象中 value 属性值为 undefineddone 属性值为 true,这个遍历器在进行数据遍历时更像给我们提供了一个暂停功能,每次都需要手动调用 next 去进行下一次遍历。

我们根据 Generator 的特性用 ES5 简单模拟一个遍历器生成函数:

/* 模拟遍历器生成函数 */
function iterator(arr) {
  var i = 0;

  return {
    next: function () {
      var done = i >= arr.length;
      var value = !done ? arr[i++] : undefined;

      return {
        value: value,
        done: done
      };
    }
  };
}

测试一下模拟的遍历器生成函数:

/* 测试 iterator 函数 */
var arr = [1, 3, 5];

// 遍历器
var result = iterator(arr);

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

Generator 的基本使用

在普通的函数 function 关键字后加一个 * 就代表声明了一个生成器函数,执行后返回一个遍历器对象,每次调用遍历器的 next 方法时,遇到 yield 关键字暂停执行,并将 yield 关键字后面的值会作为返回对象中 value 的值,如果函数有返回值,会把返回值作为调用 next 方法进行遍历完成后返回的对象中 value 的值,果已经遍历完成,再次 next 调用这个 value 的值会变成 undefined

// 生成器函数
function* gen() {
  yield 1;
  yield 2;
  return 3;
}

// 遍历器
const it = gen();

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

Generator 函数中可以使用变量接收 yield 关键字执行后的返回值,只是接收的值并不是 yield 关键字后面表达式执行的结果,而是遍历器在下一次调用 next 方法时传入的参数。

也就是说我们第一次调用 next 方法进行遍历时是不需要传递参数的,因为上面并没有变量来接收它,即使传参也会被忽略掉,我们用一个例子感受一下这种比较特殊的执行机制:

// 生成器函数
function* gen() {
  const a = yield 1;
  const b = yield a;
  const c = yield b;
  return c;
}

// 遍历器
const it = gen();

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

如果已经遍历完成,并把上次遍历接收到的值作为返回值传递给返回对象 value 属性的值,后面再次调用 next 传入的参数也会被忽略,返回对象的 value 值为 undefined

Generator 函数中,如果在其他函数或方法调用的回调内部(函数的执行上/下文发生变化)不能直接使用 yield 关键字。

/* 循环中使用 yield */
// 错误的写法
function* gen(arr) {
  arr.forEach(*item => {
    yield* item;
  });
}

// 正确的写法
function* gen(arr) {
  for (let i = 0; i < arr.length; i++) {
    yield arr[i];
  }
}

如果在一个 Generator 函数中调用了另一个 Generator 函数,在调用外层函数返回遍历器的 next 方法时是不会遍历内部函数返回的遍历器的。

/* 合并生成器 —— 错误 */
// 外层的生成器函数
function* genOut() {
  yield 'a';
  yield genIn();
  yield 'c';
}

// 内层的生成器函数
function* genIn() {
  yield 'b';
}

// 遍历器
const it = genOut();

it.next(); // {value: 'a', done: false}
it.next(); // 返回 genIn 的遍历器对象
it.next(); // {value: 'c', done: false}
it.next(); // {value: undefined, done: true}

上面代码如果想在调用 genOut 返遍历器的 next 方法时,同时遍历 genIn 调用后返回的遍历器,需要使用 yield* 表达式。

/* 合并生成器 —— yield* */
// 外层的生成器函数
function* genOut() {
  yield 'a';
  yield* genIn();
  yield 'c';
}

// 内层的生成器函数
function* genIn() {
  yield 'b';
}

// 遍历器
const it = genOut();

it.next(); // {value: 'a', done: false}
it.next(); // {value: 'b', done: false}
it.next(); // {value: 'c', done: false}
it.next(); // {value: undefined, done: true}

genOut 返回的遍历器调用 next 遇到 yield* 表达式时帮我们去遍历了 genIn 返回的遍历器,其实 yield* 内部做了处理,等同于下面代码:

/* 合并生成器 —— for of */
// 外层的生成器
function* genOut() {
  yield 'a';
  for (let v of genIn()) {
    yield v;
  }
  yield 'c';
}

// 内层的生成器
function* genIn() {
  yield 'b';
}

// 遍历器
const it = genOut();

it.next(); // {value: 'a', done: false}
it.next(); // {value: 'b', done: false}
it.next(); // {value: 'c', done: false}
it.next(); // {value: undefined, done: true}

Generators 与 Promise 结合

Promise 也是 ES6 的规范,同样是解决异步的一种手段,如果对 Promise 还不了解,可以阅读下面两篇文章:

因为 Generator 函数在执行时遇到 yield 关键字会暂停执行,那么 yield 后面可以是异步操作的代码,比如 Promise,需要继续执行,就手动调用返回遍历器的 next 方法,因为中间有一个等待的过程,所以在执行异步代码的时候避免了回调函数的嵌套,在写法上更像同步,更容易理解。

我们来设计一个 Generator 函数与 Promise 异步操作结合的使用场景,假设我们需要使用 Node.jsfs 模块读取一个文件 a.txt 的内容,而 a.txt 的内容是另一个需要读取文件 b.txt 的文件名,读取 b.txt 最后打印读取到的内容 “Hello world”。

回调函数的实现:

/* 连续读取文件 —— 异步回调 */
// 引入依赖
const fs = require('fs');

fs.readFile('a.txt', 'utf8', (err, data) => {
  if (!err) {
    fs.readFile(data, 'utf8', (err, data) => {
      if (!err) {
        console.log(data); // Hello world
      }
    });
  }
});

上面代码因为只有两层回调函数嵌套,所以感觉没那么复杂,但是嵌套的回调函数多了,代码就不那么的优雅了,我们接下来使用 Generator 结合 Promise 来实现,为了方便将 fs 异步的方法转换成 Promise,我们引入 util 模块,并转换 readFile 方法。

/* 连续读取文件 —— Generator + Promise */
// 引入依赖
const fs = require('fs');
const util = require('util');

// 将 readFile 方法转换成 Promise
const read = util.promisify(fs.readFile);

// 生成器函数
function* gen() {
  const aData = yield read('1.txt', 'utf8');
  const bData = yield read(aData, 'utf8');
  return bData;
}

// 遍历器
const it = gen();

it.next().value.then(data => {
  it.next(data).then(data => {
    console.log(data); // Hello world
  });
});

我们只看 Generator 函数 gen 内部的执行,虽然是异步操作,但是在写法上几乎和同步没有区别,理解起来更容易,唯一美中不足的是,我们需要自己手动的调用遍历器的 nextPromise 实例的 then,这个问题 co 库可以帮我们解决。

co 库的使用

co 库的作者是著名的 Node.js 大神 tj,是基于 GeneratorPromise 的,这个库能帮我们实现自动调用 Generator 函数返回遍历器的 next 方法,并执行 yield 后面 Promise 实例的 then 方法,所以每次 yield 后面的异步操作返回的必须是一个 Promise 实例,代码看起来像同步,执行其实是异步,不用自己手动进行下一次遍历,这更是我们想要的。

由于 co 是一个第三方的模块,所以在使用时需要我们提前下载:

$ npm install co

我们使用 co 来实现之前异步连续读文件的案例:

/* 连续读取文件 —— Generator + co */
// 引入依赖
const fs = require('fs');
const util = require('util');
const co = require('co');

// 将 readFile 方法转换成 Promise
const read = util.promisify(fs.readFile);

// 生成器函数
function* gen() {
  const aData = yield read('1.txt', 'utf8');
  const bData = yield read(aData, 'utf8');
  return bData;
}

// 使用 co 库代替手动调用 next
co(gen()).then(data => {
  console.log(data); // Hello world
});

从上面代码可以看出,co 库的 co 函数参数是一个遍历器,即 Generator 函数执行后的返回结果,在 co 内部操作遍历器并遍历完成后返回了一个 Promise 实例,遍历器最终的返回结果的 value 值作为 then 方法回调的参数,所以我们可以使用 then 对结果进行后续的处理。

co 库的实现原理

我们其实在上面使用 co 的过程中对于 co 函数的内部做了什么已经有所了解,主要就是帮助我们调用遍历器的 next 和调用 yield 后面代码执行后返回 Promise 实例的 then,并在整个遍历结束后,返回一个新的 Promise 实例。

下面我们根据上面分析的 co 函数的原理来模拟一个简易版的 co 库:

/* 文件:myCo.js —— co 原理 */
// co 函数,it 为遍历器对象
function co(it) {
  // 返回 Promise 实例
  return new Promise((resolve, reject) => {
    // 异步递归
    function next(data) {
      // 第一次调用 next 不需要传参
      let { value, done } = it.next(data);

      if (!done) {
        // 如果没完成遍历,调用返回 Promise 的 then 方法
        value.then(data => {
          // 如果 Promise 成功,继续递归,如果失败直接执行 reject
          next(data);
        }, reject);
      } else {
        // 如果遍历完成直接执行 resolve 并传入 value
        resolve(value);
      }
    }
    next();
  });
}

// 导出模块
module.exports = co;

验证 myCo.js 实现的 co 函数:

/* 验证 myCo */
// 引入依赖
const fs = require('fs');
const util = require('util');
const myCo = require('./myCo');

// 将 readFile 方法转换成 Promise
const read = util.promisify(fs.readFile);

// 生成器函数
function* gen() {
  let aData = yield read('1.txt', 'utf8');
  let bData = yield read(aData, 'utf8');
  return bData;
}

// 使用 co 库代替手动调用 next
myCo(gen()).then(data => {
  console.log(data); // Hello world
});

我们将引入的 co 库替换成了自己实现的简易版 myCo 模块,上面读取文件的案例依然生效,这说明我们模拟的 co 库核心逻辑是没问题的,跟原版不同的是并没有处理很多细节,并定义指针,如果对 co 库感兴趣建议看看 tj 大神的源码,整个库写的非常精简,值得学习。

总结

Generators 相当于把一个函数拆分成若干个部分执行,执行一次时将指针指向下一段要执行的代码,直到结束位置,Generators 配合 co 库的使用场景多在 Node.js 当中,并在 Koa 1.x 版本中居多,现在已经升级到 Koa 2.x 版本,使用更多的是基于 Generatorsco 库衍生出来的 ES7 新标准 async/await,我们在下一篇异步发展流程系列的文章中来详细介绍。