深入 ES6:生成器,续篇

ES6 In Depth 是一个系列,介绍了 ECMAScript 标准第 6 版(简称 ES6)中添加到 JavaScript 编程语言的新特性。

欢迎回到 ES6 In Depth!我希望你在夏季休假期间玩得和我们一样开心。但程序员的生活不可能全是烟火和柠檬水。现在该继续我们之前的工作了,我准备了一个完美的主题来继续。

早在五月份,我就写过一篇关于生成器的文章,这是 ES6 中引入的一种新的函数类型。我称它们是 ES6 中最神奇的特性。我谈到了它们可能是异步编程的未来。然后我又写了这篇文章

关于生成器还有很多要说的……但我想这篇文章已经足够长,也足够令人困惑了。就像生成器本身一样,我们应该暂停,并在下次再讨论其他内容。

现在就是时机了。

您可以在此处找到本文的第一部分。 建议您在阅读本文之前先阅读那篇文章。去吧,它很有趣。它……有点长,有点令人困惑。但是有一只会说话的猫!

快速回顾

上次,我们重点关注了生成器的基本行为。它可能有点奇怪,但并不难理解。生成器函数非常像普通函数。主要区别在于生成器函数的函数体不会一次性执行。它会一次执行一点点,每次执行到yield表达式时都会暂停。

第一部分中有一个详细的解释,但我们从未做过一个完整的示例来演示所有部分是如何组合在一起的。现在让我们来做一下。

<pre>
function* someWords() {
yield "hello";
yield "world";
}

for (var word of someWords()) {
alert(word);
}
</pre>

这段脚本非常直观。但如果您可以观察到这段代码中发生的一切,就像所有代码片段都是戏剧中的角色一样,将是一个截然不同的脚本。它可能像这样

SCENE - INTERIOR COMPUTER, DAY

FOR LOOP stands alone onstage, wearing a hard hat and
carrying a clipboard, all business.

                          FOR LOOP
                         (calling)
                        someWords()!
                              
The GENERATOR appears: a tall, brass, clockwork gentleman.
It looks friendly enough, but it's still as a statue.

                          FOR LOOP
               (clapping her hands smartly)
           All right! Let's get some stuff done.
                     (to the generator)
                          .next()!

The GENERATOR springs to life.

                         GENERATOR
               {value: "hello", done: false}

It freezes in a goofy pose.

                          FOR LOOP
                           alert!

Enter ALERT at a sprint, wide-eyed and breathless. We sense
he's always like that.

                          FOR LOOP
                   Tell the user "hello".

ALERT turns around and sprints offstage.

                           ALERT
                   (offstage, screaming)
                      Stop everything!
          The web page at hacks.mozilla.org says,
                          "hello"!

A few seconds' pause, then ALERT races back on, crossing all
the way over to FOR LOOP and skidding to a halt.

                           ALERT
                     The user says OK.

                          FOR LOOP
               (clapping her hands smartly)
           All right! Let's get some stuff done.
              (turning back to the GENERATOR)
                          .next()!

The GENERATOR again springs to life.

                         GENERATOR
               {value: "world", done: false}

It freezes in a different goofy pose.

                          FOR LOOP
                           alert!

                           ALERT
                     (already running)
                           On it!
                   (offstage, screaming)
                      Stop everything!
          The web page at hacks.mozilla.org says,
                          "world"!

Again, a pause, then ALERT trudges back onstage, suddenly
crestfallen.

                           ALERT
               The user says OK again, but...
             but please prevent this page from
               creating additional dialogues.

He exits, pouting.

                          FOR LOOP
               (clapping her hands smartly)
           All right! Let's get some stuff done.
              (turning back to the GENERATOR)
                          .next()!

The GENERATOR springs to life a third time.

                         GENERATOR
                       (with dignity)
               {value: undefined, done: true}

Its head comes to rest on its chest and the lights go out of
its eyes. It will never move again.

                          FOR LOOP
                  Time for my lunch break.

She exits.

After a while, the GARBAGE COLLECTOR enters, picks up the
lifeless GENERATOR, and carries it offstage.

好吧,它并不完全是《哈姆雷特》。但你明白我的意思。

正如你在戏剧中看到的那样,当生成器对象第一次出现时,它是处于暂停状态的。每次调用它的.next()方法时,它都会醒来并运行一小段时间。

这个动作是同步的,并且是单线程的。请注意,在任何给定时间,这些角色中只有一个实际上正在做任何事情。角色之间从不互相打断或相互交谈。他们轮流说话,谁在说话就可以说多久就说多久。(就像莎士比亚!)

每次将生成器传递给for-of循环时,都会上演这个戏剧的某个版本。总是有这一系列.next()方法调用,这些调用在你的代码中任何地方都看不见。在这里,我将它全部放在舞台上,但对于你和你的程序来说,这一切都将发生在幕后,因为生成器和for-of循环是通过迭代器接口设计为协同工作的。

因此,为了总结到目前为止的所有内容

  • 生成器对象是礼貌的黄铜机器人,它们会产生值。
  • 每个机器人的程序都包含一个代码块:创建它的生成器函数的函数体。

如何关闭生成器

生成器有一些细微的额外功能,我在第一部分中没有介绍

  • generator.return()
  • generator.next()的可选参数
  • generator.throw(error)
  • yield*

我跳过它们主要是因为,如果不了解这些功能为什么存在,就很难关心它们,更不用说把它们全部记在脑子里了。但随着我们更多地思考我们的程序将如何使用生成器,我们将看到这些原因。

以下是一个你可能在某个时候用过的模式

<pre>
function doThings() {
setup();
try {
// ... 做一些事情 ...
} finally {
cleanup();
}
}

doThings();
</pre>

清理可能包括关闭连接或文件,释放系统资源,或者只是更新 DOM 以关闭“正在进行”的旋转器。我们希望无论我们的工作是否成功完成,这都要发生,所以它放在finally块中。

这在生成器中会是什么样子呢?

<pre>
function* produceValues() {
setup();
try {
// ... 产生一些值 ...
} finally {
cleanup();
}
}

for (var value of produceValues()) {
work(value);
}
</pre>

看起来还可以。但这里有一个细微的问题:调用work(value)不在try块中。如果它抛出了一个异常,我们的清理步骤会发生什么?

或者假设for-of循环包含一个breakreturn语句。那么清理步骤会发生什么?

它照常执行。ES6 会替你处理。

当我们第一次讨论迭代器和for-of循环时,我们说过迭代器接口包含一个可选的.return()方法,当迭代在迭代器说它已经完成之前退出时,语言会自动调用该方法。生成器支持此方法。调用myGenerator.return()会导致生成器运行任何finally块,然后退出,就像当前的yield点神秘地变成了return语句一样。

请注意,.return()并非在所有上下文中都由语言自动调用,而是在语言使用迭代协议的情况下才会调用。因此,生成器有可能在从未运行其finally块的情况下被垃圾回收。

这个功能会在舞台上如何上演呢?生成器被冻结在一个需要一些设置的任务的中间,比如建造一座摩天大楼。突然有人抛出了一个错误!for循环捕获它并将其搁置一旁。她告诉生成器.return()。生成器平静地拆除了所有脚手架并关闭了。然后for循环重新获取错误,并继续正常的异常处理。

生成器掌权

到目前为止,我们看到的生成器与其用户之间的对话都是单方面的。暂时抛开戏剧的比喻

(A fake screenshot of iPhone text messages between a generator and its user, with the user just saying 'next' repeatedly and the generator replying with values.)

用户说了算。生成器按需执行其工作。但这并不是使用生成器的唯一方式。

在第一部分中,我说过生成器可以用于异步编程。你现在使用异步回调或 promise 链来完成的事情,可以使用生成器来代替。你可能想知道这到底是如何工作的。为什么生成器仅仅能够产生(这毕竟是生成器的唯一特殊能力)就足够了呢?毕竟,异步代码不仅会产生。它会让事情发生。 它从文件和数据库中调用数据。它向服务器发送请求。然后它返回到事件循环中等待这些异步进程完成。生成器到底是如何做到这一点的呢?没有回调,当数据从这些文件和数据库以及服务器中传入时,生成器是如何接收数据的呢?

为了开始找到答案,考虑一下如果我们只有一种方法可以将值传递回生成器,会发生什么。仅仅改变这一点,我们就可以进行一种全新的对话

(A fake screenshot of iPhone text messages between a generator and its caller; each value the generator yields is an imperious demand, and the caller passes whatever the generator wants as an argument the next time it calls .next().)

事实上,生成器的.next()方法确实接受一个可选参数,巧妙的是,该参数随后会在生成器中显示为yield表达式返回的值。也就是说,yield不像return那样是一个语句;它是一个表达式,一旦生成器恢复,它就有一个值。

  var results = yield getDataAndLatte(request.areaCode);

这一行代码做了很多事情

  • 它调用getDataAndLatte()。假设该函数返回字符串"get me the database records for area code...",我们在屏幕截图中看到了这个字符串。
  • 它暂停生成器,产生字符串值。
  • 此时,任何时间都可能过去。
  • 最终,有人调用.next({data: ..., coffee: ...})。我们将该对象存储在局部变量results中,然后继续下一行代码。

为了在上下文中展示这一点,这里提供了上述完整对话的代码

<pre>
function* handle(request) {
var results = yield getDataAndLatte(request.areaCode);
results.coffee.drink();
var target = mostUrgentRecord(results.data);
yield updateStatus(target.id, "ready");
}
</pre>

请注意,yield仍然只表示它之前的意思:暂停生成器并将值传递回调用方。但事物发生了变化!这个生成器期望它的调用方表现出非常具体的支持性行为。它似乎期望调用方充当行政助理。

普通函数通常不会这样。它们往往是为了满足调用方的需求而存在的。但生成器是你可以与其对话的代码,这使得生成器与其调用方之间可以建立更广泛的可能关系。

这个行政助理生成器运行器可能是什么样子呢?它并不需要那么复杂。它可能像这样。

<pre>
function runGeneratorOnce(g, result) {
var status = g.next(result);
if (status.done) {
return; // 呼!
}

// 生成器要求我们获取某些内容,
// 并在我们完成时将其回调。
doAsynchronousWorkIncludingEspressoMachineOperations(
status.value,
(error, nextResult) => runGeneratorOnce(g, nextResult));
}
</pre>

为了启动,我们需要创建一个生成器并运行它一次,像这样

  runGeneratorOnce(handle(request), undefined);

五月份,我提到了Q.async(),它是一个将生成器视为异步进程并自动运行它们的库。runGeneratorOnce就是这种东西。实际上,生成器不会产生字符串来说明它们需要调用方做什么。它们可能会产生 Promise 对象。

如果你已经了解了 promise,现在你也了解了生成器,你可能想尝试修改runGeneratorOnce以支持 promise。这是一项艰巨的任务,但一旦你完成,你就可以使用 promise 将复杂的异步算法写成直线代码,而不是.then()或回调。

如何炸毁一个生成器

你注意到runGeneratorOnce是如何处理错误的吗?它忽略了它们!

好吧,这不好。我们真的希望以某种方式将错误报告给生成器。生成器也支持这一点:你可以调用generator.throw(error)而不是generator.next(result)。这会导致yield表达式抛出异常。就像.return()一样,生成器通常会被杀死,但如果当前的yield点在一个try块中,那么catchfinally块会被执行,所以生成器可能会恢复。

修改runGeneratorOnce以确保.throw()被适当地调用是另一个很好的练习。请记住,在生成器中抛出的异常总是会传播给调用者。所以generator.throw(error)会直接将error抛回到你那里,除非生成器捕获了它!

这完成了生成器到达yield表达式并暂停时的所有可能性。

  • 有人可能会调用generator.next(value)。在这种情况下,生成器将从它停止的地方恢复执行。
  • 有人可能会调用generator.return(),可选地传递一个值。在这种情况下,生成器不会恢复它正在做的事情。它只会执行finally块。
  • 有人可能会调用generator.throw(error)。生成器表现得好像yield表达式是一个调用抛出error的函数。
  • 或者,可能没有人会做任何这些事情。生成器可能会永远冻结。(是的,生成器可以进入一个try块,并且根本不执行finally块是可能的。生成器甚至可以在这种状态下被垃圾回收器回收。)

这与普通的函数调用相比并没有复杂多少。只有.return()是一个真正的新可能性。

事实上,yield与函数调用有很多共同之处。当你调用一个函数时,你是暂时暂停了,对吧?你调用的函数处于控制状态。它可能会返回。它可能会抛出异常。或者它可能会永远循环。

生成器协同工作

让我展示另一个特性。假设我们编写一个简单的生成器函数来连接两个可迭代对象。

<pre>
function* concat(iter1, iter2) {
for (var value of iter1) {
yield value;
}
for (var value of iter2) {
yield value;
}
}
</pre>

ES6 为此提供了一个简写方式

<pre>
function* concat(iter1, iter2) {
yield* iter1;
yield* iter2;
}

一个普通的yield表达式产生一个单一的值;一个yield*表达式消费一个完整的迭代器并产生所有的值。

相同的语法也解决了另一个有趣的难题:如何在生成器内部调用生成器的问题。在普通函数中,我们可以从一个函数中取出一些代码,并将其重构到一个单独的函数中,而不改变行为。显然我们也希望重构生成器。但是我们需要一种方法来调用重构后的子程序,并确保我们之前产生的每个值仍然被产生,即使是子程序现在正在产生这些值。yield*是实现这一目标的方法。

<pre>
function* factoredOutChunkOfCode() { ... }

function* refactoredFunction() {
...
yield* factoredOutChunkOfCode();
...
}
</pre>

想象一个黄铜机器人将子任务委派给另一个机器人。你可以看到这个想法对于编写大型基于生成器的项目并保持代码的整洁和组织的重要性,就像函数对于组织同步代码至关重要一样。

退场

好了,这就是生成器!我希望你像我一样喜欢它。很高兴回来。

下周,我们将讨论另一个令人惊叹的功能,它完全是 ES6 中的新功能,一种新型的对象,它非常微妙,非常巧妙,你甚至可能在不知情的情况下使用它。下周请加入我们,深入了解 ES6 代理。

关于 Jason Orendorff

更多由 Jason Orendorff 撰写的文章……


8 条评论

  1. Awal

    太棒了,读起来很不错。喜欢莎士比亚风格的戏剧!

    我一直期待着关于代理的文章。我希望它也能获得一些 Reflect API 的支持 :)

    干杯,谢谢你的精彩文章!

    2015 年 7 月 9 日 下午 10:43

  2. 旁观者

    `return()` 有道理,因为你需要一种方法来清理,如果你突然中止迭代。

    但是另外两个?`next(value)` 和 `throw(ex)`?对我来说,它们真的感觉像是被强加到这个规范中,以便在 ES6 生成器之上轻松地启用异步代码。还有其他好的用例吗?我只是看不出迭代序列和向其中注入数据之间的联系,尤其是异常。

    鉴于 await/async 可能会出现在 ES7 中,我想知道这些额外的技巧是否真的合理。

    或者也许生成器的命名不当,它们应该被称为协程或其他什么?它们的名字就像它们的目的是迭代序列一样,但它们的行为就像它们想要更多一样。

    2015 年 7 月 10 日 下午 1:58

  3. Flo

    非常感谢你写了这个系列!我一直很享受阅读和学习这些文章!

    2015 年 7 月 11 日 上午 4:34

  4. Michael

    非常感谢你提供这些信息,读起来很不错!

    2015 年 7 月 14 日 下午 12:44

  5. Mörre

    我“理解”生成器和承诺,我尝试过使用它们。然后我决定使用它们的次数比这些文章中看起来的少得多。尤其是这里的一些例子,其中一个生成器(看看那个*词*!它有意义)被用来做它确实能做的事情——但谁会理解 2 年后的代码呢?

    现在许多关于 ES 2015 中所有新功能的*可以*(但应该吗?)做的事情的激动人心的例子充斥着博客。当易受影响的人认为他们必须通过将这些东西投入使用来表明自己是“圈内人”时,这将是一场噩梦。这种在函数中间产生一些东西,然后带着一个值返回,对我来说并不像我想要使用的东西,除非我真的非常确定它有意义并且易于理解(在一个拥有 100,000 行其他代码的上下文中,这些代码都渴望得到关注和理解)。

    承诺、生成器和某些类型的“函数式”编程风格的共同点是,在幕后发生的事情并不明显。测试一下:让 100 个程序员解释那些漂亮例子中到底发生了什么(在机器中)。我的意思是那些已经实际使用过它们(在任何语言中)的人。

    2015 年 7 月 16 日 上午 6:57

    1. Jason Orendorff

      是的,我应该在激发这一切方面做得更好。这里真正的益处是,我们将修复异步编程。

      目前大多数人仍然使用回调来进行异步编程。这有点像一场灾难。代码一团糟,错误被丢弃,调试不可能。使用承诺可以将代码量减少三分之一,并且它们使错误处理更容易正确。使用生成器和承诺将是类似的进步。

      我应该展示一些具体的例子。第一部分中有一个,但我没有深入探讨。

      这个系列叫做“ES6 深入浅出”,目的是深入研究这些功能,并解释到底发生了什么以及为什么。但是 JS 程序员不必理解所有内部机制才能提高效率,就像你不需要理解 React 或 Backbone 的所有内部机制一样。

      2015 年 7 月 16 日 上午 8:35

      1. Sammy

        关于使用回调进行异步编程,你说得很好,我完全同意,是时候彻底替换它们了。

        好文章,有用的信息,谢谢你 Jason。

        2015 年 7 月 21 日 下午 9:43

  6. 旁观者

    @Jason Orendorff 好的,所以基本上你承认了我上面写的内容?这些对“yield”的无意义添加是为了修复异步编程?

    这真的有必要吗?我们将在 ES2016 中拥有“async/await”,这完全隐藏了它的内部机制。我们是否需要在同时弄脏生成器?鉴于所有这些都将在一段时间内被转译成 ES5,我想答案是否定的。

    看看像 C# 这样的语言。它有一个干净的“yield”(没有返回数据的奇特方法)和“await”,它真的需要更多吗?

    2015 年 7 月 20 日 下午 2:13

本文的评论已关闭。