【译】ES6 生成器 - 1. ES6 生成器基础

1,368 阅读11分钟
原文链接: www.jianshu.com

原文地址:davidwalsh.name/es6-generat…

作者 Kyle Simpson,发布于 2014年7月21日

生成器(generator),作为一种新的函数,是JavaScript ES6 带来的最令人兴奋的新特性之一。名字或许有点陌生,不过初步了解之后你会发现,它的行为更加陌生。本文的目的是帮你了解生成器,并且让你认识到为什么对于 JS 的未来而言它是如此重要。

执行-结束

首先让我们来看下它相较于普通函数“执行-结束”模式的不同之处。

不知道你是否注意过,对于函数你一直以来的看法就是:一旦函数开始执行,它就会一直执行下去直到结束,这个过程中其他的 JS 代码无法执行。

示例:

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 Worker 机制可以将 JS 程序的一部分在一个单独的线程中执行,与 JS 主程序并行。之所以说这不会带来多线程的问题,是因为这两个线程可以通过普通的异步事件来彼此通信,仍然在事件循环的一次执行一个的行为模式下。

执行-停止-执行

ES6 生成器(generator)是一种不同类型的函数,可以在执行过程中暂停若干次,并在稍后继续执行,使得其他代码可以在其暂停过程中得到执行。

如果你对并发或线程编程有所了解,你可能听过“协作(cooperative)”这个词,意思是一个进程(这里指函数)可以自主选择什么时间进行中断,从而可以与其他代码协作。与之相对的是“抢占”,意思是一个进程/函数可以在外部被打断。

从并发行为上来说,ES6 生成器是“协作的”。在生成器函数内部,可以通过 yield 关键字来暂停函数的执行。不能在生成器外部停止其执行;只能是生成器内部在遇到 yield 时主动停止。

不过,在生成器通过 yield 暂停后,它不能自己继续执行。需要通过外部控制来让生成器重新执行。我们会花一点时间来阐述这个过程。

所以,基本上,一个生成器函数可以停止执行和被重新启动任意多次。实际上,可以通过无限循环(如臭名昭著的 while (true) { .. })来使得一个生成器函数永远不终止。尽管在通常的 JS 编程中这是疯了或者出错了,但对于生成器函数这却会是非常合理的,并且有时候就是你需要的!

更重要的是,生成器函数执行过程中的控制并不仅仅是停止和启动,在这个过程中还实现了生成器函数内外的双向消息传递。对于普通函数,是在最开始执行时获得参数,最后通过 return 返回值。而在生成器函数中,可以在每个 yield 处向外发送消息,在每次重新启动时得到外部返回的消息。

语法!

让我们开始深入分析这全新和令人兴奋的生成器函数的语法。

首先,新的声明语法:

function *foo() {
    // ..
}

注意到 * 了没?看起来有点陌生和奇怪吧。对于了解其他语言的人来说,这看起来很像是一个函数的指针。但是别被迷惑了!这里只是用于标记特殊的生成器函数类型。

你可能看过其他文章/文档使用了 function* foo() { } 而不是 function *f00() { }* 的位置有所不同)。两种都是合法的,不过最近我认为 function *foo() { } 更准确些,所以我后面会使用这种形式。

下面,我们来讨论下生成器函数的内容。大多数情况下,生成器函数就像是普通的 JS 函数。在生成器的 内部 只有很少的新的语法需要学习。

我们主要的新玩具,前面也提到过,就是 yield 关键字。yield __ 被称为“yield 表达式”(而非语句),因为生成器重新执行时,会得到一个返回给生成器的值,这个值会作为 yield __ 表达式的值使用。

示例:

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

在执行到 yield "foo" 这里时,生成器函数暂停执行,"foo" 会被发送到外部,而(如果)等到生成器重新执行时,不管被传入了什么值,都会作为这个表达式的结果值,进而与 1 相加后赋值给变量 x

看出来双向通信了吗?生成器将 "foo" 发送到外部,暂停自身的执行,然后在未来某一时间点(可能是马上,也可能是很久之后!),生成器被重新启动并传回来一个值。这看起来就像是 yield 关键字产生了一个数据请求。

在任何使用表达式的位置,都可以在表达式/语句中只使用 yield,这就像是对外发送了 undefinded 值。如:

// 注意:这里的 `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() 返回 nullfalse 或其他可以让你知道数据容器中的所有值已被遍历的信号。

我们在外部控制生成器函数的方式,就是构造一个 生成器迭代器 并与之交互。这听起来比实际情况要复杂。来看下面的例子:

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

为了获得生成器函数 *foo() 的值,我们需要构造一个迭代器。怎么做呢?很简单!

var it = foo();

噢!所以,像一般函数那样调用生成器函数,其实并没有执行其内部。

这有点奇怪是吧。你可能还在想,为什么不是 var it = new foo();。不过很遗憾,语法背后的原因有点复杂,超出了我们这里讨论的范围。

现在,为了遍历我们的构造器函数,只需要:

var message = it.next();

这会从 yield 1 语句那里得到 1,但这并不是唯一返回的东西。

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

实际上每次调用 next() 会返回一个对象,返回对象包含一个对应 yield 返回值的 value 属性,以及一个表示生成器函数是否已经完全执行完毕的布尔型的 done 属性。

继续迭代过程:

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 }

有意思的是,done 属性在获取到 5 这个值时仍为 false。这是因为从 技术上 讲,生成器函数的执行还未结束。我们还需要最后一次调用 next(),这时如果我们传入一个值,它会被用作表达式 yield 5 的结果。然后 生成器函数才会结束。

所以,现在:

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

生成器函数的最后一个返回结果表示函数执行结束,但没有值返回(因为所有的 yield 语句都已执行)。

你可能会想,如果在生成器函数中使用 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 }

可以看到,通过迭代初始化时调用的 foo( 5 ) 仍然可以进行传参(对应例子中的 x),这和普通函数相同,会使 x 的值为 5

第一个 next(..) 调用,没有传入任何值。为什么?因为没有对应的 yield 表达式来接收传入的值。

不过即使第一次调用时传入了值,也不会有什么坏事发生。传入的值只是被丢弃了而已。ES6 规定这种情况下生成器函数要忽略没有用到的值。(注意:在实际写代码的时候,最新版的 Chrome 和 FF 应该没问题,不过其他浏览器可能不是完全兼容的,或许会在这种情况下抛出异常。)

语句 yield (x + 1) 向外发送 6。第二个调用 next(12) 向正在等待状态的 yield (x + 1) 表达式发送了 12,所以 y 的值为 12 * 2,也就是 24。然后 yield (y / 3)yield (24 / 3))向外发送值 8。第三个调用 next(13) 向表达式 yield (y / 3) 发送了 13,使得 z 的值为 13

最终,return (x + y + z)return (5 + 24 + 13),也就是说返回的最后的 value42

把上面的内容多看几遍。对于大多数人来说,最初看的时候都会感觉很奇怪。

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 返回。donefalse 时,会自动提取 value 属性赋值给迭代变量(上例中为 v)。一旦 donetrue,循环迭代终止(也不会处理最后返回的 value,如果有的话)。

就像上文提到过的那样,for..of 循环忽略并丢弃了最后的 return 6 的值。所以,由于没有暴露 next() 调用,还是不要在像上面那种情况下使用for..of 循环。

总结

OK,以上就是生成器的基础知识了。如果还是有点懵,不用担心。所有人一开始都是这样的!

很自然地,你会想这个外来的新玩具在自己的代码中实际会怎么使用。其实,有关生成器还有很多的东西。我们只是翻开了封面而已。所以,在发现生成器是/将会多么强大之前,我们还得更进一步学习。

在你试着玩过上面的代码片段之后(试试 Chrome 最新版或 FF 最新版,或者带有 --harmony 标记的 node 0.11+ 环境),可能会思考下面的问题:

  1. 异常如何处理?
  2. 一个生成器能够调用另一个吗?
  3. 异步代码怎么应用生成器?

这些问题,以及其他更多的问题,将会在该系列文章中讨论,所以,请继续关注!

该系列文章共有4篇,这是第一篇,如有时间,其他3篇也会在近期陆续翻译出来。

ES6 Generators: Complete Series

另外,有关 for..of 的部分,其实有个细节文章没有解释。for..if 接收的并不是迭代器(实现了 iterator 接口,也就是有 next() 方法),而应该是实现了 iterable 接口的对象。

之所以生成器函数调用后的返回值可以用于 for..of,是由于得到的生成器对象同时支持了 iterator 接口和 iterable 接口。

iterable 接口对应一个特殊的方法,调用后返回一个迭代器,对于生成器对象而言,这个接口方法返回的其实就是对象自身。

由于同时支持了两个接口,所以生成器函数返回的生成器对象既能直接调用 next(),也可以用于 for..in 循环中。