JS - 生成器

231 阅读9分钟

生成器 ES6 新增的,生成器拥有在一个函数块内暂停和恢复代码的能力,这种新能力具有深远的影响,比如使用生成器可以自定义迭代器和实现协程

思维导图

生成器的基本

表现形式

// 生成器 * 两边的空格不影响生成器的标识,一般格式化会将其 紧挨着 function 或函数名(看起来美观)

// 函数形式的生成器
function* g1() {}

// 函数表达式的生成器
const g2 = function* () {};

// 对象字面量里生成器表达
const obj = {
  *g3() {},
};

// 类中的生成器
class G4className {
  // 实例对象的生成器
  *g4() {}

  // 静态方法的生成器
  static *g5() {}
}

生成器对象

调用生成器会产生一个处于暂停执行(suspended)状态的生成器对象,与迭代器相似,生成器对象也实现了 Iterator 接口,因此具有 next() 方法,调用这个方法会让生成器开始或恢复执行;生成器实现了 Iterable 接口,他们默认的迭代器是自引用的。

注意 ⚠️: 调用 生成器函数的时候只会返回一个生成器对象,并不会执行生成器函数块中的代码,只有初次调用 next 之后才会开始执行生成器函数块中的代码

function* genFn() {
  log("start");
  return "strVal";
}

/**
 * 调用 生成器函数的时候只会返回一个生成器对象,并不会执行生成器函数块中的代码,
 * 只有初次调用 next 之后才会开始执行生成器函数块中的代码
 */
const genObj = genFn();

log(genObj); // 先打印 start;然后 打印 genObj 浏览器显示: genFn {<suspended>}, node 显示:Object [Generator] {}
log(genObj.next()); // log: { value: 'strVal', done: true } done 为 true 之后 返回的 value 都为 undefiend
log(genObj.next()); // log: { value: undefined, done: true }

生成器对象作为可迭代对象

生成器对象显示调用 next 方法意义不大,因此生成器可以当成可迭代对象,适合自定义迭代对象,这时候生成器非常有用。

function* genFn() {
  yield 1;
  yield 2;
  yield 3;
}

const genObj = genFn();

for (const val of genObj) {
  log(val); // 依此换行 log 1、2、3
}

//  --- split ---
function* nTimes(n) {
  while (n) {
    yield n;
    n--;
  }
}

for (let v of nTimes(3)) {
  log(v); // 依此换行log 3,2,1
}

yield 关键字

yield 中断执行

yield 关键字可以让生成器停止和开始执行。生成器函数在遇到 yield 关键字之前会正常执行。遇到 yield 后执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用 next() 方法来恢复执行; yield 生成的值会返回在 next() 方法返回对象里,通过 yield 退出的生成器函数会处在 done: false 状态,通过 return 关键字退出的生成器会处于 done: true 的状态

⚠️ yield 只能在生成器函数内部顶层使用,在其他地方会抛出错误

function* genFn() {
  // yield 生成的值会出现在 next 方法返回的对象的 value 中,done 为 false
  yield 3;
  yield 4;
  // return 退出生成器会是 next 方法返回的对象的 done 为 true, 因此函数块中 最外层块 return 之后的代码无意义
  return 5;
  yield 15; // 无意义的代码
}

const genObj = genFn();
log(genObj.next()); // log: { value: 3, done: false }
log(genObj.next()); // log: { value: 4, done: false }
log(genObj.next()); // log: { value: 5, done: true }
log(genObj.next()); // log: { value: undefined, done: true }

使用 yield 实现输入和输出

出了作为函数的中间返回语句使用,yield 关键字还可以作为函数的中间参数使用,上一次让生成器函数暂停的 yield 关键字会接受到 传入 next() 方法的第一个值(但是第一次调用 next 传入的值不会被使用,因为第一次调用是为了开始执行生成器函数)

// demo1
function* genFn(initial) {
  // 调用 生成器函数的时候 不会执行其内部的代码,仅仅是返回一个生成器对象,但是传递给迭代器函数的参数会被记录在 initial 中

  // 调用 next(0) 的时候 开始执行
  log(initial);
  // 调用 next(0) 的时候暂停到了下一行,但是 next(0) 是第一次调用,只是为了执行生成器函数,因此传递来的 0 没有意义
  log(yield); // 调用 next(1,11) 的时候,会从这一行开始, 1(next 第一个参数) 被 yield 接受了,因此 log 1,遇到 下一行 的 yield 后又 暂停到了下一行
  log(yield); // 调用 next(2) 的时候,从这一行开始,2 被 yield 接受了,因此 log 2 后 没有 yield 了就直接 退出了
}

const genObj = genFn("call");
genObj.next(0); // log: call
genObj.next(1, 11); // log: 1
genObj.next(2); // log: 2
genObj.next(3); // 已经退出了

// demo2
function* gen2() {
  // yield 关键字同时用于输入和输出
  return yield "value";
}

const g2 = gen2();
log(g2.next()); // log: { value: 'value', done: false }, 第一次调用 next 执行生成器函数,next 返回的对象的 value 值 是 yield 生成的
log(g2.next(15)); // log: { value: 15, done: false }, next的参数 15 会被 yield 接受,return 返回了 15 作为生成器函数要返回的值,return 的 done 为 true

yield 在循环中的使用

可以利用 生成器来实现循环,而不是借助数组

function* forGen(n) {
  for (let i = 1; i <= n; i++) {
    yield i;
  }
}

for (const v of forGen(3)) {
  log(v); // 换行依此打印 1、2、3
}

// ----- split -----
function* whileGen(n) {
  while (n) {
    log(n--);
  }
}

for (const v of whileGen(3)) {
  log(v); // 换行依此打印 3、2、1
}

// ----- split -----
// 使用生成器实现 范围 [start,end]闭区间 的值
function* range(start, end) {
  while (end >= start) {
    yield start++;
  }
}

const rangeList = [...range(2, 6)]; // 利用扩展运算符将生成器转化为数组
log(rangeList); // log: [ 2, 3, 4, 5, 6 ]
log(...range(2, 6)); // log: 2 3 4 5 6  (node environment)

使用 * 号增强 yield 行为

*号可以增强 yield 行为,让它能够迭代一个可迭代对象,从而一次产出一个值, 即只是将一个可迭代对象序列化为一连串可以单独产出的值,这跟把 yield 放在一个循环里没有什么不同

function* gen() {
  yield* [1, 2, 3]; // 等效于 for(const v of [1,2,3]){ yield v }, * 号两侧的空格不影响其行为
}

log(...gen()); // log: 1 2 3 (node environment)

function* gen2() {
  yield* ["v1", "v2"];
  yield* ["v3", "v4"];
}

log(...gen2()); // log: v1 v2 v3 v4  (node environment)

yield *的值是关联迭代器函数返回 done: true 是的 value值,有 return 的时候,就是 return 的值

function* gen1() {
  log("iter value: ", yield* [1, 2, 3]);
}

const g1 = gen1();
log(g1.next()); // log: { value: 1, done: false }
log(g1.next()); // log: { value: 2, done: false }
log(g1.next()); // log: { value: 3, done: false }
log(g1.next()); // 因为上一行已经遍历结束,因此先 log "iter value: " undefined, 然后在 log { value: undefined, done: true }

log("------- split -------");
// innerGen 用于嵌套的值
function* innerGen() {
  yield "in1";
  return "returnVal";
}

function* outterGen() {
  log("yield * value: ", yield* innerGen());
}

const outGen = outterGen();
log(outGen.next()); // log: { value: 'in1', done: false }
log(outGen.next()); // 先 log 「yield * value:  returnVal」,然后在 log: { value: undefined, done: true }
log(outGen.next()); // log: { value: undefined, done: true }

使用 yield * 实现递归算法

如下这个例子,每个生成器首先都会从新创建的生成器对象产出每个值,然后再产出一个整数。结果就是生成器函数会递归地减少计数器值,并实例化一个另一个生成器对象,从最底层来看,这就相当于创建一个可迭代对象并返回递增的整数

function* nTimes(n) {
  if (n > 0) {
    yield* nTimes(n - 1);
    yield n - 1;
  }
}

for (const n of nTimes(3)) {
  log(n); // 换行依此打印 0 1 2
}
随机双向图的实现

图数据结构非常适合递归遍历,而递归生成器则恰好非常合用。

class Node {
  constructor(id) {
    this.id = id;
    this.neighbors = new Set();
  }

  connect(node) {
    if (node !== this) {
      // 实现双向图的连接
      this.neighbors.add(node);
      node.neighbors.add(this);
    }
  }
}

// 随机双向图
class RandomGraph {
  constructor(size) {
    this.nodes = new Set();
    // 创建节点
    for (let i = 0; i < size; ++i) {
      this.nodes.add(new Node(i));
    }

    // 随机连接节点
    const threshold = 1 / size;
    for (const x of this.nodes) {
      for (const y of this.nodes) {
        if (Math.random() < threshold) {
          // 实现随机节点的连接
          x.connect(y);
        }
      }
    }
  }

  // 打印出随机双向图
  print() {
    for (const node of this.nodes) {
      // 一次遍历每个节点的 相连节点
      const ids = [...node.neighbors].map((n) => n.id).join("、");
      console.log(`current Node id: ${node.id} => neighbors'id: ${ids}`);
    }
  }

  // 测试某个图是否连通,没有不可达到的节点即没有连通,只需要从一个节点开始,然后尽力访问每个节点就可以了
  isConnected() {
    const visitedNodes = new Set();

    function* traverse(nodes) {
      for (const node of nodes) {
        if (!visitedNodes.has(node)) {
          // 如果访问过的节点没有当前的 node,则 yield node
          yield node;
          // 然后继续遍历当前节点的 node.neighbors
          yield* traverse(node.neighbors);
        }
      }
    }

    // 取得集合中的第一个节点
    const firstNode = this.nodes[Symbol.iterator]().next().value;

    // 使用递归生成器迭代每个节点
    for (const node of traverse([firstNode])) {
      // 将访问过的节点添加到 visitedNodes 中
      visitedNodes.add(node);
    }

    // 如果访问过的节点长度 和 当前实例的所有 节点长度一样,则说明图是连通的,反之
    return visitedNodes.size === this.nodes.size;
  }
}

const rg = new RandomGraph(6);
rg.print(); // 根据调用时决定,因为涉及了 Math.random
log(rg.isConnected()); // 同上

提前终止生成器

迭代器、生成器均可以支持“可关闭”概念,一个实现了 Iterator 接口的对象一定有 next() 方法,还有一个可选的 return() 方法用于提前终止迭代器,生成器还有另外一个方法 throw

强制生成器进入关闭状态的两个方法:

  • return()
  • throw()
return(value?:any) 中断生成器方法

与迭代器不同,所有生成器对象都有 return()方法,只要调用它,生成器对象就无法恢复了,后续调用 next() 会 done 都为 true,value 为 undefied ;提供的任何值都不会被储存或传播

但是 for-of 循环等内置语言结构会忽略状态为 done: true 的 IteratorObject 内部返回的值

function* gen() {
  yield* [1, 2, 3];
}

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

// 每个生成器都是相互独立的,互不干扰,因此 g 调用了 return方法 对 g2 的迭代不会造成影响
const g2 = gen();
for (const v of g2) {
  if (v > 2) {
    g.return(4); // for-of 循环等内置语言结构会忽略 done: 为 true 的返回值
  }
  log(v); // 依此换行打印 1 2 3
}
throw(e?:any) 中断生成器方法

throw() 方法会在暂停的时候将一个提供的错误注入到生成器对象中,如果错误未被处理,生成器将会被关闭;生成器内部处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行。错误处理会跳过对应的 yield

function* gen() {
  for (const v of [1, 2, 3]) {
    try {
      yield v;
    } catch (error) {}
  }
}

/**
 * 生成器在 try/catch 块中的 yield 关键字处暂停执行
 * 在暂停期间,throw() 方法向生成器对象内部注入了一个错误 err
 * 这个错误会被 yield 关键字抛出,因为错误是在生成器 try/catch 块中抛出,所以仍然在生成器内部被捕获
 * 由于 yield 抛出了那个错误,生成器不会再产出值 2
 * 生成器继续执行,在下一次迭代再次遇到 yield 关键字时产出 值 3
 */
const g = gen();
log(g.next());
g.throw("err");
log(g.next());

⚠️:如果生成器在对象还没有开始执行,那么调用 throw() 抛出的错误不会在函数内部被捕获,因为这相当于在函数块外部抛出了错误