深入浅出 ES6:Generators
今天,我们将要讨论的是 ES6 中最奇妙的特性。
为何称之为"奇妙"呢?对于初学者而言,这个特性与现有 JavaScript 中的内容看起来是如此这般的格格不入。从某种角度而言,它将由内而外地改变语言的常见行为。如果这还不算奇妙,那还有什么能算呢?
不仅如此,这个特性可以大幅度地简化现有代码,并神奇地解决"callback hell" 的问题。
接下来让我们来深入了解一下这个奇妙的特性吧。
ES6 生成器简介
什么是生成器(Generator)?
先来看一个例子:
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!";
}
这是一段会说话的猫中的代码,也许是当今互联网上最重要的一类应用。(点击链接,与这只猫互动。当你感到困惑的时候,再回来看看这篇文章中的解释。)
生成器(Generator)看起来像是一种函数,对吗?它确实被称之为生成器函数,且与函数有很多相似之处。但是两者有以下两点区别:
- 普通函数用
function
声明。生成器函数则用function *
。 - 在生成器函数内部,关键词
yield
是一种类似return
的语法。区别就在于函数(甚至生成器函数)只能返回一次,但是生成器函数可以yield
多次。yield
表达式将生成器的执行过程挂起,随后可以被恢复。
所以这是一个普通函数与生成器函数之间比较大的区别。普通函数无法暂停自身的执行,而生成器函数可以。
生成器是做什么的?
当你调用 quips()
生成器函数的时候发生了什么?
> 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 }
你可能对普通函数以及它们是如何运行已经非常了解。当你调用它们的时候,就马上开始运行,直到遇到return
语句或者抛出异常。这对于 JavaScript 程序员而言是再熟悉不过的了。
调用生成器看起来(和普通函数)没有区别:quips("jorendorff")
。但是当你调用一个生成器的时候,它还没有开始运行。反之,它返回了一个暂停的生成器对象(上述例子中的iter
)。你可以把生成器对象当做一个函数调用,但是立即冻结了。具体而言,它在运行生成器函数顶端第一行代码之前就已经冻结了。
每次调用生成器对象的.next()
方法,函数就会将自身解冻然后运行直到遇到下一个yield
表达式。
这也就是为什么在上面我们每次调用iter.next()
的时候,都会得到一个不同的字符串值。这些值都是由quips()
函数体内的yield
表达式生成的。
在最后一次调用iter.next()
时,就到了生成器函数的末尾,所以返回值中的.done
字段值为true
。函数执行完就如同返回了一个undefined
,这也就是为什么返回值中的.value
字段的值为undefined
。
现在可以回过头来看会说话的猫的 demo。尝试着把yield
放到一个循环中,会发生什么?
就技术角度而言,每当一个生成器执行 yield
操作的时候,它的栈结构内的本地变量,参数,临时变量以及生成器内部的执行位置都会被移出栈。但是生成器对象本身维持了一个对栈结构的引用或者拷贝,所以之后的 .next()
调用可以重新激活生成器随后继续执行。
值得注意的是,生成器不是线程。支持线程的语言中,多段不同的代码可以在同一时候运行,这经常会导致竟态条件、不确定性以及不错的性能提升。生成器则完全不同。当生成器运行的时候,它会在叫做 caller
的同一个线程中运行。执行的顺序是有序、确定的,并且永远不会产生并发。不同于系统的线程,生成器只会在其内部用到 yield
的时候才会被挂起。
好了。既然已经知道生成器是什么,也看到生成器是如何运行、暂停然后恢复执行。那么问题来了,这个奇怪的功能到底有什么用处呢?
生成器是迭代器
在上一篇文章中,我们了解到 ES6 迭代器不仅仅是一个单独的内建类。同时也是对语言的一个扩展点。你可以通过实现两个方法:[Symbol.iterator]()
和 .next()
创建自己的迭代器。
但是实现一个接口总归不是一件小事。让我们来看看如何在实践中实现一个迭代器。举个例子,来创建一个简单的 range 迭代器 -- 可以从一个数字计数到另一个,类似C 语言中经典的 for(;;)
循环。
// 以下代码将会输出三次 Ding!
for (var value of range(0, 3)) {
alert("Ding! at floor #" + value);
}
以下是一个使用 ES6 class 的解决方案。(如果对 class
语法不是完全了解,不要担心,我们将会在之后的另一篇文章中介绍)
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);
}
这是像 Java 或 Swift 那样实现的一个迭代器。不是很糟糕,但是也不是完全没有问题。那么这段代码会有 bug 吗?不好说。它看起来与我们一开始要实现的 for(;;)
循环没有丝毫相像之处:迭代器的协议迫使我们拆解了循环。
此时你也许已经对迭代器不是那么感兴趣了。他们也许用起来非常不错,但是看起来却难以实现。
也许你不会建议为了让迭代器的构建更为容易而使用我们介绍的这种 JavaScript 语言中新的陌生而复杂的控制流程结构。但是既然我们有了生成器,那么为什么在此去使用它呢?一起来试试吧:
function* range(start, stop) {
for (var i = start; i < stop; i++)
yield i;
}
上述使用 generator 的4行代码可以替代之前用23行代码的包含了完整的 RangeIterator
类的range()
实现。这大概是因为 generator 也属于迭代器。所有的生成器都内置实现了 .next()
和 [Symbol.iterator]()
方法。你只需编写循环部分的逻辑。
不使用生成器实现迭代器就像完全用被动语句写一篇很长的邮件。如果无法简练地表达,那么说出来的话可能会相当晦涩难懂。由于 RangeIterator
并没有用循环的语法来描述一个循环的功能,从而令人觉得它额外的冗长、怪异。相反的,生成器才是这个问题的答案。
将其当做迭代器,还能如何发挥生成器的能力呢?
-
令任意对象可迭代。只需写一个生成器函数遍历这个对象,在此过程中把每个值
yield
。然后将这个生成器函数作为对象的[Symbol.iterator]
方法。 -
简化数组构建的函数。假设你有一个函数,每次调用的时候都返回一个结果的数组,比如这个:
// 将一个一维数组 '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;
}
生成器则让这个代码稍微短一些:
function* splitIntoRows(icons, rowLength) {
for (var i = 0; i < icons.length; i += rowLength) {
yield icons.slice(i, i + rowLength);
}
}
唯一的不同之处在于,函数返回的是一个迭代器而不是将所有结果计算好一次性返回整个数组。结果是按需逐一计算出来的。
-
异常大小的结果。你无法创建一个无穷大小的数组,但是可以返回一个可以生成无穷序列的生成器,并且每次调用都可以从中获取任意数量的值。
-
重构复杂的循环。你有写过又大又丑的函数吗?想要把它拆分为两个更为简单的部分吗?相信生成器会成为你重构工具箱中的一把利刃。当面对一个复杂的循环时,你拆分出那段产生数据的代码,然后将其变成一个独立的生成器函数。然后用
for
修改循环(var data of myNewGenerator(args))
。 -
创建可迭代的工具。ES6 没有提供一个可以用于过滤、映射以及针对任意数据集进行操作的扩展库。但是借助生成器,就可以用很少几行代码构建这一类工具。
例如,假设你需要一个类似 Array.prototype.filter
的应用于 DOM NodeLists 而不仅仅是数组的工具。很简单:
function* filter(test, iterable) {
for (var item of iterable) {
if (test(item))
yield item;
}
}
那么生成器真的有用吗?当然。这是极其简单的、用于实现自定义迭代器的方法,而且根据 ES6 的标准,迭代器是最新的用于数据和循环的标准。
但生成器所能做的并不仅仅局限于此,也许这也不是生成器可以做的最重要的事。
生成器与异步代码
这是一段我之前写的代码:
};
})
});
});
});
});
也许你也在自己的代码中见到过类似这样的部分。异步的 API 通常需要一个回调,也就意味着没做一些事情就需要编写一个额外的异步函数。所以如果你做了三件事,就会看到有三层缩进的代码而非三行。
下面也是我曾写过的一段代码:
}).on('close', function () {
done(undefined, undefined);
}).on('error', function (error) {
done(error);
});
异步接口有错误处理的机制,而没有异常处理。对于不同的接口而言,有着不同的约定俗成。就其中大部分而言,错误是默认被静默抛出的。而其中有一些的成功提示都是默认的。
以上这些问题都是我们为异步编程所付出的代价。我们正在慢慢接受异步代码比如其等效的同步代码简洁美观的事实。
生成器则给了人们不用这样书写代码的新的希望。
Q.async()
是一个实验性的结合 promise 使用生成器来生成与异步代码等效的同步代码的库。例如:
// 制造“噪音”的同步代码
function makeNoise() {
shake();
rattle();
roll();
}
// 制造“噪音”的异步代码
// 当我们产生“噪音”后
// 返回一个 resolved 状态的 Promise 对象
function makeNoise_async() {
return Q.async(function* () {
yield shake_async();
yield rattle_async();
yield roll_async();
});
}
上述两段代码最主要的区别就在于异步版本的代码必须在调用异步函数的地方添加 yield
关键词。
在 Q.async 版本的代码中增加一个 if 或者 try/catch 语句与添加到普通的同步版本中几乎一样。相比其他的编写异步代码的方式,这种方式更让人感觉不是在学习一门完全新的语言。
如果你已经到了这一步,可以尝试欣赏一下 James Long 的关于这个话题一篇比较详细的文章。
所以生成器指明了一种更为适合人类大脑的新的异步编程模型。相关的工作还在继续进行当中。相比其他东西,更好的语法会更有帮助。一种构建于 promise 和 generator 之上的更好的异步函数提案,将会在 ES7 中出现,这一提案借鉴了 C#中一些相似的特性。
什么时候可以使用这些疯狂的特性
在服务端,已经可以在 io.js (或者用 --harmony
启动的 Node) 中使用 ES6 的生成器。
而在浏览器中,目前为止只有 Firefox27+ 和 Chrome 39+ 支持 ES6 的生成器。若要在浏览器中使用之,可以使用 Babel 或者 Traceur 将 ES6 的代码转换为浏览器友好的 ES5代码。
最初,Brendan Eich 借鉴 Python 实现了 JavaScript 中的生成器,而 Python 中的生成器则是受 Icon 启发实现的。他们早在 2006年发布的 Firefox 2.0中就实现了这一特性。然而,通向标准化的路是崎岖的,一路上语法与行为都发生了不少的变化。ES6 生成器在 Firefox 和 Chrome 中都是由编译器黑客 Andy Wingo 实现的。这一工作由Bloomberg赞助。
yield;
关于生成器还有很多内容,我们没有提到 .throw()
和 .return()
方法,.next()
方法的可选参数以及 yield*
表达式语法。但是我认为这篇文章到现在已经足够长,看着可能少许有点累了。就像生成器他们一样,我们需要稍作歇息,之后找个时间继续讲解。