ES6 深入:生成器

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

我对今天的文章感到兴奋。今天,我们将讨论 ES6 中最神奇的功能。

我所说的“神奇”是什么意思?首先,这个特性与 JS 中已经存在的事物非常不同,以至于一开始可能看起来完全神秘。从某种意义上说,它颠覆了语言的正常行为!如果那不是魔法,我不知道什么才是。

不仅如此:此特性简化代码和理清“回调地狱”的能力几乎超自然。

我是不是有点夸大了?让我们深入了解一下,你可以自己判断。

介绍 ES6 生成器

什么是生成器?

让我们先看一个例子。

<pre>
function* quips(name) {
yield "hello " + name + "!";
yield "i hope you are enjoying the blog posts";
if (name.startsWith("X")) {
yield "it's cool how your name starts with X, " + name;
}
yield "see you later!";
}
</pre>

这是一些用于 会说话的猫 的代码,可能是当今互联网上最重要的应用类型。(继续,点击链接,与猫咪互动。当你彻底困惑时,回到这里获取解释。)

它看起来有点像函数,对吧?这被称为生成器函数,它与函数有很多共同之处。但是你可以立即看到两个区别

  • 普通函数以function开头。生成器函数以function*开头。

  • 在生成器函数内部,yield是一个关键字,语法类似于return。不同之处在于,虽然函数(即使是生成器函数)只能返回一次,但生成器函数可以产生任意次数。yield表达式暂停生成器的执行,以便稍后可以恢复执行。

所以就是这样,这就是普通函数和生成器函数之间的最大区别。普通函数无法暂停自身。生成器函数可以。

生成器做什么

当你调用quips()生成器函数时会发生什么?

<pre>
> var iter = quips("jorendorff");
[object Generator]
> iter.next()
{ value: "hello jorendorff!", done: false }
> iter.next()
{ value: "i hope you are enjoying the blog posts", done: false }
> iter.next()
{ value: "see you later!", done: false }
> iter.next()
{ value: undefined, done: true }
</pre>

你可能非常习惯普通函数及其行为。当你调用它们时,它们会立即开始运行,并且会一直运行,直到它们返回或抛出异常。所有这些对于任何 JS 程序员来说都是第二天性。

调用生成器看起来完全一样:quips("jorendorff")。但是当你调用生成器时,它不会立即开始运行。相反,它会返回一个已暂停的生成器对象(在上面的示例中称为iter)。你可以将此生成器对象视为一个函数调用,冻结在时间中。具体来说,它冻结在生成器函数的顶部,在运行其第一行代码之前。

每次调用生成器对象的.next()方法时,函数调用都会自行解冻并运行,直到到达下一个yield表达式。

这就是为什么我们每次调用上面的iter.next()时,都会得到一个不同的字符串值。这些是由quips()主体中的yield表达式产生的值。

在最后一次iter.next()调用中,我们终于到达了生成器函数的末尾,因此结果的.done字段为true。到达函数的末尾就像返回undefined一样,这就是为什么结果的.value字段为undefined

现在可能是回到 会说话的猫演示页面 并真正玩弄代码的好时机。尝试在循环中放置一个yield。会发生什么?

从技术角度讲,每次生成器产生值时,其栈帧(局部变量、参数、临时值以及生成器主体中执行的当前位置)都会从栈中移除。但是,生成器对象会保留对(或复制)此栈帧的引用,以便稍后的.next()调用可以重新激活它并继续执行。

值得指出的是,**生成器不是线程。**在具有线程的语言中,多段代码可以同时运行,通常会导致竞争条件、不确定性和出色的性能。生成器根本不像那样。当生成器运行时,它在与调用方相同的线程中运行。执行顺序是顺序的和确定性的,绝不是并发的。与系统线程不同,生成器仅在其主体中由yield标记的位置暂停。

好的。我们知道生成器是什么了。我们已经看到一个生成器运行、暂停自身,然后恢复执行。现在是最大的问题。这种奇怪的能力可能有什么用?

生成器是迭代器

上周,我们看到 ES6 迭代器不仅仅是一个内置类。它们是语言的扩展点。你只需实现两个方法:[Symbol.iterator]().next(),就可以创建自己的迭代器。

但是实现接口总归至少需要一些工作。让我们看看在实践中迭代器实现是什么样的。例如,让我们创建一个简单的range迭代器,它只是从一个数字计数到另一个数字,就像一个老式的 C for (;;)循环。

<pre>
// 这应该“叮”三次
for (var value of range(0, 3)) {
alert("Ding! at floor #" + value);
}
</pre>

这是一个解决方案,使用 ES6 类。(如果class语法不是很清楚,别担心——我们将在未来的博文中介绍它。)

<pre>
class RangeIterator {
constructor(start, stop) {
this.value = start;
this.stop = stop;
}

[Symbol.iterator]() { return this; }

next() {
var value = this.value;
if (value < this.stop) {
this.value++;
return {done: false, value: value};
} else {
return {done: true, value: undefined};
}
}
}

// 返回一个新的迭代器,从 'start' 计数到 'stop'。
function range(start, stop) {
return new RangeIterator(start, stop);
}
</pre>

查看此代码的实际效果。

这就是在 JavaSwift 中实现迭代器的方式。它还不错。但它也不是微不足道的。这段代码中是否存在任何错误?不容易说。它看起来与我们试图在这里模拟的原始for (;;)循环完全不同:迭代器协议迫使我们拆分循环。

此时,你可能对迭代器感觉有点不冷不热。它们可能非常棒使用,但它们似乎很难实现。

你可能不会想到建议我们向 JS 语言引入一种疯狂的、令人费解的新控制流结构,仅仅是为了使迭代器更容易构建。但既然我们确实有生成器,我们能否在这里使用它们?让我们试试看

<pre>
function* range(start, stop) {
for (var i = start; i < stop; i++)
yield i;
}
</pre>

查看此代码的实际效果。

上面的 4 行生成器替换了之前 23 行的range()实现,包括整个RangeIterator类。这是可能的,因为**生成器是迭代器。**所有生成器都内置实现了.next()[Symbol.iterator]()。你只需编写循环行为。

在没有生成器的情况下实现迭代器就像被迫用完全被动语态写一封很长的电子邮件。当简单地说出你的意思不是一种选择时,你最终说出的内容可能会变得非常复杂。RangeIterator很长很奇怪,因为它必须描述循环的功能,而无需使用循环语法。生成器是答案。

我们还能如何使用生成器充当迭代器?

  • **使任何对象可迭代。**只需编写一个遍历this的生成器函数,在遍历过程中产生每个值。然后将该生成器函数安装为对象的[Symbol.iterator]方法。

  • **简化数组构建函数。**假设你有一个每次被调用时都返回结果数组的函数,如下所示

    <pre>
    // 将一维数组 'icons'
    // 分成长度为 'rowLength' 的数组。
    function splitIntoRows(icons, rowLength) {
    var rows = [];
    for (var i = 0; i < icons.length; i += rowLength) {
    rows.push(icons.slice(i, i + rowLength));
    }
    return rows;
    }
    </pre>

    生成器使这种代码稍微简短一些

    <pre>
    function* splitIntoRows(icons, rowLength) {
    for (var i = 0; i < icons.length; i += rowLength) {
    yield icons.slice(i, i + rowLength);
    }
    }
    </pre>

    唯一的行为差异是,它不是一次计算所有结果并返回它们的数组,而是返回一个迭代器,并且结果按需逐个计算。

  • **不寻常大小的结果。**你无法构建无限数组。但是你可以返回一个生成无限序列的生成器,并且每个调用者都可以根据需要从中提取任意数量的值。

  • **重构复杂的循环。**你是否有一个庞大而丑陋的函数?你想将其分成两个更简单的部分吗?生成器是添加到重构工具包中的一把新刀。当你遇到复杂的循环时,可以将产生数据的代码部分提取出来,将其变成一个单独的生成器函数。然后将循环更改为for (var data of myNewGenerator(args))

  • **用于处理可迭代对象的工具。**ES6提供用于过滤、映射和一般处理任意可迭代数据集的扩展库。但是生成器非常适合使用几行代码构建所需的工具。

    例如,假设你需要一个Array.prototype.filter的等效项,它适用于 DOM NodeList,而不仅仅是数组。小菜一碟

    <pre>
    function* filter(test, iterable) {
    for (var item of iterable) {
    if (test(item))
    yield item;
    }
    }
    </pre>

那么生成器有用吗?当然。它们是实现自定义迭代器的一种极其简单的方法,而迭代器是 ES6 中数据和循环的新标准。

但生成器不仅仅可以做到这些。它甚至可能不是它们最重要的用途。

生成器和异步代码

这是一段我之前写的一些 JS 代码。

<pre>
};
})
});
});
});
});
</pre>

你可能在自己的代码中见过类似的东西。异步 API 通常需要回调,这意味着每次执行操作时都需要编写一个额外的匿名函数。因此,如果你有一段执行三件事的代码,而不是三行代码,你将看到三个缩进级别的代码。

这是我写的一些 JS 代码

<pre>
}).on('close', function () {
done(undefined, undefined);
}).on('error', function (error) {
done(error);
});
</pre>

异步 API 具有错误处理约定,而不是异常。不同的 API 具有不同的约定。在大多数情况下,错误默认情况下会被静默丢弃。在某些情况下,即使是普通的成功完成也会默认被丢弃。

到目前为止,这些问题仅仅是我们为异步编程付出的代价。我们已经接受了异步代码看起来不如相应的同步代码简洁明了的事实。

生成器为我们带来了新的希望,表明情况不必如此。

Q.async() 是一种实验性的尝试,它使用生成器和 Promise 来生成类似于相应同步代码的异步代码。例如

<pre>
// 同步代码来制造一些噪音。
function makeNoise() {
shake();
rattle();
roll();
}

// 异步代码来制造一些噪音。
// 返回一个 Promise 对象,当我们完成制造噪音时,该对象将被解析。
function makeNoise_async() {
return Q.async(function* () {
yield shake_async();
yield rattle_async();
yield roll_async();
yield roll_async();
});
}
</pre>

主要区别在于,异步版本必须在调用异步函数的每个位置添加yield关键字。

Q.async版本中添加像if语句或try/catch块这样的复杂结构,就像在普通的同步版本中添加它们一样。与其他编写异步代码的方式相比,这感觉不像是学习一门全新的语言。

如果你已经看到这里了,你可能会喜欢James Long关于这个主题的非常详细的文章

所以生成器为我们指明了通往一种新的异步编程模型的道路,这种模型似乎更适合人类的大脑。这项工作还在进行中。其中,更好的语法可能会有所帮助。异步函数提案,建立在Promise和生成器的基础上,并借鉴了C#中类似的功能,正在被纳入ES7的考虑范围

我什么时候可以使用这些神奇的东西?

在服务器端,你今天就可以在io.js中使用ES6生成器(如果你使用--harmony命令行选项,也可以在Node中使用)。

在浏览器中,目前只有Firefox 27+和Chrome 39+支持ES6生成器。要在网络上使用生成器,你需要使用BabelTraceur将你的ES6代码转换为Web友好的ES5代码。

一些值得感谢的人:生成器最初由Brendan Eich在JS中实现;他的设计紧密遵循了Python生成器,而Python生成器则受到Icon的启发。它们在Firefox 2.0中发布早在2006年。标准化的道路是崎岖的,语法和行为也随之改变了一些。ES6生成器由编译器黑客Andy Wingo在Firefox和Chrome中实现。这项工作由彭博社赞助。

yield;

关于生成器,还有更多内容需要说明。我们没有涵盖.throw().return()方法,.next()的可选参数,或者yield*表达式语法。但我认为这篇文章已经足够长和令人困惑了。就像生成器本身一样,我们应该暂停一下,下次再继续讨论剩下的内容。

但下周,让我们稍微改变一下方向。我们已经连续讨论了两个深入的主题。谈论一个不会改变你生活的ES6特性难道不是很好吗?一些简单而明显有用的东西?一些会让你微笑的东西?ES6也有一些这样的特性。

即将推出:一个可以直接应用到你每天编写的代码中的特性。请在下周加入我们,深入了解ES6模板字符串。

关于 Jason Orendorff

更多 Jason Orendorff 的文章…


14 条评论

  1. Jason

    感谢您对这篇文章的详细阐述。我有一段时间一直在想这些是如何工作的。您能否举一个使用生成器重构的“又大又丑的函数”的例子?除了需要暂停并等待用户交互的情况外,我仍然难以想象它们比循环好在哪里…

    2015年5月7日 13:42

    1. Jason Orendorff

      @Jason:好问题。嗯。很难找到一个真正“庞大”且“丑陋”的例子,同时又足够小且清晰易懂。

      这是我最好的尝试。这段代码是“真实风格的”。它来自我的一个真实项目,并进行了一些简化。我对这个项目的代码质量并不感到特别自豪,但就这样吧。https://gist.github.com/jorendorff/ad207a791457a27bf446

      这段代码是Python。以下是ES6的翻译版本:https://gist.github.com/jorendorff/4ec9fe31493e1fb24a7e

      2015年5月8日 21:20

  2. Awal

    这些文章写得非常好。我希望它们能更频繁地出现,比如每两天更新一次。一周一次…有点太久了 :D

    不过阅读体验很不错。

    2015年5月7日 16:33

    1. Jason Orendorff

      @Awal:谢谢。我也希望我能够每隔一天发布一篇这样的文章!不幸的是,我的工作还有另一部分,需要我编写代码。;-)

      2015年5月8日 21:32

  3. voracity

    如果生成器/异步/等待是未来五年JavaScript唯一得到普遍改进的功能,我仍然会欣喜若狂。异步代码可能会将JavaScript撕成碎片(字面意思);生成器/异步/等待恢复了秩序(同样,字面意思)。

    2015年5月7日 21:37

  4. Phil Stricker

    确实是一个“神奇的功能”!可以看到它可以减少循环内部的变量声明,并且是提取迭代内部任何复杂逻辑的好方法。

    2015年5月9日 17:36

  5. Oleg

    Jason,我已经通过将生成器函数安装为对象的[Symbol.iterator]方法改进了我在第二篇文章中的示例。请看http://codepen.io/anon/pen/OVMrPd?editors=001

    顺便说一句,你可能应该向读者解释方括号[]中的表达式是什么意思。这是一个“计算属性名”。这里有很好的解释https://mdn.org.cn/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer#Computed_property_names

    继续努力!

    2015年5月11日 13:03

  6. Andrea Giammarchi

    不错的文章.. 顺便说一句 ;-)

    function* range(start, stop) {
    while (start < stop) yield start++;
    }

    2015年5月22日 15:19

  7. Julian Bravard

    我不确定我是否遗漏了什么,但看起来在splitIntoRows示例中从未使用过变量nRows。

    2015年5月23日 21:24

    1. Jason Orendorff

      你说得对。我会删除这段无效代码。

      2015年5月26日 11:37

  8. Karthick Siva

    感谢您的文章,Jason。我明白了yield返回一个对象。我很想了解更多关于yield在堆栈方面的功能。

    2015年5月24日 04:34

    1. Jason Orendorff

      当然——生成器不会改变堆栈的形状。JS堆栈仍然与以前相同的数据结构:一个FIFO堆栈帧,每个函数调用一个堆栈帧。

      新的是能够从堆栈中移除一个堆栈帧并在以后恢复它——也就是说,稍后将其放回堆栈并继续运行。普通函数无法做到这一点。但这就是yield和.next()所做的。

      更详细地说明

      1. 调用生成器函数会创建一个新的堆栈帧,但不会立即将其放入堆栈中。相反,它会创建一个新的生成器对象,将新的堆栈帧存储在其中,并返回该对象。

      2. 在生成器对象上调用.next()会从生成器对象中获取堆栈帧,将其放入堆栈中,并开始实际运行它。

      3. `yield` 会将当前帧从堆栈中移除,就像`return`一样。但它不会丢弃该堆栈帧,而是将其存储在生成器对象中,因此下次调用.next()时,我们将从离开的地方继续。

      因此,生成器对象基本上只是一个表示单个堆栈帧的JS对象。

      你可以通过编写一些使用生成器的代码并执行`alert(Error().stack)`来查看当前堆栈,从而进行实验。

      2015年5月26日 12:52

  9. Karthick Siva

    感谢您的详细解释,Jason。

    2015年5月26日 22:13

  10. Eric

    文章写得非常好。这可能是我读过的关于解释生成器是什么以及它们如何工作的最好的文章。谢谢!

    2015年5月28日 10:27

本文的评论已关闭。