最近,为了更好地理解Redux Sagas的工作原理,我重学了JavaScript generators的知识,我把从网上收集到的各种知识点浓缩到一篇文章里,我希望这篇文章既通俗易懂,又足够严谨,可以作为初学者的generators使用指南。
简介
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() 方法实现 Iterator protocol >的任何一个对象,该方法返回具有两个属性的对象: value,这是序列中的 next 值;和 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:next方法的参数
到目前为止,我们一直在使用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方法用来启动遍历器对象,所以不用带有参数。
我喜欢这样对程序的执行进行合理化:
- 在第一个next()调用时,它将一直运行,直到遇到yield 'Hello world',在此基础上生成{ value: 'Hello world', done: false }和暂停。就是这么回事。正如大家所看到的,传递给第一个next()调用的任何值都是不会被使用的(因此被丢弃)。
- 当再次调用next(…)时,执行将恢复。在这种情况下,执行需要为常量foo分配一些值(由yield语句决定)。因此,我们对next(2)的第二次调用赋值foo=2。程序不会在这里停止—它会一直运行,直到遇到下一个yield或return语句。在本例中,没有