ES6生成器基础

JavaScript ES6中最激动人心的新特性之一,是一种新的函数,被称为生成器。 这个名字有点陌生,但是其行为可能在初次目睹时更为怪异。本文的目的就是解释 生成器的基本运作原理,帮助你理解为什么对于JS的未来而言,它们是如此的强大。

Run-To-Completion

当我们谈到生成器要考察的第一件事,就是在运行到结束方面,它们和普通的函数有何区别。

不管你是否意识到,你经常能够对函数作出一些相当基本的假设:一旦函数开始执行, 它就会一直执行到结束,在此之前其他任何JS代码都不能运行。

看一个小例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
setTimeout(function(){
console.log("Hello World");
},1);

function foo() {
//注意:千万不要像这样搞些疯狂的长时间运行的循环
for (var i=0; i<=1E10; i++) {
console.log(i);
}
}

foo();
// 0..1E10
// "Hello World"

在这里,for循环将花费相当长的时间才能够完成,长于1毫秒,但是我们的定时器 回调——其中包含有console.log(..)语句——无法打断foo()函数的执行,因此它就 被阻塞,只能耐心等待循环完成。

但是,如果foo()的执行可以被中断,会怎么样?那不会给我们的程序带来灾难吗?

对于多线程编程而言,这必然带来噩梦般的挑战,但是我们相当幸运的是,在JavaScript的领地 不需要担心这样的事情,因为Js总是单线程执行 —— 在任何特定时间只有一个命令/函数执行。

注意:Web Workers机制让你可以启动一个完全隔离的线程来运行一部分JS程序,完全 并行于你的JS主程序线程。而这不会给我们程序带来多线程复杂性的原因在于,两个线程 之间只能够通过正常的异步时间通信,而这些事件总是遵循运行到结束原则所要求的 一次只运行一段代码的行为模式。

运行..停止..运行

有了ES6生成器,我们有了一种不同类型的函数,它可以在运行中间暂停一次或多次,然后 在晚些时候继续,这使得其他代码可以在它暂停期间运行。

如果你看过任何关于并发或线程编程的资料,你可能已经看到过这个词:合作,它基本的含义 是,一个进程(在我们这里就是:函数)它自己选择什么时候允许一个中断,这样就实现了它 和其他代码的合作。这个概念是相对于抢占式而言,抢占式的意思是一个进程/函数可以 被不自主的中断。

ES6生成器函数在并发行为方面是合作式的。在生成器函数体内,你使用一个新的yield 关键字来从函数内部暂停运行。无法从外部暂停一个生成器,只有它自己运行中碰到一个 yield时,才能够暂停自己。

但是,一旦一个生成器通过yield暂停了自己,它确不能自主的继续运行。必须使用一个 外部的控制来重新启动生成器。我们一会儿就会解释这是如何发生的。

因此,基本上,一个生成器函数可以停止,可以重新启动,可以随你的心情任意多次的停止 或重新启动。事实上,你可以定义一个具有无限循环(就像恶名昭著的while(true){..}) 的生成器函数,它永远也不会结束。虽然这很疯狂,或者就是一个正常的JS程序中的错误, 然而作为生成器函数,它是百分之百的理性实现,而且有时恰恰就是你想要做的。

更重要的是,这个停止和启动并不只是用于控制生成器函数的执行,它还使得外部代码和 生成器的双向消息传递成为可能。对于普通函数,你在开始的时候拿到参数,在结束的时候 返回一个值。而对于生成器函数,你可以使用yield向生成器外部发送消息,也可以在每次 重新启动时,从外部代码向生成器发送消息。

语法!

让我们深入这些全新的、令人激动的生成器函数的语法。

首先是,新的声明语法:

1
2
3
function *foo() {
// ..
}

注意到*没有?这是新的东西,看起来有点怪异。对于那些来自其他开发语言的人而言, 这东西看起来非常像一个函数返回值指针。不过别搞混了!这不过就是用来表示这个函数 是一个生成器函数而已。

你可能读过一些资料中使用function* foo(){}而不是function *foo(){},注意两种 书写方式中*的位置不同。这两种写法都是合法的,不过我最近决定,我认为function *foo(){} 更准确一些,因此我会遵循这个写法。

现在,让我们谈一下生成器函数的内容。生成器函数在很多方面就是普通的JS函数。在生 成器函数内部,只有非常少的新语法需要学习。

像前面谈到的,我们摆弄的新玩具是yield关键字。yield ___被称为yield表达式,它 不是一个语句,因为当我们重启生成器时,我们将把一个值发送回生成器,无论我们发送的 是什么,都将是那个yield ___表达式的计算结果。

看一个小例子:

1
2
3
4
function *foo() {
var x = 1 + (yield "foo");
console.log(x);
}

当暂停这个生成器的时候,yield "foo"表达式会发送出foo字符串,无论何时重新启动 生成器,无论什么值从外部传入生成器,这个值都将是yield表达式的结果,它被加1然后 赋给变量x

发现双向通信了吗?你把值foo发送出去,暂停自己,然后在晚些某个时候(可能是立刻, 也可能是很久以后!),生成器会被重新启动,会给回来一个值。几乎就像yield关键字 发送了一个对某个值的请求。

在任何表达式位置,你可以在表达式/语句中只使用yield自己,这意味着yield会发送出 一个未定义值。因此:

1
2
3
4
5
6
7
8
9
//注意: 这里的`foo(..)` 不是生成器!!
function foo(x) {
console.log("x: " + x);
}

function *bar() {
yield; // 只是暂停
foo( yield ); // 暂停并等待一个参数传入函数foo
}

生成器迭代器

”生成器迭代器“,相当拗口,对吧?

迭代器是一种特殊的行为,实际上是一种设计模式,我们通过调用next()来单步处理一个有序 集合中的每个值。相像一下,例如,在一个数组上使用迭代器,这个数组的值是:[1,2,3,4,5]。 第一次调用next()将返回1,第二次调用next()将返回2,依次类推。当所有的值都返回后, next()将返回空值,或者false,或者提示你已经将数据容器中的所有值迭代完了。

我们从外部控制生成器函数的方法是构造并使用一个迭代器生成器。这听起来相当复杂,实际上不是。 看一下这个小例子:

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

要单步走完那个*foo()生成器函数的所有值,我们需要构造一个迭代器。怎么做?简单!

1
var it = foo();

噢!因此,以普通方式调用生成器函数,并不会实际执行它的内容。

这有点奇怪,可能让你忍不住想,为什么不是var it = new foo()。 鬼知道!这些语法背后的 为什么太复杂了,超出了我们在这里讨论的范文。

好,现在启动我们生成器函数上的迭代,只需要:

1
var message = it.next();

这会从yield 1语句得到1,但是这不是我们拿到的唯一的东西。

1
console.log(message); // { value:1, done:false }

我们实际上从每一个next()的调用得到一个对象,它有一个value属性保存yield发出的值, done属性是布尔型的,代表生成器函数是否执行完毕。

让我们继续迭代:

1
2
3
4
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。这是因为,技术角度来讲, 这个生成器函数还没有完毕。我们还需要再调用一下next(),如果我们发送进去一个值,它 将被设置为yield 5表达式的结果。直到那时,才算生成器函数执行完毕。

因此,继续:

1
console.log( it.next() ); // { value:undefined, done:true }

因此,我们的生成器函数最终的结果是,我们执行完了函数,但是没有结果给出来(因为我们已经耗尽 了所有的yield ___语句)。

对于这一点你可能感到好奇,我可以在一个生成器函数中使用return吗?如果我这样做的话,那个值会 在value属性中发送出来吗?

正确…

1
2
3
4
5
6
7
8
9
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 }

.. 也不正确

依赖于生成器的返回值,可能不是一个好主意,因为当使用for..of循环对生成器函数进行迭代时(看下 面),最终的返回值将被丢弃。

出于完整性考虑,让我们看一下在进行迭代时,生成器函数的双向消息传递:

1
2
3
4
5
6
7
8
9
10
11
12
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 }

你可以看到,在初始的进行迭代器实例化的调用foo(5)时,我们依然可以传入参数(在我们的示例中:x), 就像普通的函数一样,让x的值为5。

第一个next(..)调用,我们没有发送进去任何东西。为什么?因为没有yield表达式可以接收我们穿进去的东西。

但是如果我们的确向第一个next(..)调用传入了一个值,也不会发生什么坏事。它就是 一个要被丢弃的值。ES6规范要求,在这种情况下,生成器函数应当忽略未使用的值(注意:在写这篇文章时, Chrome和FireFox的测试表明器符合规范,但其他浏览器可能不完全如此)。

yield (x + 1)发送出值6.第二个next(12)调用发送12给那个等待的yield (x+1)表达式,因此y被设置为 12*2,值24。后续的yeidl (y/3),即yeild (24/3)送出值8.第三个next(13)调用发送13给那个等待的 yield (y/3)表达式,将z设置为13。

最终,reurn (x + y + z)就是reurn (5 + 24 + 13),即最后返回42。

上面的文字,请多读几遍,它对大多数人都有点烧脑。

for..of

ES6在语法层面也拥抱了这个迭代器模式,将迭代器运行到结束 —— for..of

看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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,循环迭代就停止了,它不会对最终的返回值(如果有的话)进行任何处理。

就像前面的注意事项,你可以看到for..of循环忽略并丢弃了返回的值6。并且,由于 没有暴露出来的next()调用,for..of循环不能用于你需要向迭代器传入值的场景。

总结

好了,这就是生成器的基础。如果你还感到困惑,也别担心。因为我们在开始的时候 都是如此感觉!

很自然地会想到,这个新的、充满异域色彩的玩具,能给你的代码带来什么实际作用。 有很多。我们还只是刚刚接触了皮毛。因此,我们必须更深入一些,才能够发现这个 东西有多么强大。

在你尝试了上面的代码片段之后,可能会产生下面的问题:

  • 错误处理如何运作?
  • 一个生成器可以调用另一个生成器吗?
  • 异步代码如何使用生成器?

这些问题,以及更多的问题,都将在后续的文章中涵盖,因此,请继续关注!

原文:https://davidwalsh.name/es6-generators

推荐课程:ECMAScript 6入门