译者:前端技术小哥
最近,我不得不重新学习JavaScript生成器的基础知识,以便更好地理解Redux Sagas是如何运作的(这是另一个话题)。我决定把我能从各种网站上收集到的零碎信息拼凑起来,并将它们浓缩到一篇文章里,我希望这篇文章既通俗易懂,又足够严谨,可以作为初学者的生成器使用指南。
简介
JavaScript在ES6时引入了生成器。生成器函数与常规函数类似,除了可以暂停和恢复它们这一点以外。生成器也与迭代器密切相关,因为生成器对象就是迭代器。
在JavaScript中,函数调用后通常不能暂停或停止。(是的,异步函数在等待await
语句时暂停,但是异步函数在ES7时才引入。此外,异步函数是建立在生成器之上的。)一个普通函数只有在返回或抛出错误时才会结束。
function foo() {
console.log('Starting');
const x = 42;
console.log(x);
console.log('Stop me if you can');
console.log('But you cannot');
}
相反,生成器允许我们在任意断点处暂停执行,并从同一断点恢复执行。
生成器和迭代器
来自MDN:在JavaScript中,迭代器是一个对象,它定义一个序列,并在序列终止时可能返回一个值。更具体地说,迭代器是通过使用next()
方法实现迭代器协议的任何对象,该方法返回具有两个属性的对象:value
,序列中的下一个值;以及done
,如果序列中的最后一个值已经被使用,则为true。如果value
与done
一起出现,则它是迭代器的返回值。
因此,本质上迭代器就是:
定义序列的对象
有一个
next()
方法…返回一个具有两个属性的对象:
value
和done
是否需要生成器来创建迭代器?不。事实上,我们已经可以使用闭包pre-ES6创建一个无限的斐波那契数列,如下例所示:
var fibonacci = {
next: (function () {
var pre = 0, cur = 1;
return function () {
tmp = pre;
pre = cur;
cur += tmp;
return cur;
};
})()
};
fibonacci.next(); // 1
fibonacci.next(); // 2
fibonacci.next(); // 3
fibonacci.next(); // 5
fibonacci.next(); // 8
关于生成器的好处,我将再次引用MDN:
虽然自定义迭代器是一个有用的工具,但是由于需要显式地维护它们的内部状态,创建它们需要我们仔细地编程。生成器函数提供了一个强大的替代方法:它们允许我们通过编写一个执行不是连续的函数来定义迭代算法。
换句话说,使用生成器创建迭代器更简单(不需要闭包!),这意味着出错的可能性更小。生成器和迭代器之间的关系就是生成器函数返回的生成器对象是迭代器。
语法
生成器函数使用function *
语法创建,并使用yield
关键字暂停。
最初调用生成器函数并不执行它的任何代码;相反,它返回一个生成器对象。值通过调用生成器的next()
方法来使用,该方法执行代码,直到遇到yield
关键字,然后暂停,直到再次调用next()
。
function * makeGen() {
yield 'Hello';
yield 'World';
}
const g = makeGen(); // g is a generator
g.next(); // { value: 'Hello', done: false }
g.next(); // { value: 'World', done: false }
g.next(); // { value: undefined, done: true }
...
在上面的最后一个语句之后重复调用g.next()
只会返回(或者更准确地说,产生)相同的返回对象:{ value: undefined, done: true }
。
yield暂停执行
大家可能会注意到上面的代码片段有一些特殊之处。第二个next()
调用生成一个对象,该对象的属性为done: false
,而不是done: true
。
既然我们正在生成器函数中执行最后一条语句,那么done
属性不应该为true
吗?并不是的。当遇到yield
语句时,它后面的值(在本例中是“World”)被生成,执行暂停。因此,第二个next()
调用暂停在第二个yield
语句上,因此执行还没有完成—只有在第二个yield
语句之后执行重新开始时,执行才算完成(即done: true
),并且不再运行代码。
我们可以将next()
调用看作是告诉程序运行到下一个yield
语句(假设它存在)、生成一个值并暂停。程序在恢复执行之前不会知道yield
语句之后没有任何内容,并且只能通过另一个next()
调用恢复执行。
yield和return
在上面的示例中,我们使用yield
将值传递给生成器外部。我们也可以使用return
(就像在普通函数中一样);但是,使用return
可以终止执行并设置done: true
。
function * makeGen() {
yield 'Hello';
return 'Bye';
yield 'World';
}
const g = makeGen(); // g is a generator
g.next(); // { value: 'Hello', done: false }
g.next(); // { value: 'Bye', done: true }
g.next(); // { value: undefined, done: true }
...
因为执行不会在return
语句上暂停,而且根据定义,在return
语句之后不能执行任何代码,所以done
被设置为true
。
yield:双向沟通
到目前为止,我们一直在使用yield
传递生成器外部的值(并暂停其执行)。然而,yield
实际上是双向的,并且允许我们将值传递到生成器函数中。
function * makeGen() {
const foo = yield 'Hello world';
console.log(foo);
}
const g = makeGen();
g.next(1); // { value: 'Hello world', done: false }
g.next(2); // logs 2, yields { value: undefined, done: true }
等一下。不应该是1
登录到控制台,而不是2
吗?起初,我发现这部分在概念上与直觉相反,因为我预期的赋值foo = 1
。毕竟,我们将“1”传递到next()
方法调用中,从而生成Hello world
,对吗?
但事实并非如此。传递给第一个next(...)
调用的值将被丢弃。除了这似乎是ES6规范之外,实际上没有其他原因。您可以在这篇文章(通过示例)中阅读更多关于它的信息。
我喜欢这样对程序的执行进行合理化:
在第一个next()
调用时,它将一直运行,直到遇到yield 'Hello world'
,在此基础上生成{ value: 'Hello world', done: false }
和暂停。就是这么回事。正如大家所看到的,传递给第一个next()
调用的任何值都是不会被使用的(因此被丢弃)。
当再次调用next(...)
时,执行将恢复。在这种情况下,执行需要为常量foo
分配一些值(由yield
语句决定)。因此,我们对next(2)
的第二次调用赋值foo = 2
。程序不会在这里停止—它会一直运行,直到遇到下一个yield
或return
语句。在本例中,没有更多的yield,因此它记录2
并返回undefined
的done: true
。
在生成器使用异步因为yield
是一个双向通道,允许信息在两个方向上流动,所以它允许我们以非常酷的方式使用生成器。到目前为止,我们主要使用yield
在生成器之外传递值。但是我们也可以利用yield
的双向特性以同步方式编写异步函数。
使用上面的概念,我们可以创建一个类似于同步代码但实际上执行异步函数的基本函数:
function request(url) {
fetch(url).then(res => {
it.next(res); // Resume iterator execution
});
}
function * main() {
const rawResponse = yield request('https://some-url.com');
const returnValue = synchronouslyProcess(rawResponse);
console.log(returnValue);
}
const it = main();
it.next(); // Remember, the first next() call doesn't accept input
这是它的工作原理。首先,我们声明一个request
函数和main
生成器函数。接下来,通过调用main()
创建一个迭代器it
。然后,我们从调用it.next()
开始。
在第一行的function * main()
,在yield request('https://some-url.com')
之后执行暂停。request()
隐式地返回undefined
,因此我们实际上生成了undefined
值,但这并不重要—我们没有使用该值。
当request()
函数中的fetch()
调用完成时,it.next(res)
将会被调用并完成下列两件事:
it继续执行;和
it将
res
传递给生成器函数,该函数被分配给rawResponse
最后,main()
的其余部分将同步完成。这是一个非常基础的设置,应该与promise有一些相似之处。有关yield和异步性的更详细介绍,请参阅此文。
生成器是一次性
我们不能重复使用生成器,但可以从生成器函数创建新的生成器。
function * makeGen() {
yield 42;
}
const g1 = makeGen();
const g2 = makeGen();
g1.next(); // { value: 42, done: false }
g1.next(); // { value: undefined, done: true }
g1.next(); // No way to reset this!
g2.next(); // { value: 42, done: false }
...
const g3 = makeGen(); // Create a new generator
g3.next(); // { value: 42, done: false }
无限序列
迭代器表示序列,有点像数组。所以,我们应该能够将所有迭代器表示为数组,对吧?然而,并不是的。数组在创建时需要立即分配,而迭代器是延迟使用的。数组是迫切需要的,因为创建一个包含n个元素的数组需要首先创建/计算所有n个元素,以便将它们存储在数组中。相反,迭代器是惰性的,因为序列中的下一个值只有在使用时才会创建/计算。
因此,表示无限序列的数组在物理上是不可能的(我们需要无限内存来存储无限项!),而迭代器可以轻松地表示(而不是存储)该序列。让我们创建一个从1到正无穷数的无穷序列。与数组不同,这并不需要无限内存,因为序列中的每个值只有在使用时才会懒散地计算出来。
function * makeInfiniteSequence() {
var curr = 1;
while (true) {
yield curr;
curr += 1;
}
}
const is = makeInfiniteSequence();
is.next(); { value: 1, done: false }
is.next(); { value: 2, done: false }
is.next(); { value: 3, done: false }
... // It will never end
有趣的事实:这类似于Python生成器表达式vs列表理解。虽然这两个表达式在功能上是相同的,但是生成器表达式提供了内存优势,因为值的计算是延迟的,而列表理解则是立即计算值并创建整个列表。