原文地址:http://davidwalsh.name/es6-generators
ES6生成器全部文章:
- The Basics Of ES6 Generators
- Diving Deeper With ES6 Generators
- Going Async With ES6 Generators
- Getting Concurrent With ES6 Generators
Generator function是ES6带来的新功能之一。这个名字看起来很怪异,然而它的功能在接触之初看起来更加怪异。这篇文章的目标是另读者对ES6生成器有初步的了解,并且使你感受到为什么它将成为JavaScript中非常强大的一部分。
阻塞式运行
首先,我们从JavaScript函数最基本的原则谈起,对比阻塞式(Run-to-Completion)的常规运行方式来说,ES6生成器有何不同?
所谓的阻塞式运行方式,指的是JavaScript中一个函数一旦开始运行,JavaScript线程便会被此函数阻塞,等待此函数运行完成后才会运行其他代码逻辑。
举个栗子:
setTimeout(function(){
console.log("Hello World");
},1);
function foo() {
// NOTE: don't ever do crazy long-running loops like this
for (var i=0; i<=1E10; i++) {
console.log(i);
}
}
foo();
// 0..1E10
// "Hello World"
上述代码中的for
循环会消耗相当长的时间运行完成,绝对会超过1ms
,也就是setTimeout()
中设置的等待时间,但是setTimeout()
中的console.log("Hello World")
方法并不会打断foo()
的运行,只能被加入到等待队列中延后执行。
但是如果foo()
的运行可以被打断呢?这样做是否会使我们的程序崩溃?
对于一些多线程编程语言来说,这种情况确实令人头疼,但是工作于JavaScript领域的同僚根本无需担心,因为JavaScript始终是单线程运行的。
备注:HTML5中的Web Worker机制允许我们建立一个独立于JavaScript主线程的并行线程。但是我并不推荐在JS中使用多线程,因为通过Web Worker建立的独立线程与主线程之间的通信只能利用常规的异步事件来实现,而异步事件与上例中的setTimeout()
一样,是可以被阻塞的。
运行-暂停-运行
ES6生成器为我们带来了一种新型解决方案:生成器是一种与常规function
完全不同的function
,它的运行可以被多次暂停和恢复,并且JavaScript可以在生成器暂停期间可以运行其他代码。
如果你了解任何一门多线程语言,你会知道多线程的特点:“协作”。所谓的协作是指一个线程本身可以选择何时被中断,以便与其他代码协作。协作的概念通常与优先权这个词相关,通俗的讲,就是一个线程可以违背自己意愿地被打断。
ES6生成器的目的便是与并行代码协作运行。在生成器function
内部,可以通过yield
关键字自内部暂停运行。请注意,生成器function
外部的代码是不能暂停它的,只有它本身可以用yield
来暂停自己。
然而,一旦生成器函数被自己暂停,它是无法使自己恢复运行的,需要生成器外部来控制。稍后将详细介绍这种工作机制。
理论上,生成器函数可以被无限次地暂停和恢复,你可以用一个无限循环(比如臭名昭著的while(true){...}
)来操作它。在常规的JS程序中,无限循环会造成严重的混乱甚至错误,但是如果与生成器函数配合,无限循环会非常顺畅地运行,甚至有时候我们正需要它!
还有重要的一点,可被暂停和恢复并不仅仅是生成器函数的全部功能,它还可以在执行中允许信息的双向传递与输出(2-way message passing into and out)。要想实现这种功能,在常规JavaScript中,我们通常为function
设置多个参数,在函数起始读取参数并在结尾return
结果。在生成器函数中,我们可以通过yield
输出结果信息,在被恢复的时候接受信息作为参数。
使用语法
废话不多说,开始使用吧!
首先,生成器函数的声明语法如下:
function *foo() {
// ..
}
请注意特殊符号*
,看起来很新奇是不是?在很多高级语言里,上面的语法看起来就像是一个返回指针类型的函数。但是在JavaScript中,上面的代码声明了一个特殊类型的函数-生成器函数。
你以前可能阅读过其他相关文献用function* foo(){}
而不是function *foo(){}
来声明生成器函数(请注意*
的位置),这两种写法都是可以的,我个人比较推崇后面一种。
下面我们讨论一下生成器函数的内部结构。生成器函数在很多方面与常规函数相似,内部结构语法是他们的区别之一。
首先解释一下之前我们提到的yield
关键字。带有yield
关键字的语句被称为yield表达式(请注意:是yield表达式 而不是 yield状态),一旦恢复生成器函数运行,我们将会给生成器函数传递一个参数,不论这个参数是什么,它都将作为yield表达式的计算结果。
举个栗子:
function *foo() {
var x = 1 + (yield "foo");
console.log(x);
}
上面的生成器函数被暂停的时候,表达式yield "foo"
将输出"foo"
字符串,一旦函数*foo()
被恢复运行,不论我们传递什么数值,这个数值都将作为表达式yield "foo"
的结果与1
相加并赋值给x
。
译者注:上面这段举例说明。假设生成器函数foo()
被暂停后恢复运行的时候,我们传递一个值2
给它,那么2
将作为表达式yield "foo"
的结果,2
将与1
相加,计算结果3
被赋值给x
。
这种信息的双向传递是不是很有趣?生成器函数首先输出字符串"foo"
,暂停,然后在某一时刻(可以是立即,也可以在很久之后)被恢复运行后又可以接收新的传入值。这种机制看起来就像yield
关键字是一个新值的请求发起者(sort of making a request for a value)。
你可以再生成器函数的任何位置使用yield
表达式,如果没有指定输出值,将会输出undefined
。举个栗子:
// 注意: 这个foo(..)只是一个常规函数!
function foo(x) {
console.log("x: " + x);
}
function *bar() {
yield; // 只为暂停作用
foo( yield ); // 暂停后等待一个新的传入值作为foo()函数的参数
}
生成器迭代器
"Generator Iterator",听起来很拗口是吧?
迭代器是一种设计模式,通过next()
方法逐次地访问队列中的值。举个具体的例子,如果对一个数组[1,2,3,4,5]
使用迭代器。第一次运行next()
方法会返回数值1
,第二次运行next()
返回数值2
,以此类推。那么当数组中所有的元素都被返回之后,再次运行next()
方法,将会返回null
或者false
或者你自行设定的迭代结束标记值。
我们通过构造一个生成器迭代器,并与它交互来实现从生成器函数外部控制它(The way we control generator functions from the outside is to construct and interact with a generator iterator)。这句话听起来很难懂,其实很简单,举个栗子:
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
为了逐次访问生成器函数*foo()
内的数值,我们需要构建一个迭代器:
var it = foo();
注意:用上述常规函数的运行语句foo()
来运行生成器函数,实际上并没有执行它。
你可能会对以上的语法产生疑惑:为什么不用var it = new foo()
来声明一个实例?原因很复杂也很晦涩,超出了我们讨论的范畴,感兴趣的话可自行查阅资料。
现在,开始运行代器:
var message = it.next();
我们将得到返回值1
,也就是表达式yield 1
的输出结果,但是数值1
并非我们得到的返回值的全部内容:
console.log(message); // { value:1, done:false }
实际上,我们每次运行next()
方法后都将得到一个Object,'value'属性是生成器函数中yield
表达式的输出结果,done
属性是一个boolean
值,标识迭代器是否已全部运行结束。
继续运行迭代器:
console.log( it.next() ); // { value:2, done:false }
console.log( it.next() ); // { value:3, done:false }
console.log( it.next() ); // { value:4, done:false }
console.log( it.next() ); // { value:5, done:false }
奇怪的现象发生了:我们得到最后数值5
的时候,done
仍然是false
!这是由迭代器的运行原理造成的,在得到最后数值5
后,迭代器实际上并没有完全运行结束,我们需要再运行一次next()
方法,如果我们传入一个参数,那么这个参数将作为yield 5
表达式的输出结果(并不返回),此时,迭代器才真正的完成全部运行任务。代码如下:
console.log( it.next() ); // { value:undefined, done:true }
此时,我们完成了迭代器的全部运行任务,但是并没有任何结果value
输出(因为我们已经执行了所有的yield表达式)。
读到这里你可能会疑惑:可以在生成器函数中使用return
关键字吗?如果可以的话,那么return
的结果可以被作为value
输出吗?
答案是:有时候可以,比如:
function *foo() {
yield 1;
return 2;
}
var it = foo();
console.log( it.next() ); // { value:1, done:false }
console.log( it.next() ); // { value:2, done:true }
但是有时候却不可以。
我不建议在生成器函数中使用return
关键字来返回结果,因为在使用for...of
循环迭代生成器时,生成器内部使用return
的值将会被过滤。下面举例说明。
我们举个完整的例子:每次迭代生成器函数的时候都读取并传入新参数。代码如下:
function *foo(x) {
var y = 2 * (yield (x + 1));
var z = yield (y / 3);
return (x + y + z);
}
var it = foo( 5 );
//注意:第一次云心next()没有传入任何值
console.log( it.next() ); // { value:6, done:false }
console.log( it.next( 12 ) ); // { value:8, done:false }
console.log( it.next( 13 ) ); // { value:42, done:true }
可以看到,初始运行生成器函数的时候,我们可以像常规函数一样传入参数(上例中的x
)。
第一次调用next()
方法的时候并没有传入任何参数。为什么?因为此时生成器函数中没有接收参数的yield表达式。
但是如果我们在第一次调用next()
的时候传入一个参数,会发声什么呢?什么都不会发生!被传入的参数将会被抛弃。ES6会告知生成器函数抛弃这种情况下的传参。(注意:原作者在写这篇文章的时候,Chrome和FF的运行结果如上所述,但其他浏览器会抛错。)
然后,第二个next(12)
传入12
作为第一个yield表达式yield (x + 1)
的输出,递三个next(13)
传入13
作为第二个yield表达式yield (y / 3)
的输出。请重点阅读此段。
译者注:上面这个例子第一次读没有理解原作者的意思,我用自己的理解重新解读一下。
-
var it = foo( 5 )
给生成器函数传入参数x=5
。 - 第一次调用
next()
的时候,根据生成器的原理,将返回yield(x+1)
的结果6
,请注意,并不是返回var y = 2 * (yield (x + 1))
,此时y=12
。 - 第二次调用
next(12)
时,12
作为yield(x+1)
的值,此时y=2*12
,也就是y=24
,那么这时候对外的返回结果是yield(y/3)
的计算值,也就是24/3=8
。 - 第三次调用
next(13)
时,13
作为yield(y/3)
的值,所以此时z=13
,那么现在的对外返回值就成了return (x + y + z)
的计算结果,也就是5+24+13=42
。 - 为什么说如果第一次
next()
传参会被忽略呢?如果读者理解了生成器原理就很容易解释了,生成器中的yield
表达式的执行时机是生成器函数暂停后被恢复时。第一次调用next()
的时候,生成器是初始运行,并没有被暂停,此时yield表达式是不能接收参数的。
for..of
ES6在语法层面提供了对迭代模式的支持,如下面中用for..of
循环执行迭代器:
function *foo() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
return 6;
}
for (var v of foo()) {
console.log( v );
}
// 1 2 3 4 5
console.log( v ); // still `5`, not `6`
上例中,生成器函数foo()
的迭代器通过for..of
循环被逐次执行,每次迭代输出一个数值,直到标识done:true
。for..of
循环中的值v
输出生成器函数的每个数值而不是Object,一旦done:true
,循环迭代便会结束(请注意此时return
的值6
被抛弃了)。
for..of
循环也有缺陷:它不能实现每次迭代向生成器函数传参。
结语
到此,我们对于生成器的简单介绍就结束了。如果仍然觉得费解,可以试着多读几遍或者参阅相关材料。
我想大家在面临这样一个全新概念的时候,除了困惑以外,我们会好奇:它会对未来的实际开发工作带来什么样的影响?我相信生成器的作用不仅仅只有这篇文章介绍的内容,我们只是看到了非常表面的东西。更深层的知识需要不断地被发掘。
阅读完以上的内容以后,你可能会觉得跃跃欲试,请使用最新的Chrome和FF来实验,并且NodeJS 0.11以上的版本也支持。
这篇文章留给我们一下几个问题:
- 如果进行错误处理?
- 生成器可以互相调用吗?
- 如果利用生成器进行异步工作?
上面的问题我(原作者)会相继在博客中解答,so,粉我吧(顺便粉我也行)。
译者注:生成器函数的兼容性情况请参考https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*