原文地址:The Basics Of ES6 Generators
作者简介:Kyle Simpson is an Open Web Evangelist from Austin, TX, passionate about all things JavaScript. He's an author, workshop trainer, tech speaker, and OSS contributor/leader.
JavaScript ES6 最令人兴奋的新特性中有一个是一种新类型函数,叫作 generator。名字有点奇怪,但是第一次看它的行为表现时会感到更奇怪的。本文旨在解释 generator 函数如何工作的基础知识,并帮助你理解为什么它们对于 JS 的未来会如此强大。
Run-To-Completion
在我们讨论 generators 函数时,首先要注意的是他们 “从执行到完成” 所期望的结果与标准的函数有什么不同。
无论你是否意识到,你总是会为你的函数假设一些基础的功能:当函数开始执行时,它总是走从执行到完成这个过程,在此之前,其它的 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 循环将会执行很长时间,肯定超过了 1ms ,但是我们使用定时器回调执行console.log(.. )
时,是无法打断 for 循环的,所以它被卡在了执行栈(事件轮询)的后面,并且耐心等待被翻牌子(_)。
如果 foo() 可以中断呢?这不会对我们的程序造成破坏吗?
这正是多线程编程的挑战,但是,非常幸运,我们在 JavaScript 环境中不必担心这样的事情,以为 JS 总是单线程的(在给定的时间只有一个命令/函数在执行)。
Note: Web Workers 是一种机制,利用这种机制你可以为 JS 程序的一部分独立出一个完整的线程,它完全与 JS 程序的主线程并行运行。这样操作并不会将多线程的问题引入到我们的程序中,因为两个线程只能通过正常的异步事件相互通信,而且这些异步事件都会遵循 one-at-a-time 行为准则——执行到完成。
Run...Stop...Run
有了 ES6 generators ,我们就有了一种不同类型的函数,它们可以在执行的过程中暂停一次或者多次,并且之后还可以恢复,在这些暂停期间还可以运行其它代码。
如果你读过任何关于并发或者线程编程的东西,你可能已经看到了 cooperative(协作式) 一词,这基本上表明一个过程(在我们的例子中是一个函数)本身就会选择什么时候允许中断,这样它就可以配合其它代码了。这个概念与 preemptive(抢占式) 形成对比,这表明 过程/函数 可能会违背其意愿。
ES6 generator 函数的并发行为是 cooperative(协作式)。在 generator 函数体内部,你可以使用新的 yield 关键字从内部暂停函数。在 generator 函数外部是无法暂停它的。当 generator 函数遇到 yield 关键字时,就回家暂停。
然而,一旦 generator 暂停,它就不能自行恢复了。必须使用外部控制来重启 generator。我们一会将会解释这种情况。
因此,基本上,generator 函数可以根据你的需要停止和重新启动任意多次。事实上,你可以使用无限循环(如臭名昭著的 while(true){}
)指定一个 generator 函数,这个循环本质上是不会结束的。在 JS 程序中,这样做通常是疯狂的或者错误的,但是使用 generator 函数是完全理智的,有时候甚至是你想要做的。
更重要的是,这种停止和启动不仅仅是对 generator 函数执行的控制,而且在它进行的过程中,还使得 2-way(双向)消息传进和传出 generator 函数。使用正常的函数,你会在函数起始处获取参数,然后在最后获取返回值。使用 generator 函数,你可以在每个 yield 暂停的时候传递出消息,然后在每次重新启动的时候传进去消息。
Syntax Please!
现在让我们来讨论令人兴奋的 generator 函数的语法。
首先,新的声明语法:
function *foo() {
// ..
}
看到那个 *
了吗?看起来有点怪异。对于那些学习其它语言的人来说,它看起来很槽糕,就像一个函数返回指针类型一样。但是不要被迷惑了!这只是标识 generator 函数是一种特殊的函数类型而已。
你可能已经看过其它的文章/文档,它们可能使用 function* foo(){}
来代替 function *foo(){}
(仅仅是 * 号的位置不同)。两者都是有效的,但我最近决定支持 function *foo(){}
这种写法,因为它更准确,所以这里我使用的这种写法。
现在,让我们来讨论一下 generator 函数的函数体。 Generator 函数在大多数方面和普通函数是一样的。在 generator 函数内部只有很少的新语法需要学习。
如上所述,我们主要的新玩具就是 yield
关键字。yield__
被称为 “ yield 表达式”(而不是一个语句),因为当我们重启 generator 时,我们将会传入一个值,并且我们传入的值将是改yield__
表达式的计算结果。
栗子:
function *foo() {
var x = 1 + (yield "foo");
console.log(x);
}
当 generator 函数在 yield
关键字处暂停时,yield "foo"
表达式将会把 "foo"
字符串传递出来,并且无论何时(如果有)generator 函数重启时,传递进去的任何值都将是该 yield
表达式的结果,然后加 1 ,赋值给变量 x。
看到双向通信了吗?你将 “foo” 字符串传递出来,暂停函数,稍后(可能会立即,可能会很长时间)generator 重启,并将会传递进来一个值。几乎就像 yield
关键字做了一个值得请求一样。
在任何表达式的位置,你可以在表达式/语句中单独使用 yield
,而且假定 undefined
将在yield
结束时返回。所以:
// note: `foo(..)` here is NOT a generator!!
function foo(x) {
console.log("x: " + x);
}
function *bar() {
yield; // just pause
foo( yield ); // pause waiting for a parameter to pass into `foo(..)`
}
Generator Iterator
"Generator Iterator"。相当的绕口,有没有?
迭代器是一种特殊的行为,实际上它是一种设计模式,我们通过 next()
来遍历一组有序的值。想象一下,例如在其中有五个值的数组上使用迭代器:[1, 2, 3, 4, 5]。第一个 next() 调用将返回 1,第二个 next() 调用将返回 2,以此类推。在返回所有值之后,next() 将返回 null 或 false,或以其他方式向您发出信号,表示你已经迭代了数据容器中的所有值。
我们从外部控制 generator 函数方式是构造一个 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() 调用中会获取到一个对象,该对象对于 yielded-out 的值有一个 value 属性,而 done 属性是一个布尔值,表示 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 }
有趣的是,当我们得到值 5 时,done 的值仍然是 false。因为从技术上来看,generator 函数还没有完成。我们仍然可以调用最后一个 next(),如果我们传入一个值,它将必须被设置为yield 5
表达式的结果。只有这样,generator 函数才算执行完成。
所以现在:
console.log( it.next() ); // { value:undefined, done:true }
所以,我们的 generator 函数最终结果是我们完成了这个函数,但没有得到结果(因为我们已经耗尽了所有的 yield__ 语句)。
你可能会这样想,在 generator 函数中我是否可以使用 return 返回值呢?如果我这样做了,那么该值将会被设置为 value 属性的值吗?
Yes ...
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 }
... and no.
在 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 );
// note: not sending anything into `next()` here
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 }
你可以看到,在我们初始化迭代器实例时,仍然可以使用 foo(5)
传递参数(在我们的示例中为 x),就是正常函数一样,使 x 的值为 5。
第一次 next() 调用,我们可以不传入任何值。为什么?因为没有 yield 表达式接收我们传入的东西。
但是,如果我们确实在第一次调用 next() 时传入了值,不会发生什么不好的事情的。这只是一个将会被抛弃的值。ES6 表示在这种情况下, generator 函数会忽略无用的值的。(注意:在撰写本文时,Chrome 和 FF 浏览器都很好,但其他浏览器可能尚未完全符合标准,并且在这种情况下可能会错误地抛出错误)。
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 就是最后的返回值。
多读几次,第一次看到它多少会感到有些奇怪的。
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()
创建的迭代器由 foo .. of
循环自动捕获,并为每个值自动迭代一次,直到 done: true
。只要 done: false
,它将自动提取 value
属性并将其赋值给你的迭代变量(在本例中为 v)。一旦 done 为 true,则循环迭代停止(并且不返回任何最终值,如果有的话)。
如上所述,你可以看到 for .. of
循环忽略并抛弃了 return 6
。此外,由于没有暴露的next()
调用,因此在需要将值传递给 generator 函数的情况下,for .. of
循环不能用于上述操作。
Summary
好的,这就是 generator 函数的基础知识。如果仍然觉得有些令人费解,不要担心,刚开始的时候我们都这样。
很自然的想知道这个新的外来玩具将会为你的代码做些什么。不过,它们还有更多的东西等待我们的探索,我们只是刚刚触摸到皮毛。所以我们必须更加的深入,才能发现它们能够/将会有多么的强大。
在运行上述代码片段(试着用 Chrome 或者 FF 又或者 node 0.11+ with --harmony)之后,可能会出现以下问题:
- 错误处理如何工作?
- 一个 generator 函数如何调用另一个?
- 异步编码如何和 generator 函数配合使用?
等等这些问题,将在这一系列后面的文章中结束,敬请期待!