手写generator核心原理

发布时间 2023-11-27 16:45:29作者: 柯基与佩奇

1. generator 的使用

Generator 函数跟普通函数的写法有非常大的区别:
一是,function 关键字与函数名之间有一个星号;
二是,函数体内部使用 yield 语句,定义不同的内部状态(yield 在英语里的意思就是“产出”)。

最简单的 Generator 函数如下:

function* g() {
  yield "a";
  yield "b";
  yield "c";
  return "ending";
}

g(); // 返回一个对象

g 函数呢,有四个阶段,分别是'a','b','c','ending'。
Generator 函数神奇之一:g()并不执行 g 函数

g()并不会执行 g 函数,返回的也不是函数运行结果,而是一个指向内部状态的指针对象,也就是迭代器对象(Iterator Object)。
Generator 函数神奇之二:分段执行

先看如下代码:

function* g() {
  yield "a";
  yield "b";
  yield "c";
  return "ending";
}

let gen = g();
gen.next(); // 返回Object {value: "a", done: false}

gen.next()返回一个非常非常简单的对象{value: "a", done: false},'a'就是 g 函数执行到第一个 yield 语句之后得到的值,false 表示 g 函数还没有执行完,只是在这暂停。

如果再写一行代码,还是 gen.next();,这时候返回的就是{value: "b", done: false},说明 g 函数运行到了第二个 yield 语句,返回的是该 yield 语句的返回值'b'。返回之后依然是暂停。

再写一行 gen.next();返回{value: "c", done: false},再写一行 gen.next();,返回{value: "ending", done: true},这样,整个 g 函数就运行完毕了。

提问:如果再写一行 gen.next();呢?
答:返回{value: undefined, done: true},这样没意义。

提问:如果 g 函数没有 return 语句呢?
答:那么第三次.next()之后就返回{value: undefined, done: true},这个第三次的 next()唯一意义就是证明 g 函数全部执行完了。

提问:如果 g 函数的 return 语句后面依然有 yield 呢?
答:js 的老规定:return 语句标志着该函数所有有效语句结束,return 下方还有多少语句都是无效,白写。

提问:如果 g 函数没有 yield 和 return 语句呢?
答:第一次调用 next 就返回{value: undefined, done: true},之后也是{value: undefined, done: true}。

提问:如果只有 return 语句呢?
答:第一次调用就返回{value: xxx, done: true},其中 xxx 是 return 语句的返回值。之后永远是{value: undefined, done: true}。

提问:下面代码会有什么结果?

function* g() {
  let o = 1;
  yield o++;
  yield o++;
  yield o++;
}
let gen = g();

console.log(gen.next()); // 1

let xxx = g();

console.log(gen.next()); // 2
console.log(xxx.next()); // 1
console.log(gen.next()); // 3

答:见上面注释。每个迭代器之间互不干扰,作用域独立。
继续提问:如果第二个 yield o++;改成 yield;会怎样?

答:那么指针指向这个 yield 的时候,返回{value: undefined, done: false}。
继续提问:如果第二个 yield o++;改成 o++;yield;会怎样?

答:那么指针指向这个 yield 的时候,返回{value: undefined, done: false},因为返回的永远是 yield 后面的那个表达式的值。

所以现在可以看出,每次调用 next 方法,内部指针就从函数头部或上一次停下来的地方开始执行,直到遇到下一个 yield 语句(或 return 语句)为止。换言之,Generator 函数是分段执行的,yield 语句是暂停执行的标记,而 next 方法可以恢复执行。

总之,每调用一次 Generator 函数,就返回一个迭代器对象,代表 Generator 函数的内部指针。以后,每次调用迭代器对象的 next 方法,就会返回一个有着 value 和 done 两个属性的对象。value 属性表示当前的内部状态的值,是 yield 语句后面那个表达式的值;done 属性是一个布尔值,表示是否遍历结束。

所以可以看出,Generator 函数的特点就是:
1、分段执行,可以暂停
2、可以控制阶段和每个阶段的返回值
3、可以知道是否执行到结尾

yield 语句

迭代器对象的 next 方法的运行逻辑如下。
(1)遇到 yield 语句,就暂停执行后面的操作,并将紧跟在 yield 后面的那个表达式的值,作为返回的对象的 value 属性值。
(2)下一次调用 next 方法时,再继续往下执行,直到遇到下一个 yield 语句。
(3)如果没有再遇到新的 yield 语句,就一直运行到函数结束,直到 return 语句为止,并将 return 语句后面的表达式的值,作为返回的对象的 value 属性值。
(4)如果该函数没有 return 语句,则返回的对象的 value 属性值为 undefined。

yield 语句与 return 语句既有相似之处,也有区别。

相似之处在于,都能返回紧跟在语句后面的那个表达式的值。

区别在于每次遇到 yield,函数暂停执行,下一次再从该位置继续向后执行,而 return 语句不具备位置记忆的功能。一个函数里面,只能执行一次(或者说一个)return 语句,但是可以执行多次(或者说多个)yield 语句。正常函数只能返回一个值,因为只能执行一次 return;Generator 函数可以返回一系列的值,因为可以有任意多个 yield。从另一个角度看,也可以说 Generator 生成了一系列的值,这也就是它的名称的来历(在英语中,generator 这个词是“生成器”的意思)。

注意:yield 语句只能用于 function 的作用域,如果 function 的内部还定义了其他的普通函数,则函数内部不允许使用 yield 语句。
注意:yield 语句如果参与运算,必须用括号括起来。

console.log(3 + yield 4); // 语法错误
console.log(3 + (yield 4)); // 打印7

next 方法可以有参数

一句话说,next 方法参数的作用,是为上一个 yield 语句赋值。由于 yield 永远返回 undefined,这时候,如果有了 next 方法的参数,yield 就被赋了值,比如下例,原本 a 变量的值是 0,但是有了 next 的参数,a 变量现在等于 next 的参数,也就是 11。

next 方法的参数每次覆盖的一定是 undefined。next 在没有参数的时候,函数体里面写 let xx = yield oo;是没意义的,因为 xx 一定是 undefined。

function* g() {
  let o = 1;
  let a = yield o++;
  console.log("a = " + a);
  let b = yield o++;
}
let gen = g();

console.log(gen.next());
console.log("------");
console.log(gen.next(11));

得到:

首先说,console.log(gen.next());的作用就是输出了{value: 1, done: false},注意 let a = yield o++;,由于赋值运算是先计算等号右边,然后赋值给左边,所以目前阶段,只运算了 yield o++,并没有赋值。

然后说,console.log(gen.next(11));的作用,首先是执行 gen.next(11),得到什么?首先:把第一个 yield o++重置为 11,然后,赋值给 a,再然后,console.log('a = ' + a);,打印 a = 11,继续然后,yield o++,得到 2,最后打印出来。

从这看出了端倪:带参数跟不带参数的区别是,带参数的情况,首先第一步就是将上一个 yield 语句重置为参数值,然后再照常执行剩下的语句。总之,区别就是先有一步先重置值,接下来其他全都一样。

这个功能有很重要的语法意义,通过 next 方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

提问:第一个.next()可以有参数么?
答:设这样的参数没任何意义,因为第一个.next()的前面没有 yield 语句。

for...of 循环
for...of 循环可以自动遍历 Generator 函数时生成的 Iterator 对象,且此时不再需要调用 next 方法。for...of 循环的基本语法是:

for (let v of foo()) {
  console.log(v);
}

其中 foo()是迭代器对象,可以把它赋值给变量,然后遍历这个变量。

function* foo() {
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
  return 6;
}

let a = foo();

for (let v of a) {
  console.log(v);
}
// 1 2 3 4 5

上面代码使用 for...of 循环,依次显示 5 个 yield 语句的值。这里需要注意,一旦 next 方法的返回对象的 done 属性为 true,for...of 循环就会中止,且不包含该返回对象,所以上面代码的 return 语句返回的 6,不包括在 for...of 循环之中。

下面是一个利用 Generator 函数和 for...of 循环,实现斐波那契数列的例子。

斐波那契数列是什么?它指的是这样一个数列 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144........
这个数列前两项是 0 和 1,从第 3 项开始,每一项都等于前两项之和。

function* fibonacci() {
  let [prev, curr] = [0, 1];
  for (;;) {
    // 这里请思考:为什么这个循环不设定结束条件?
    [prev, curr] = [curr, prev + curr];
    yield curr;
  }
}

for (let n of fibonacci()) {
  if (n > 1000) {
    break;
  }
  console.log(n);
}

2. 手写 generator 核心原理

从一个简单的例子开始,一步步探究 Generator 的实现原理:

function* foo() {
  yield "result1";
  yield "result2";
  yield "result3";
}

const gen = foo();
console.log(gen.next()); //{value: "result1", done: false}
console.log(gen.next()); //{value: "result2", done: false}
console.log(gen.next()); //{value: "result3", done: false}
console.log(gen.next()); //{value: undefined, done: true}

看到这种整齐的结构,想起了 switch case,也是这么地整齐,所以这两种之间应该存在一种关系。

尝试写一个用 switch/case 来实现下:

function gen$(nextStep) {
  while (1) {
    switch (nextStep) {
      case 0:
        return "result1";
      case 2:
        return "result2";
      case 4:
        return "result3";
      case 6:
        return undefined;
    }
  }
}

如代码所示,每次调用 gen$然后传对应的参数,就能返回对应的值(也就是原本函数 yield 后面的值)

但是 nextStep 应该是一个自动增加的函数,应该不是传进去的。所以这里应该用一个闭包来实现

function gen$() {
  let nextStep = 0;
  return function () {
    while (1) {
      switch (nextStep) {
        case 0:
          nextStep = 2;
          return "result1";

        case 2:
          nextStep = 4;
          return "result2";

        case 4:
          nextStep = 6;
          return "result3";

        case 6:
          return undefined;
      }
    }
  };
}

现在可以通过

let a = gen$();

获得内函数。
这样每次执行

a();

nextStep 就会改成下一次执行 a()应该对应的值,并且返回相应的 result 了。

但是 generator 的底层原理不是用闭包的。而是用一个全局变量,因为这样为了后面的实现方便很多,为了遵循原理,改成用全局变量来实现。

先定义一个全局变量

context = {
  prev: 0,
  next: 0,
};

function gen$(context) {
  while (1) {
    switch ((context.prev = context.next)) {
      case 0:
        context.next = 2;
        return "result1";

      case 2:
        context.next = 4;
        return "result2";

      case 4:
        context.next = 6;
        return "result3";

      case 6:
        return undefined;
    }
  }
}

第一次执行 gen$(context),swtich判断的时候,是用prev来判断这一次应该执行那个case,执行case时再改变next的值,next表示下次应该执行哪个case。第二次执行gen$(context)的时候,将 next 的值赋给 prev。

但是直接返回这么一个值是不对的。看前面的例子是返回一个对象。那该怎么实现呢?

再把例子搬下来:

function* foo() {
  yield "result1";
  yield "result2";
  yield "result3";
}

const gen = foo();
console.log(gen.next()); //{value: "result1", done: false}
console.log(gen.next()); //{value: "result2", done: false}
console.log(gen.next()); //{value: "result3", done: false}
console.log(gen.next()); //{value: undefined, done: true}

发现 gen 有 next 这个方法。所以可以判断出 执行 foo 返回的应该是一个对象,这个对象有 next 这个方法。所以初步实现 foo 的转化后的函数。

let foo = function () {
  return {
    next: function () {},
  };
};

而每次执行 next,就会返回拥有 value 和 done 的对象,

所以,可以完善返回值

let foo = function () {
  return {
    next: function () {
      return {
        value,
        done,
      };
    },
  };
};

但是这里还没定义这 value 和 done 啊,该怎么定义呢?

先看 value 的实现。在上面实现 gen$的时候,就发现它返回的是value了。所以可以在这里获取$gen 的返回值作为 value。

let foo = function () {
  return {
    next: function () {
      value = gen$(context);
      return {
        value,
        done,
      };
    },
  };
};

那 done 怎么定义呢?

其实 done 作为一个全局状态表示 generator 是否执行结束,因此,可以在

context 里定义,默认值为 false。

let context = {
  next: 0,
  prev: 0,
  done: false,
};

所以,每次返回,直接返回 context.done 就可以了

let foo = function () {
  return {
    next: function () {
      value = gen$(context);
      done = context.done;
      return {
        value,
        done,
      };
    },
  };
};

那 done 是怎么改变为 true 的。知道,generator 执行到后面,就会返回 done:true。可以看例子的第四个执行结果

function* foo() {
  yield "result1";
  yield "result2";
  yield "result3";
}

const gen = foo();
console.log(gen.next()); //{value: "result1", done: false}
console.log(gen.next()); //{value: "result2", done: false}
console.log(gen.next()); //{value: "result3", done: false}
console.log(gen.next()); //{value: undefined, done: true}

因此,需要在最后一次执行 gen$的时候改变 context.done 的值。

思路,给 context 添加一个 stop 方法。用来改变自身的 done 为 true。在执行$gen 的时时候让 context 执行 stop 就好

let context = {
  next: 0,
  prev: 0,
  done: false,
  // 新增代码
  stop: function stop() {
    this.done = true;
  },
};

function gen$(context) {
  while (1) {
    switch ((context.prev = context.next)) {
      case 0:
        context.next = 2;
        return "result1";

      case 2:
        context.next = 4;
        return "result2";

      case 4:
        context.next = 6;
        return "result3";

      case 6:
        新增代码;
        context.stop();
        return undefined;
    }
  }
}

let foo = function () {
  return {
    next: function () {
      value = gen$(context);
      done = context.done;
      return {
        value,
        done,
      };
    },
  };
};

这样执行到 case 为 6 的时候就会改变 done 的值了。
实际上这就是 generator 的大致原理

并不难理解,分析一下流程:
定义的 function*生成器函数被转化为以上代码

转化后的代码分为三大块:
gen$(_context)由 yield 分割生成器函数代码而来

context 对象用于储存函数执行上下文
迭代器法定义 next(),用于执行 gen$(_context)来跳到下一步

从中可以看出,「Generator 实现的核心在于上下文的保存,函数并没有真的被挂起,每一次 yield,其实都执行了一遍传入的生成器函数,只是在这个过程中间用了一个 context 对象储存上下文,使得每次执行生成器函数的时候,都可以从上一个执行结果开始执行,看起来就像函数被挂起了一样」

3. 参照源码实现 Context 类

不过,这里的 context 是个全局对象啊?都知道如果是下面这种情况:

function* g() {
  let o = 1;
  yield o++;
  yield o++;
  yield o++;
}
let gen = g();

console.log(gen.next()); // 1

let xxx = g();

console.log(gen.next()); // 2
console.log(xxx.next()); // 1
console.log(gen.next()); // 3

发现 每个迭代器之间互不干扰,作用域独立。

也就是说每个迭代器的 context 是独立的。但是与目前实现的一个全局 context 不一致,这个是百思不得其解,所以看下源码。

利用 babel 将下面代码转化一下

function* foo() {
  yield "result1";
  yield "result2";
  yield "result3";
}

可以在 babel 官网上在线转化这段代码,看看 ES5 环境下是如何实现 Generator 的:

"use strict";

let _marked =
  /*#__PURE__*/
  regeneratorRuntime.mark(foo);

function foo() {
  return regeneratorRuntime.wrap(function foo$(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        case 0:
          _context.next = 2;
          return "result1";

        case 2:
          _context.next = 4;
          return "result2";

        case 4:
          _context.next = 6;
          return "result3";

        case 6:
        case "end":
          return _context.stop();
      }
    }
  }, _marked);
}

看源码,可能觉得跟实现的有点不一样,实际上结构是基本一样的,基本都是分成那三部分

发现源码是将的 gen$(context)方法传入了 wrap 中。

看下 wrap 方法

function wrap(innerFn, outerFn, self) {
  let generator = Object.create(outerFn.prototype);
  let context = new Context([]);
  generator._invoke = makeInvokeMethod(innerFn, self, context);

  return generator;
}

发现它是每生 foo()执行一次 ,就会执行一次 wrap 方法,而在 wrap 方法里就会 new 一个 Context 对象。这就说明了每个迭代器的 context 是独立的。

Soga原来如此~~~

也就是说如果要实现独立 context 还是 把 context 改成一个类。

在执行 let gen = g();的时候再生成 context 实例即可:

class Context {
  constructor() {
    this.next = 0
    this.prev = 0
    this.done = false
  }
  top() {
    this.done = true
  }
}

let foo = function () {
  let context = new Context() 新增代码
  return {
    next: function () {
      value = gen$(context);
      done = context.done
      return {
        value,
        done
      }
    }
  }
}

4. 参照源码实现参数值的保存

好了,这个独立 context 问题解决。但是发现哈有一个问题:

function* foo() {
  let a = yield "result1";
  console.log(a);
  yield "result2";
  yield "result3";
}

const gen = foo();
console.log(gen.next().value);
console.log(gen.next(222).value);
console.log(gen.next().value);

发现这里用 let a 来接收传入的参数。

当第一次执行 gen.next(),foo 内部会执行到 yield 这里。还没给 a 赋值

当第二次执行 gen.next(),foo 内部会再第一个 yield 这里执行。把传入的参数 222 赋值给 a。

那原理是怎么实现的呢?依旧百思不得其解,不得不再看下源码。

将下面代码 babel 一下

function* foo() {
  let a = yield "result1";
  console.log(a);
  yield "result2";
  yield "result3";
}

("use strict");

let _marked = /*#__PURE__*/ regeneratorRuntime.mark(foo);

function foo() {
  let a;
  // 在这里定义;
  return regeneratorRuntime.wrap(function foo$(_context) {
    while (1) {
      switch ((_context.prev = _context.next)) {
        case 0:
          _context.next = 2;
          return "result1";

        case 2:
          a = _context.sent;
          // 在这里赋值;
          console.log(a);
          _context.next = 6;
          return "result2";

        case 6:
          _context.next = 8;
          return "result3";

        case 8:
        case "end":
          return _context.stop();
      }
    }
  }, _marked);
}

可见。是将在 generator 定义的变量提到 foo 函数顶部了。作为一个闭包的变量。

因此,居于这个思路,可以完善一下的代码。

如果在 nenerator 定义了 xxx 这个变量,那么就会被提升到函数顶部

function gen$(context) {
  let xxx;// 新增代码
  while (1) {
    switch (context.prev = context.next) {
      case 0:
        context.next = 2;
        return 'result1';
      case 2:
        context.next = 4;
        return 'result2';
      case 4:
        context.next = 6;
        return 'result3';
      case 6:
        context.stop();
        return undefined
    }
  }
}

如果将出传入的参数赋值给这个变量,那么参数就会作为 Context 的参数。将传入的参数保存到 context 中。

let foo = function () {
  let context = new Context(222); //修改代码
  return {
    next: function () {
      value = gen$(context);
      done = context.done;
      return {
        value,
        done,
      };
    },
  };
};

然后在 gen$()执行的时候再赋值给变量

function gen$(context) {
  let xxx;
  while (1) {
    switch (context.prev = context.next) {
      case 0:
        context.next = 2;
        return 'result1';
      case 2:
        xxx = context._send // 新增代码
        context.next = 4;
        return 'result2';
      case 4:
        context.next = 6;
        return 'result3';
      case 6:
        context.stop();
        return undefined
    }
  }
}