对于JavaScript来说,生成器也是一个重要但又难以理解的内容,难以理解的地方不在于生成器的定义和使用,而在于其运作的内部逻辑,如果我们搞不清楚生成器的基本原理,仅靠死记硬背是无法真正对其掌握和运用的。
在ES6标准发布之前,JavaScript程序在运行的时候始终遵循这样的规则:一个函数一旦开始执行就会运行到结束,运行期间不会有其他代码能够打断它并插入其中。
由于JavaScript不是抢占式的,也不是多线程的,但如果一个函数能够通过某种形式在代码的某个位置进行暂停处理的话,那么也就意味着JavaScript可以在一定程度上实现程序的中断和并发。
这也就是ES6提出“生成器”概念的原因:通过一个极为灵活的结构,让JavaScript程序拥有一个函数内暂停和恢复代码执行的能力。比如,利用生成器自定义迭代器和实现协程。
由于生成器和迭代器有着紧密的联系,所以如果不首先搞清楚迭代器的原理,那么试图掌握生成器的使用也将会是“浮沙之塔”。因此建议小伙伴们,先去看看我写的《千哥读书笔记:JavaScript迭代器原理》,再来学习有关生成器的内容。
一、生成器的基本概念
(一)生成器函数
在JavaScript中,生成器(generator)是一个函数,可以暂停执行并返回中间结果,之后可以从暂停的地方恢复执行。生成器通过函数名前面加一个星号(*)来声明,使用yield语句来暂停执行。
以下是一个简单的JavaScript生成器的例子,它生成一个简单的数列:
function *generateSequence(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
const seqGen = generateSequence(5);
console.log(seqGen.next().value); // 输出: 0
console.log(seqGen.next().value); // 输出: 1
console.log(seqGen.next().value); // 输出: 2
console.log(seqGen.next().value); // 输出: 3
console.log(seqGen.next().value); // 输出: 4
console.log(seqGen.next().value); // 输出: undefined (表示迭代结束)
生成器还可以使用for...of循环来简化迭代过程:
for (let value of generateSequence(5)) {
console.log(value); // 输出: 0, 1, 2, 3, 4
}
生成器还可以使用throw和catch来处理异常,这与普通函数的try和catch语句类似。
生成器的声明方式,有以下几种:
1、生成器函数声明
function *generatorFn(){}
2、生成器函数表达式
let generatorFn = function *(){}
3、作为对象字面量的生成器函数
let obj = {
*generatorFn(){}
}
4、作为类实例方法的生成器函数
class MyClass{
*generatorFn(){}
}
5、作为类静态方法的生成器函数
class MyClass{
static *generatorFn(){}
}
而生成器星号(*)的位置没有特别的要求,只要函数前面有星号,就代表这个函数是一个生成器。因此,function *generatorFn()和 function * generatorFn()、function* generatorFn()都是一样的,这取决于你的编程习惯,只要你不把星号(*)当成C或C++里的指针就好。
(二)生成器对象
调用生成器函数会产生一个生成器对象,和普通的对象一样,其结构满足{...}(对象字面量)的语法格式;而且与迭代器对象相似,生成器对象也实现了Iterator接口,这就意味着生成器对象本身就是一个迭代器,只不过是一种特殊的迭代器。 从以下代码中还可以看出:
function *generateSequence(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
let gen = generateSequence(5);// 产生一个生成器对象
for (let value of gen) {
console.log(value); // 输出: 0, 1, 2, 3, 4
}
成生器对象(例子中的gen)可以参与for/of循环,说明其本身也是可迭代对象(Iterable)。
由于[Symbol.iterator]()是可迭代对象的标志,因此,即使在生成器函数中没有显式定义[Symbol.iterator](),生成器对象也会内置这种方法,而且可以显式地调用:
//生成器函数内置方法的显式调用
function *generateSequence(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
let gen = generateSequence(5);//创建一个生成器对象,这个对象也是一个可迭代对象
let iterator = gen[Symbol.iterator]();//调用[Symbol.iterator]()方法获得一个迭代器对象iterator
while(true){
let result = iterator.next();//迭代器对象iterator调用next()方法获得一个迭代器结果对象result
console.log(result.value);//打印迭代器结果对象result.value值
if(result.done) break;//如果迭代器结果对象result.done为true,则跳出循环
}//0 1 2 3 4
在这个例子中,生成器函数*generateSequence()在定义的时候,并没有[Symbol.iterator]()方法,但却可以通过一系列步骤实现迭代功能。
这个例子实现迭代操作的方式,符合迭代基本原理图:

在这个例子中,生成器对象gen作为一个可迭代对象,调用了迭代器方法[Symbol.iterator](),由这个方法生成了迭代器对象iterator,然后由迭代器对象iterator调用了next()方法,最后生成了迭代器结果对象result。
而在实际的运用中,上述的步骤中,生成器对象调用迭代器方法[Symbol.iterator]()并不是必需的,也可以这样操作:
function *generateSequence(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
let gen = generateSequence(5);//创建一个生成器对象
//用while循环实现迭代
while (true) {
let result = gen.next();//调用生成器对象的next()方法,返回一个迭代器结果对象
if (result.done) break;//如果迭代器结果对象的done属性为true,则跳出循环
console.log(result.value);//打印迭代器结果对象的value属性
}//0 1 2 3 4
//用for/of实现迭代
for(let value of generateSequence(5)){
console.log(value);//0 1 2 3 4
}
在这个例子中,生成器对象gen可以直接调用next()方法实现迭代功能,由于next()方法是迭代器对象的标志,这再次印证一点:
生成器对象(例子中的gen)又是迭代器对象。
再来看下面的例子:
function *generateSequence(n) {
for (let i = 0; i < n; i++) {
yield i;
}
}
let gen = generateSequence(5);//创建一个生成器对象
while (true) {
let result = gen.next();//调用生成器对象的next()方法,返回一个迭代器结果对象
console.log(result.value);//打印迭代器结果对象的value属性
if (result.done) break;//如果迭代器结果对象的done属性为true,则跳出循环
}//0 1 2 3 4
let iterator = gen[Symbol.iterator]();//生成器对象gen有[Symbol.iterator]()方法,因此是一个可迭代对象
//[Symbol.iterator]()方法返回一个迭代器对象iterator,将这个迭代器对象与gen进行全等比较
console.log(gen === iterator);//结果为true
在《千哥读书笔记:JavaScript迭代器原理》这篇文章中,千哥提到过:
一个可迭代对象的内部结构是采取的第一种方式(标准的迭代器接口)还是第二种方式(闭包式迭代器接口),可以通过对迭代器对象和可迭代对象进行全等(===)操作来判断。
如果二者全等,则说明其内部结构采用的是第一种方式;如果二者不全等,则说明其内部结构采用的是第二种方式。
在这个例子中,生成器对象gen是一个可迭代对象,而gen全等于(===)由[Symbol.iterator]()方法产生的迭代器对象iterator。
说明两点:
1、生成器对象内部的迭代功能,采用的是第一种方式:标准的迭代器接口。而不是采用的闭包式迭代器接口。也就是说,生成器对象同时具有[Symbol.iterator]()方法和next()方法,这两个方法是并列的,不存在嵌套关系。
2、生成器对象默认的迭代器是自引用的。
二、生成器的yield关键字与next()方法之间的关系
生成器对象是可迭代对象,又是迭代器对象这种“三位一体”的特点,决定了生成器比普通的迭代器更具有灵活性和扩展性。
但最让人迷惑的是,yield的行为方式,以及yield关键字与next()方法之间的关系。
前面提到过:生成器使用yield语句来暂停执行。
yield这个英文单词,作为动词中文翻译的主要意思是:“产生(收益、效益等),产生(结果)”。
因此,从字面意义上理解,yield关键字的作用,就不仅仅是实现“暂停”这么简单,一定还有“产生结果”的作用。
那么,yield如何实现暂停功能,能产生什么样的结果,通过什么方式产生结果,这就与生成器的next()方法的行为密切相关。
(一)理解生成器的next()方法
由于生成器对象也是迭代器对象,因此生成器对象调用其next()方法也会产生迭代器结果对象。
“迭代器结果对象”的对象字面量的语法是:{done:布尔值,value:当前值}。
其中的布尔值就是false或true,当迭代还在进行中时,布尔值为false;当迭代结束时,布尔值为true,当前值为undefined。
因此,生成器对象的next()方法同样遵循迭代器结果对象的生成规则。
(二)理解生成器yield的暂停功能
yield关键字可以让生成器停止和开始执行,这也是生成器最有用的地方。看下面的例子:
function *generatorFn(x){
let y = 2 ;
let z;
console.log(x);
y = y * x;
console.log(y);
yield y;//第一次使用yield关键字
z = y * x;
console.log(z);
yield z;//第二次使用yield关键字
}
let gen = generatorFn(5);//x和y的结果不会打印出来
在let gen = generatorFn(5)这一步,如果是generatorFn一个普通函数,在用let声明了gen这个变量之后,应该会将x和y的值通过console.log()进行输出。但对于成生器函数来说 ,声明了gen这个对象之后,x和y的值是不会输出的。
而这一步,只是创建了一个迭代器对象(也是生成器对象),把它赋给了一个变量gen,用于控制生成器*generatorFn(x),并不会立即执行生成器内部的代码。
把上面的程序修改一下:
function *generatorFn(x){
let y = 2 ;
let z;
console.log(x);
y = y * x;
console.log(y);
yield y;//第一次使用yield关键字
z = y * x;
console.log(z);
yield z;//第二次使用yield关键字
}
let gen = generatorFn(5);//x和y的结果不会打印出来
console.log(gen.next());//第一次调用next()方法,打印出x的值5、y的值10,并打印出迭代器结果对象
//但z的结果50不会打印出来
运行之后,程序会打印出x的值5、y的值10,并打印出迭代器结果对象{ value: 10, done: false }。
修改后的代码,增加了一个gen的next()方法的调用。其运作的步骤为:
1、第一次调用next()方法,让生成器内部执行yield之前的代码,通过console.log()打印出x的值和y的值。
2、第一次遇到yield关键字后,暂停表达式yield y后面的代码z = y * x;console.log(z);的运行。
3、表达式yield y 向next()方法发出y的值(10),由next()方法产生迭代器结果对象{ value: 10, done: false },此时yield的作用类似于return,只不过是隐式地将y的值返回到了next()方法中。
下面,再次修改程序,第二次调用gen的next()方法:
function *generatorFn(x){
let y = 2 ;
let z;
y = y * x;
console.log(y);
yield y;//第一次使用yield关键字
z = y * x;
console.log(z);
yield z;//第二次使用yield关键字
}
let gen = generatorFn(5);//y的结果不会打印出来
console.log(gen.next());//第一次调用next()方法,打印出x的值5、y的值10,并打印出迭代器结果对象
//但z的结果50不会打印出来
console.log(gen.next());//第二次调用next()方法打印出z的结果50,并打印出迭代器结果对象
生成器对象gen第二次调用next()方法之后,通过console.log(y)打印出z的值50,并由next()方法产生了迭代器结果对象{ value: 50, done: false }。
其运作的步骤为:
1、第二次调用next()方法,next()方法通知第一个处于暂停的yield关键字,恢复表达式yield y后面的代码z = y * x;console.log(z);的运行。
2、第二次遇到yield关键字后,程序再次处于暂停状态。
3、表达式yield z 向next()方法发出z的值(50),由next()方法产生迭代器结果对象{ value: 50, done: false },此时yield的作用类似于return,只不过是隐式地将z的值返回到了next()方法中。
下面,将程序继续修改, 第三次调用gen的next()方法:
function *generatorFn(x){
let y = 2 ;
let z;
y = y * x;
console.log(y);
yield y;//第一次使用yield关键字
z = y * x;
console.log(z);
yield z;//第二次使用yield关键字
}
let gen = generatorFn(5);//y的结果不会打印出来
console.log(gen.next());//第一次调用next()方法,打印出x的值5、y的值10,并打印出迭代器结果对象
//但z的结果50不会打印出来
console.log(gen.next());//第二次调用next()方法打印出z的结果50,并打印出迭代器结果对象
console.log(gen.next());//第三次调用next()方法打印出迭代器结果对象
最后一次调用gen的next()方法,由于生成器里已经没有更多的yield关键字,由next()方法产生的迭代器结果对象,其value属性被设置为undefined,done属性被设置为ture,迭代结束。
这个例子回答了前面的疑问,yield如何实现暂停功能,能产生什么样的结果,通过什么方式产生结果:
1、第一次调用next()方法,让生成器内部的代码开始运行。
1、当生成器内部的代码运行到yield处时,yield表达式后面代码的执行将处理暂停状态。
2、yied在next()方法的“通知”下,恢复yield表达式后面代码的执行,并“通知”next()方法产生了迭代器结果对象。
由此,我们可以发现:yield与next()方法的配合使用,让生成器内部构建了一个“双向消息传递系统”。
对于这个“双向消息传递系统”,前面的例子可以用下面的原理图表示:

需要注意的是:
1、在一次完整的迭代中,next() 方法的调用数量与yield的数量总是不匹配的,需要的next()方法会比yield语句多一个。
2、第一个next()方法总是启动一个生成器,并让生成器内部的代码运行到第一个yield处,并使程序处于暂停状态。
3、第二个next()方法会使第一个处于暂停的yield恢复后面代码的运行,直到代码运行到第二个yield处,并使程序再次处于暂停状态。
4、以此类推,第三个next()方法使第二个处于暂停状态的yield恢复后面代码的运行,直到生成器结束。
5、生成器可以一次或多次启动和停止,并不一定非得要完成。
三、深入理解生成器的yield关键字与next()方法构成的“双向消息传递系统”
生成器的“双向消息传递系统”不仅仅具有“解除暂停状态”和“生成迭代器结果对象”的功能,yield关键字和next()方法之间还可以传递参数。下面来看一个更复杂的例子:
//next()方法和yield关键字之间传递参数
function *foo(x) {
let y = x * (yield 5);
return y;
}
let it = foo(6);
//启动foo()
let res = it.next(7);
console.log(res.value,res.done);//输出什么?
res = it.next(8);
console.log(res.value,res.done);//输出什么?
在这个例子中,最让人迷惑的就是
let y = x * (yield 5);
这里的yield 后面,还跟了一个数字5,二者构成了一个表达式。问题来了:
1、这个表达式(yield 5)会参与前面的x的乘法运算吗?
2、生成器函数*foo()通过return关键字返回了y的值,这个值返回到哪里去了?
先来看两次调用console.log(res.value,res.done)的结果:
5 false
48 true
第一次,迭代器结果对象res的value值为5,done值为false;第二次迭代器结果对象res的value值为48,done值为true。这是不是和想象中不一样?
下面,来分析一下这个程序的运行步骤:
1、执行let it = foo(6);时,创建了一个生成器对象(也是一个迭代器对象),把它赋给一个变量it。
2、执行let res = it.next(7);时,第一次调用了生成器对象it的next()方法,并创建了一个迭代器结果对象,把它赋给一个变量res。这里需注意的是:第一次调用next()方法,next()方法仅仅是指示生成器运行内部的代码,所有向next()方法传递的参数都会被默默丢弃。这就是为什么it.next(7)里,7这个数值没有参与运算的原因。
3、生成器*foo()内部,在let y = x * (yield 5);这个语句中,yield关键字的“暂停”作用域,不仅仅是在(yield 5)后面的代码,还包括“let y = x *”,也就是说,当生成器内部的代码遇到yield关键字时,“let y = x *”连同(yield 5)后面的代码都会暂停。
这一点不太好理解,如果把程序修改一下:
function* foo(x) {
console.log(x);//增加了打印变量x的功能,这行语句不受yield暂停作用的影响
let y = x * (yield 5);
console.log(y);//增加了打印变量y的功能,这行语句会受yield暂停作用的影响
return y;
}
let it = foo(6);
//启动foo()
let res = it.next(7);//打印出x的值6
console.log(res.value,res.done);//打印出value的值5,done的值false
res = it.next(8);//打印出y的值48
console.log(res.value,res.done);//打印出value的值48,done的值true
修改的代码,分别在let y = x * (yield 5);的前后,分别增加了打印变量x和y的功能,执行let res = it.next(7);时,第一次调用了生成器对象it的next()方法,在执行console.log(x);之后,遇到yield关键字,“let y = x *”连同后面的console.log(y);都处于暂停状态。
4、在表达式(yield 5)中,实际上数值5并不会参与“let y = x *”的运算。如前面的“双向消息传递系统”原理图,yield会通知第一次调用的next()方法,产生一个value属性为5的值。由于迭代还没结束,所以value的值5,done的值false。
5、在“let y = x *”处于暂停状态时,这个表达式期待下一次调用next()方法时,yield能提供一个值,以参与同x的乘法运算,并将运算结果赋给变量y。我们假定这个值是z,那么yield关键字之前的表达式可以假想为:let y = x * z。
6、执行res = it.next(8);时,第二次调用了生成器对象it的next()方法,这次由next()方法将数值8传送给yield关键字,假象的表达式let y = x * z就成为let y = 6 * 8 ,y值最终为48。并且恢复console.log(y);的运行。
7、最后一个return y,实际上是将y值返回给了第二次调用的next()方法,最后产生迭代器结果对象value属性的值48,done属性的值true。迭代结束。
需要注意的是:
1、ES6规范和所有兼容浏览器都会默默丢弃传递给第一个next()方法的任何东西,因此启动生成器(第一次调用next()方法)时最好不要用带参数的next()。
2、yield表达式中,yield关键字后面如果不带任何值(数值、字符串或者由其他表达式产生的值),默认会向next()传递undefined的value值;yield关键字后面如果有值(数值、字符串或者由其他表达式产生的值,比如yield "hello"),会向next()传递该值并将其作为value的值。
3、yield表达式中,yield关键字前面如果有表达式(如前面提到的“let y = x *”),那么这个表达式在yield对其产生暂停作用的时候,期待yield提供一个参与该表达式运算的值,这个值将由非第一个next()方法(第二个、第三个,以此类推)用带参数的方式提供。
4、通过return关键字退出的生成器会处于done:true状态。但return不是生成器必需的,如果没有return,也将以隐式的/假定的方式返回undefined(也就是value:undefined)。
5、不能使用箭头函数来定义生成器函数。
503

被折叠的 条评论
为什么被折叠?



