ES6 Generators系列:
在JavaScript ES6提供的诸多令人兴奋的新特性中,有一个新函数类型,叫generator。名字听起来很怪(我们姑且将它称之为生成器函数),而且行为更加让人觉得怪异。本文旨在解释generator函数的一些基本知识,用来说明它是如何工作的,并帮助你了解为什么它会让未来的JS变得如此强大。
运行-完成(Run-To-Completion)
首先我们要讨论的是generator函数和普通函数在运行方式上有什么区别。
不论你是否已经意识到了,对于函数而言,你总是会假定一个原则:一旦函数开始运行,它就会在其它JS代码运行之前运行到结束。这句话怎么理解呢?看下面的代码:
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循环需要一个比较长的时间来执行完,显然超过1毫秒。在foo()函数运行过程中,上面的setTimeout函数不会被运行直到foo()函数运行结束。
那如果事情不是这样的会怎么样?如果foo()函数的运行会被setTimeout打断呢?是不是我们的程序将会变得不稳定?
在多线程运行的程序中,这的确会给你带来噩梦,好在JavaScript是单线程运行的(同一时间只有一条命令或函数会被运行),因此这一点你不必担心。
注意,Web开发允许JS程序的一部分在一个独立的线程里运行,该线程可以与JS主线程并行运行。但这并不意味着我们可以在JS程序中引入多线程操作,因为在多线程操作中两个独立的线程之间是可以通过异步事件相互通信的,它们彼此之间通过事件轮询机制(event-loop)一次一个地来运行。
运行-停止-运行(Run-Stop-Run)
ES6的generator函数允许在运行的过程中暂停一次或多次,随后再恢复运行。暂停的过程中允许其它的代码执行。
如果你曾经读过有关并发或者线程编程方面的文章,你也许见到过"cooperative"(协作)一词,它说明了一个进程(这里可以将它理解为一个function)本身可以选择何时被中断以便与其它代码进行协作。这个概念与"preemptive"(抢占式。进程调度的一种方式。当前进程在运行过程中,如果有重要或紧迫的进程到达(其状态必须为就绪),则该进程将被迫放弃处理机,系统将处理机立刻分配给新到达的进程。)正好相反,它表明了一个进程或function可以被其自身的意愿打断。
在ES6中,generator函数使用的都是cooperative类型的并发方式。在generator函数体内,通过使用新的yield关键字从内部将函数的运行打断。除了generator函数内部的yield关键字,你不可能从任何地方(包括函数外部)中断函数的运行。
不过,一旦generator函数被中断,它不可能自行恢复运行,除非通过外部的控制来重新启动这个generator函数。稍后我会介绍如何实现这一点。
基本上,按照需要,一个generator函数在运行中可以被停止和重新启动多次。事实上,你完全可以指定一个无限循环的generator函数(就像while(true){...}语句一样),它永远也不会被执行完。不过在一个正常的JS程序中,我们通常不会这样做,除非代码写错了。Generator函数足够理性,有时候它恰恰就是你想要的!
而更重要的是,这种停止和启动不仅仅控制着generator函数的执行,它还允许信息的双向传递。普通函数在开始的时候获取参数,在结束的时候return一个值,而generator函数可以在每次yield的时候返回值,并且在下一次重新启动的时候再传入值。
语法
是时候介绍一下generator函数的语法了:
function *foo() { // .. }
注意这里的*了吗?这是一个新引入的运算符,对于学习C语言系的同学而言,可能会想到函数指针。不过这里千万不要把它和指针的概念混淆了,*运算符在这里只是用来标识generator函数的类型。
你可能在其它的文章或文档中看到这种写法function* foo(){},而本文中我们使用这种写法function *foo(){}(区别仅仅是*的位置)。这两种写法都是正确的,不过我们推荐使用后者。
我们来看看generator函数的内容。Generator函数在大多数方面就是普通的JS函数,因此我们需要学习的新语法不会很多。
在generator函数体内部主要是yield关键字的应用,前面我们已经提到过它。注意这里的yield ___被称之为yield表达式而不是语句,这是因为当我们重新启动generator函数时,我们会传入一个值,而不管这个值是什么,都会作为yield ___表达式计算的结果。
一个例子:
function *foo() { var x = 1 + (yield "foo"); console.log(x); }
这里的yield "foo"表达式会在generator函数暂停时返回字符串"foo",当下一次generator函数重新启动时,不管传入的值是什么,都会作为yield表达式计算的结果。这里会将表达式1 + 传入值的结果赋值给变量x。
从这个意义上来说,generator函数具有双向通信的功能。Generator函数暂停的时候返回了字符串"foo",稍后(可能是立即,也可能是从现在开始一段很长的时间)重新启动的时候它会请求一个新值并将最终计算的结果返回。这里的yield关键字起到了请求新值的作用。
在任何表达式中,你可以只用yield关键字而不带其它内容,此时yield返回的值是undefined。看下面的例子:
// 注意,这里的函数foo(..)不是一个generator函数!! function foo(x) { console.log("x: " + x); } function *bar() { yield; // 暂停执行,返回值是undefined foo( yield ); // 暂停执行,稍后将获取到的值作为函数foo(..)的参数传入 }
Generator遍历器
“Generator遍历器”!乍一看,好像很难懂!
遍历器是一种特殊的行为,实际上是一种设计模式,我们通过调用next()方法来遍历一组有序的值。想象一下,例如使用遍历器对数组[1,2,3,4,5]进行遍历。第一次调用next()方法返回1,第二次调用next()方法返回2,以此类推。当数组中的所有值都返回后,调用next()方法将返回null或false或其它可能的值用来表示数组中的所有元素都已遍历完毕。
我们唯一可以从外部控制generator函数的方式就是构造和通过遍历器进行遍历。这听起来好像有点复杂,考虑下面这个简单的例子:
function *foo() { yield 1; yield 2; yield 3; yield 4; yield 5; }
为了遍历generator函数*foo(),首先我们需要构造一个遍历器。怎么做?很简单!
var it = foo();
事实上,通过普通的方式调用一个generator函数并不会真正地执行它。
这有点让人难以理解。你可能在想,为什么不是var it = new foo(). 背后的原理已经超出了我们的范围,这里我们不展开讨论。
然后,我们通过下面的方法对generator函数进行遍历:
var message = it.next();
这会执行yield 1表达式并返回值1,但不仅限于此。
console.log(message); // { value:1, done:false }
事实上每次调用next()方法都会返回一个object对象,其中的value属性就是yield表达式返回的值,而属性done是一个boolean类型,用来表示对generator函数的遍历是否已经结束。
继续看剩余的几个遍历:
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 }
有趣的是,当value的值是5时done仍然是false。这是因为从技术上来说,generator函数还没有执行完,我们必须再调用一次next()方法,如果此时传入一个值(如果未传入值,则默认为undefined),它会被设置为yield 5表达式计算的结果,然后generator函数才算执行完毕。
因此:
console.log( it.next() ); // { value:undefined, done:true }
所以,最终的结果是我们完成了generator函数的调用,但是最后一次的遍历并没有返回任何值,这是因为所有的yield表达式都已经被执行完了。
你或许在想,我们可以在generator函数中使用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 }
但是:
依赖generator函数中return语句返回的值并不值得提倡,因为当使用for..of循环(下面会介绍)来遍历generator函数时,最后的return语句可能会导致异常。
我们来完整地看一下在遍历generator函数时信息是如何被传入和传出的:
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 }
你可以看到我们在构造generator函数遍历器的时候仍然可以传递参数,这和普通的函数调用一样,通过语句foo(5),我们将参数x的值设置为5。
第一次调用next()方法时,没有传入任何值。为什么呢?因为此时没有yield表达式来接收我们传入的值。
如果在第一次调用next()方法时传入一个值,也不会有任何影响,该值会被抛弃掉。按照ES6标准的规定,此时generator函数会直接忽略掉该值(注意:在撰写本文时,Chrome和FireFox浏览器都能很好地符合该规定,但其它浏览器可能并不完全符合,而且可能会抛出异常)。
表达式yield(x + 1)的返回值是6,然后第二个next(12)将12作为参数传入,用来代替表达式yield(x + 1),因此变量y的值就是12 × 2,即24。随后的yield(y / 3)(即yield(24 / 3))返回值8。然后第三个next(13)将13作为参数传入,用来代替表达式yield(y / 3),所以变量z的值是13。
最后,语句return (x + y + z)即return (5 + 24 + 13),所以最终的返回值是42。
多重温几次上面的代码,开始的时候你会觉得很难懂,只要理解了generator函数执行的过程,掌握起来并不难。
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 ); // 仍然是5,而不是6
正如你所看到的,由foo()创建的遍历器被for..of循环自动捕获,然后自动进行遍历,每遍历一次就返回一个值,直到属性done的值为true。只要属性done的值为false,它就会自动提取value属性的值并将其传递给迭代变量(本例中为变量v)。一旦属性done的值为true,循环遍历就停止(而且不会包含函数的返回值,如果有的话。所以此处的return 6不包括在for..of循环中)。
如上所述,可以看到for..of循环忽略并抛弃了返回值6,这是因为此处没有对应的next()方法被调用,for..of循环不支持将值传递给generator函数迭代的情况,如在for..of循环中使用next(v)。事实上,在使用for..of循环时不需要使用next方法。
总结
以上就是generator函数的基本概念。如果你仍然觉得有点难以理解,也不用太担心,任何人刚开始接触generator函数时都会有这种感觉!
你应该会很自然地想到generator函数能在自己的代码中起到什么样的作用,尽管我们会在很多地方用到它。我们刚刚只是接触到了一些皮毛,还有很多需要了解的,所以我们必须深入研究,才能发现它是如此的强大。
尝试在Chrome nightly/canary或FireFox nightly或node 0.11+(使用--harmony参数)环境中运行本文的示例代码,并思考下面的问题:
- 如何处理异常?
- 在一个generator函数中可以调用另一个generator函数吗?
- 如何在generator函数中进行异步编程?
接下来的文章会解答上述问题,并继续深入探讨有关ES6 generator函数的内容,敬请关注!