在JavaScript ES6中最激动人心的新特性之一,是一种新的函数,被称为生成器。 这个名字有点陌生,但是其行为可能在初次目睹时更为怪异。本文的目的就是解释 生成器的基本运作原理,帮助你理解为什么对于JS的未来而言,它们是如此的强大。
Run-To-Completion
当我们谈到生成器要考察的第一件事,就是在运行到结束方面,它们和普通的函数有何区别。
不管你是否意识到,你经常能够对函数作出一些相当基本的假设:一旦函数开始执行, 它就会一直执行到结束,在此之前其他任何JS代码都不能运行。
看一个小例子:
1 | setTimeout(function(){ |
在这里,for
循环将花费相当长的时间才能够完成,长于1毫秒,但是我们的定时器
回调——其中包含有console.log(..)
语句——无法打断foo()
函数的执行,因此它就
被阻塞,只能耐心等待循环完成。
但是,如果foo()
的执行可以被中断,会怎么样?那不会给我们的程序带来灾难吗?
对于多线程编程而言,这必然带来噩梦般的挑战,但是我们相当幸运的是,在JavaScript的领地 不需要担心这样的事情,因为Js总是单线程执行 —— 在任何特定时间只有一个命令/函数执行。
注意:Web Workers机制让你可以启动一个完全隔离的线程来运行一部分JS程序,完全 并行于你的JS主程序线程。而这不会给我们程序带来多线程复杂性的原因在于,两个线程 之间只能够通过正常的异步时间通信,而这些事件总是遵循运行到结束原则所要求的 一次只运行一段代码的行为模式。
运行..停止..运行
有了ES6生成器,我们有了一种不同类型的函数,它可以在运行中间暂停一次或多次,然后 在晚些时候继续,这使得其他代码可以在它暂停期间运行。
如果你看过任何关于并发或线程编程的资料,你可能已经看到过这个词:合作,它基本的含义 是,一个进程(在我们这里就是:函数)它自己选择什么时候允许一个中断,这样就实现了它 和其他代码的合作。这个概念是相对于抢占式而言,抢占式的意思是一个进程/函数可以 被不自主的中断。
ES6生成器函数在并发行为方面是合作式的。在生成器函数体内,你使用一个新的yield
关键字来从函数内部暂停运行。无法从外部暂停一个生成器,只有它自己运行中碰到一个
yield
时,才能够暂停自己。
但是,一旦一个生成器通过yield
暂停了自己,它确不能自主的继续运行。必须使用一个
外部的控制来重新启动生成器。我们一会儿就会解释这是如何发生的。
因此,基本上,一个生成器函数可以停止,可以重新启动,可以随你的心情任意多次的停止
或重新启动。事实上,你可以定义一个具有无限循环(就像恶名昭著的while(true){..}
)
的生成器函数,它永远也不会结束。虽然这很疯狂,或者就是一个正常的JS程序中的错误,
然而作为生成器函数,它是百分之百的理性实现,而且有时恰恰就是你想要做的。
更重要的是,这个停止和启动并不只是用于控制生成器函数的执行,它还使得外部代码和
生成器的双向消息传递成为可能。对于普通函数,你在开始的时候拿到参数,在结束的时候
返回一个值。而对于生成器函数,你可以使用yield
向生成器外部发送消息,也可以在每次
重新启动时,从外部代码向生成器发送消息。
语法!
让我们深入这些全新的、令人激动的生成器函数的语法。
首先是,新的声明语法:
1 | function *foo() { |
注意到*
没有?这是新的东西,看起来有点怪异。对于那些来自其他开发语言的人而言,
这东西看起来非常像一个函数返回值指针。不过别搞混了!这不过就是用来表示这个函数
是一个生成器函数而已。
你可能读过一些资料中使用function* foo(){}
而不是function *foo(){}
,注意两种
书写方式中*
的位置不同。这两种写法都是合法的,不过我最近决定,我认为function *foo(){}
更准确一些,因此我会遵循这个写法。
现在,让我们谈一下生成器函数的内容。生成器函数在很多方面就是普通的JS函数。在生 成器函数内部,只有非常少的新语法需要学习。
像前面谈到的,我们摆弄的新玩具是yield
关键字。yield ___
被称为yield表达式,它
不是一个语句,因为当我们重启生成器时,我们将把一个值发送回生成器,无论我们发送的
是什么,都将是那个yield ___
表达式的计算结果。
看一个小例子:
1 | function *foo() { |
当暂停这个生成器的时候,yield "foo"
表达式会发送出foo
字符串,无论何时重新启动
生成器,无论什么值从外部传入生成器,这个值都将是yield表达式的结果,它被加1然后
赋给变量x
。
发现双向通信了吗?你把值foo
发送出去,暂停自己,然后在晚些某个时候(可能是立刻,
也可能是很久以后!),生成器会被重新启动,会给回来一个值。几乎就像yield
关键字
发送了一个对某个值的请求。
在任何表达式位置,你可以在表达式/语句中只使用yield
自己,这意味着yield
会发送出
一个未定义值。因此:
1 | //注意: 这里的`foo(..)` 不是生成器!! |
生成器迭代器
”生成器迭代器“,相当拗口,对吧?
迭代器是一种特殊的行为,实际上是一种设计模式,我们通过调用next()
来单步处理一个有序
集合中的每个值。相像一下,例如,在一个数组上使用迭代器,这个数组的值是:[1,2,3,4,5]
。
第一次调用next()
将返回1,第二次调用next()
将返回2,依次类推。当所有的值都返回后,
next()
将返回空值,或者false
,或者提示你已经将数据容器中的所有值迭代完了。
我们从外部控制生成器函数的方法是构造并使用一个迭代器生成器。这听起来相当复杂,实际上不是。 看一下这个小例子:
1 | function *foo() { |
要单步走完那个*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 | console.log( it.next() ); // { value:2, done:false } |
值得注意的是,当我们拿到5
这个值时,done
依然还是false
。这是因为,技术角度来讲,
这个生成器函数还没有完毕。我们还需要再调用一下next()
,如果我们发送进去一个值,它
将被设置为yield 5
表达式的结果。直到那时,才算生成器函数执行完毕。
因此,继续:
1 | console.log( it.next() ); // { value:undefined, done:true } |
因此,我们的生成器函数最终的结果是,我们执行完了函数,但是没有结果给出来(因为我们已经耗尽
了所有的yield ___
语句)。
对于这一点你可能感到好奇,我可以在一个生成器函数中使用return
吗?如果我这样做的话,那个值会
在value
属性中发送出来吗?
正确…
1 | function *foo() { |
.. 也不正确
依赖于生成器的返回值,可能不是一个好主意,因为当使用for..of
循环对生成器函数进行迭代时(看下
面),最终的返回值将被丢弃。
出于完整性考虑,让我们看一下在进行迭代时,生成器函数的双向消息传递:
1 | function *foo(x) { |
你可以看到,在初始的进行迭代器实例化的调用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 | function *foo() { |
正如你看到的,使用foo()
创建的迭代器被for..of
循环自动捕捉、自动迭代,
每次迭代一个值,直到done
属性变成true
。只要done
还是false
,这个循环
就会自动地提取value
属性的值,赋给你的迭代变量(对我们而言:v)。一旦等到
done
变成true
,循环迭代就停止了,它不会对最终的返回值(如果有的话)进行任何处理。
就像前面的注意事项,你可以看到for..of
循环忽略并丢弃了返回的值6。并且,由于
没有暴露出来的next()
调用,for..of
循环不能用于你需要向迭代器传入值的场景。
总结
好了,这就是生成器的基础。如果你还感到困惑,也别担心。因为我们在开始的时候 都是如此感觉!
很自然地会想到,这个新的、充满异域色彩的玩具,能给你的代码带来什么实际作用。 有很多。我们还只是刚刚接触了皮毛。因此,我们必须更深入一些,才能够发现这个 东西有多么强大。
在你尝试了上面的代码片段之后,可能会产生下面的问题:
- 错误处理如何运作?
- 一个生成器可以调用另一个生成器吗?
- 异步代码如何使用生成器?
这些问题,以及更多的问题,都将在后续的文章中涵盖,因此,请继续关注!
原文:https://davidwalsh.name/es6-generators
推荐课程:ECMAScript 6入门