【你不懂JS: 异步与性能】第四章: Generator

在第二章中,我们发现了在使用回调表达异步流程控制时的两个关键缺陷:

  • 基于回调的异步与我们的大脑规划任务的各个步骤的过程不相符。
  • 由于 控制倒转 回调是不可靠的,也是不可组合的。

在第三章中,我们详细地讨论了Promise如何反转回调的 控制倒转,重建了可靠性/可组合性。

现在让我们把注意力集中到用一种顺序的,看起来同步的风格来表达异步流程控制。使这一切成为可能的“魔法”是ES6的 generator

打破运行至完成

在第一章中,我们讲解了一个JS开发者们在他们的代码中几乎永恒依仗的一个认识:一旦函数开始执行,它将运行直至完成,没有其他的代码可以在运行期间干扰它。

这看起来可能很滑稽,ES6引入了一种新型的函数,它不按照“运行至完成”的行为进行动作。这种新型的函数称为“generator(生成器)”。

为了理解它的含义,让我们看看这个例子:

  1. var x = 1;
  2. function foo() {
  3. x++;
  4. bar(); // <-- 这一行会发生什么?
  5. console.log( "x:", x );
  6. }
  7. function bar() {
  8. x++;
  9. }
  10. foo(); // x: 3

在这个例子中,我们确信bar()会在x++console.log(x)之间运行。但如果bar()不在这里呢?很明显结果将是2而不是3

现在让我们来燃烧你的大脑。要是bar()不存在,但以某种方式依然可以在x++console.log(x)语句之间运行呢?这可能吗?

抢占式(preemptive) 多线程语言中,bar()去“干扰”并正好在两个语句之间那一时刻运行,实质上时可能的。但JS不是抢占式的,也(还)不是多线程的。但是,如果foo()本身可以用某种办法在代码的这一部分指示一个“暂停”,那么这种“干扰”(并发)的 协作 形式就是可能的。

注意: 我使用“协作”这个词,不仅是因为它与经典的并发术语有关联(见第一章),也因为正如你将在下一个代码段中看到的,ES6在代码中指示暂停点的语法是yield——暗示一个让出控制权的礼貌的 协作

这就是实现这种协作并发的ES6代码:

  1. var x = 1;
  2. function *foo() {
  3. x++;
  4. yield; // 暂停!
  5. console.log( "x:", x );
  6. }
  7. function bar() {
  8. x++;
  9. }

注意: 你将很可能在大多数其他的JS文档/代码中看到,一个generator的声明被格式化为function* foo() { .. }而不是我在这里使用的function *foo() { .. }——唯一的区别是摆放*位置的风格。这两种形式在功能性/语法上是完全一样的,还有第三种function*foo() { .. }(没空格)形式。这两种风格存在争议,但我基本上偏好function *foo..,因为当我在写作中用*foo()引用一个generator时,这种形式可以匹配我写的东西。如果我只说foo(),你就不会清楚地知道我是在说一个generator还是一个一般的函数。这纯粹是一个风格偏好的问题。

现在,我们该如何运行上面的代码,使bar()yield那一点取代*foo()的执行?

  1. // 构建一个迭代器`it`来控制generator
  2. var it = foo();
  3. // 在这里开始`foo()`!
  4. it.next();
  5. x; // 2
  6. bar();
  7. x; // 3
  8. it.next(); // x: 3

好了,这两段代码中有不少新的,可能使人困惑的东西,所以我们得跋涉好一段了。在我们用ES6的generator来讲解不同的机制/语法之前,让我们过一遍这个行为的流程:

  1. it = foo()操作 不会 执行*foo()generator,它只不过构建了一个用来控制它执行的 迭代器(iterator)。我们一会更多地讨论 迭代器
  2. 第一个it.next()启动了*foo()generator,并且运行*foo()第一行上的x++
  3. *foo()yield语句处暂停,就在这时第一个it.next()调用结束。在这个时刻,*foo()依然运行而且是活动的,但是处于暂停状态。
  4. 我们观察x的值,现在它是2.
  5. 我们调用bar(),它再一次用x++递增x
  6. 我们再一次观察x的值,现在它是3
  7. 最后的it.next()调用使*foo()generator从它暂停的地方继续运行,而后运行使用x的当前值3console.log(..)语句。

清楚的是,*foo()启动了,但 没有 运行到底——它停在yield。我们稍后继续*foo(),让它完成,但这甚至不是必须的。

所以,一个generator是一种函数,它可以开始和停止一次或多次,甚至没必要一定要完成。虽然为什么它很强大看起来不那么明显,但正如我们将要在本章剩下的部分将要讲到的,它是我们用于在我们的代码中构建“generator异步流程控制”模式的基础构建块儿之一。

输入和输出

一个generator函数是一种带有我们刚才提到的新型处理模型的函数。但它仍然是一个函数,这意味着依旧有一些不变的基本原则——即,它依然接收参数(也就是“输入”),而且它依然返回一个值(也就是“输出”):

  1. function *foo(x,y) {
  2. return x * y;
  3. }
  4. var it = foo( 6, 7 );
  5. var res = it.next();
  6. res.value; // 42

我们将67分别作为参数xy传递给*foo(..)。而*foo(..)将值42返回给调用端代码。

现在我们可以看到发生器的调用和一般函数的调用的一个不同之处了。foo(6,7)显然看起来很熟悉。但微妙的是,*foo(..)generator不会像一个函数那样实际运行起来。

相反,我们只是创建了 迭代器 对象,将它赋值给变量it,来控制*foo(..)generator。当我们调用it.next()时,它指示*foo(..)generator从现在的位置向前推进,直到下一个yield或者generator的最后。

next(..)调用的结果是一个带有value属性的对象,它持有从*foo(..)返回的任何值(如果有的话)。换句话说,yield导致在generator运行期间,一个值被从中发送出来,有点儿像一个中间的return

但是,为什么我们需要这个完全间接的 迭代器 对象来控制generator还不清楚。我们回头会讨论它的,我保证。

迭代通信

generator除了接收参数和拥有返回值,它们还内建有更强大,更吸引人的输入/输出消息能力,这是通过使用yieldnext(..)实现的。

考虑下面的代码:

  1. function *foo(x) {
  2. var y = x * (yield);
  3. return y;
  4. }
  5. var it = foo( 6 );
  6. // 开始`foo(..)`
  7. it.next();
  8. var res = it.next( 7 );
  9. res.value; // 42

首先,我们将6作为参数x传入。之后我们调用it.next(),它启动了*foo(..).

*foo(..)内部,var y = x ..语句开始被处理,但它运行到了一个yield表达式。就在这时,它暂停了*foo(..)(就在赋值语句的中间!),而且请求调用端代码为yield表达式提供一个结果值。接下来,我们调用it.next(7),将7这个值传回去作为暂停的yield表达式的结果。

所以,在这个时候,赋值语句实质上是var y = 6 * 7。现在,return y将值42作为结果返回给it.next( 7 )调用。

注意一个非常重要,而且即便是对于老练的JS开发者也非常容易犯糊涂的事情:根据你的角度,在yieldnext(..)调用之间存在着错位。一般来说,你所拥有的next(..)调用的数量,会比你所拥有的yield语句的数量多一个——前面的代码段中有一个yield和两个next(..)调用。

为什么会有这样的错位?

因为第一个next(..)总是启动一个generator,然后运行至第一个yield。但是第二个next(..)调用满足了第一个暂停的yield表达式,而第三个next(..)将满足第二个yield,如此反复。

两个疑问的故事

实际上,你主要考虑的是哪部分代码会影响你是否感知到错位。

仅考虑generator代码:

  1. var y = x * (yield);
  2. return y;

第一个 yield基本上是在 问一个问题:“我应该在这里插入什么值?”

谁来回答这个问题?好吧,第一个 next()在这个时候已经为了启动generator而运行过了,所以很明显 不能回答这个问题。所以,第二个 next(..)调用必须回答由 第一个 yield提出的问题。

看到错位了吧——第二个对第一个?

但是让我们反转一下我们的角度。让我们不从generator的角度看问题,而从迭代器的角度看。

为了恰当地描述这种角度,我们还需要解释一下,消息可以双向发送——yield ..作为表达式可以发送消息来应答next(..)调用,而next(..)可以发送值给暂停的yield表达式。考虑一下这段稍稍调整过的代码:

  1. function *foo(x) {
  2. var y = x * (yield "Hello"); // <-- 让出一个值!
  3. return y;
  4. }
  5. var it = foo( 6 );
  6. var res = it.next(); // 第一个`next()`,不传递任何东西
  7. res.value; // "Hello"
  8. res = it.next( 7 ); // 传递`7`给等待中的`yield`
  9. res.value; // 42

yield ..next(..)一起成对地 在generator运行期间 构成了一个双向消息传递系统。

那么,如果只看 迭代器 代码:

  1. var res = it.next(); // 第一个`next()`,不传递任何东西
  2. res.value; // "Hello"
  3. res = it.next( 7 ); // 传递`7`给等待中的`yield`
  4. res.value; // 42

注意: 我们没有传递任何值给第一个next()调用,而且是故意的。只有一个暂停的yield才能接收这样一个被next(..)传递的值,但是当我们调用第一个next()时,在generator的最开始并 没有任何暂停的yield 可以接收这样的值。语言规范和所有兼容此语言规范的浏览器只会无声地 丢弃 任何传入第一个next()的东西。传递这样的值是一个坏主意,因为你只不过创建了一些令人困惑的无声“失败”的代码。所以,记得总是用一个无参数的next()来启动generator。

第一个next()调用(没有任何参数的)基本上是在 问一个问题:“*foo(..)generator将要给我的 下一个 值是什么?”,谁来回答这个问题?第一个yield表达式。

看到了?这里没有错位。

根据你认为是 在问问题,在yieldnext(..)之间的错位既存在又不存在。

但等一下!跟yield语句的数量比起来,还有一个额外的next()。那么,这个最后的it.next(7)调用又一次在询问generator 下一个 产生的值是什么。但是没有yield语句剩下可以回答了,不是吗?那么谁来回答?

return语句回答这个问题!

而且如果在你的generator中 没有return——比起一般的函数,generator中的return当然不再是必须的——总会有一个假定/隐式的return;(也就是return undefined;),它默认的目的就是回答由最后的it.next(7)调用 提出 的问题。

这些问题与回答——用yieldnext(..)进行双向消息传递——十分强大,但还是看不出来这些机制与异步流程控制有什么联系。我们正在接近真相!

多迭代器

从语法使用上来看,当你用一个 迭代器 来控制generator时,你正在控制声明的generator函数本身。但这里有一个容易忽视的微妙细节:每当你构建一个 迭代器,你都隐含地构建了一个将由这个 迭代器 控制的generator的实例。

你可以让同一个generator的多个实例同时运行,它们甚至可以互动:

  1. function *foo() {
  2. var x = yield 2;
  3. z++;
  4. var y = yield (x * z);
  5. console.log( x, y, z );
  6. }
  7. var z = 1;
  8. var it1 = foo();
  9. var it2 = foo();
  10. var val1 = it1.next().value; // 2 <-- 让出2
  11. var val2 = it2.next().value; // 2 <-- 让出2
  12. val1 = it1.next( val2 * 10 ).value; // 40 <-- x:20, z:2
  13. val2 = it2.next( val1 * 5 ).value; // 600 <-- x:200, z:3
  14. it1.next( val2 / 2 ); // y:300
  15. // 20 300 3
  16. it2.next( val1 / 4 ); // y:10
  17. // 200 10 3

警告: 同一个generator的多个并发运行实例的最常见的用法,不是这样的互动,而是generator在没有输入的情况下,从一些连接着的独立资源中产生它自己的值。我们将在下一节中更多地讨论产生值。

让我们简单地走一遍这个处理过程:

  1. 两个*foo()在同时启动,而且两个next()都分别从yield 2语句中得到了2value
  2. val2 * 10就是2 * 10,它被发送到第一个generator实例it1,所以x得到值20z1递增至2,然后20 * 2yield出来,将val1设置为40
  3. val1 * 5就是40 * 5,它被发送到第二个generator实例it2中,所以x得到值200z又一次递增,从23,然后200 * 3yield出来,将val2设置为600
  4. val2 / 2就是600 / 2,它被发送到第一个generator实例it1,所以y得到值300,然后分别为它的x y z值打印出20 300 3
  5. val1 / 4就是40 / 4,它被发送到第一个generator实例it2,所以y得到值10,然后分别为它的x y z值打印出200 10 3

这是在你脑海中跑过的一个“有趣”的例子。你还能保持清醒?

穿插

回想第一章中“运行至完成”一节的这个场景:

  1. var a = 1;
  2. var b = 2;
  3. function foo() {
  4. a++;
  5. b = b * a;
  6. a = b + 3;
  7. }
  8. function bar() {
  9. b--;
  10. a = 8 + b;
  11. b = a * 2;
  12. }

使用普通的JS函数,当然要么是foo()可以首先运行完成,要么是bar()可以首先运行至完成,但是foo()不可能与bar()穿插它的独立语句。所以,前面这段代码只有两个可能的结果。

然而,使用generator,明确地穿插(甚至是在语句中间!)是可能的:

  1. var a = 1;
  2. var b = 2;
  3. function *foo() {
  4. a++;
  5. yield;
  6. b = b * a;
  7. a = (yield b) + 3;
  8. }
  9. function *bar() {
  10. b--;
  11. yield;
  12. a = (yield 8) + b;
  13. b = a * (yield 2);
  14. }

根据 迭代器 控制*foo()*bar()分别以什么样的顺序被调用,前面这段代码可以产生几种不同的结果。换句话说,通过两个generator在同一个共享的变量上穿插,我们实际上可以展示(以一种模拟的方式)在第一章中讨论的,理论上的“线程的竞合状态”环境。

首先,让我们制造一个称为step(..)的帮助函数,让它控制 迭代器

  1. function step(gen) {
  2. var it = gen();
  3. var last;
  4. return function() {
  5. // 不论`yield`出什么,只管在下一次时直接把它塞回去!
  6. last = it.next( last ).value;
  7. };
  8. }

step(..)初始化一个generator来创建它的it 迭代器,然后它返回一个函数,每次这个函数被调用时,都将 迭代器 向前推一步。另外,前一个被yield出来的值将被直接发给下一步。所以,yield 8将变成8yield b将成为b(不管它在yield时是什么值)。

现在,为了好玩儿,让我们做一些实验,来看看将这些*foo()*bar()的不同块儿穿插时的效果。我们从一个无聊的基本情况开始,保证*foo()*bar()之前全部完成(就像我们在第一章中做的那样):

  1. // 确保重置了`a`和`b`
  2. a = 1;
  3. b = 2;
  4. var s1 = step( foo );
  5. var s2 = step( bar );
  6. // 首先完全运行`*foo()`
  7. s1();
  8. s1();
  9. s1();
  10. // 现在运行`*bar()`
  11. s2();
  12. s2();
  13. s2();
  14. s2();
  15. console.log( a, b ); // 11 22

最终结果是1122,就像第一章的版本那样。现在让我们把顺序混合穿插,来看看它如何改变ab的值。

  1. // 确保重置了`a`和`b`
  2. a = 1;
  3. b = 2;
  4. var s1 = step( foo );
  5. var s2 = step( bar );
  6. s2(); // b--;
  7. s2(); // 让出 8
  8. s1(); // a++;
  9. s2(); // a = 8 + b;
  10. // 让出 2
  11. s1(); // b = b * a;
  12. // 让出 b
  13. s1(); // a = b + 3;
  14. s2(); // b = a * 2;

在我告诉你结果之前,你能指出在前面的程序运行之后ab的值是什么吗?不要作弊!

  1. console.log( a, b ); // 12 18

注意: 作为留给读者的练习,试试通过重新安排s1()s2()调用的顺序,看看你能得到多少种结果组合。别忘了你总是需要三个s1()调用和四个s2()调用。至于为什么,回想一下刚才关于使用yield匹配next()的讨论。

当然,你几乎不会想有意制造 这种 水平的,令人糊涂的穿插,因为他创建了非常难理解的代码。但是这个练习很有趣,而且对于理解多个generator如何并发地运行在相同的共享作用域来说很有教育意义,因为会有一些地方这种能力十分有用。

我们会在本章末尾更详细地讨论generator并发。

生成值

在前一节中,我们提到了一个generator的有趣用法,作为一种生产值的方式。这 不是 我们本章主要关注的,但如果我们不在这里讲一下基本我们会想念它的,特别是因为这种用法实质上是它的名称的由来:生成器。

我们将要稍稍深入一下 迭代器 的话题,但我们会绕回到它们如何与generator关联,并使用generator来 生成 值。

发生器与迭代器

想象你正在生产一系列的值,它们中的每一个都与前一个值有可定义的关系。为此,你将需要一个有状态的发生器来记住上一个给出的值。

你可以用函数闭包(参加本系列的 作用域与闭包)来直接地实现这样的东西:

  1. var gimmeSomething = (function(){
  2. var nextVal;
  3. return function(){
  4. if (nextVal === undefined) {
  5. nextVal = 1;
  6. }
  7. else {
  8. nextVal = (3 * nextVal) + 6;
  9. }
  10. return nextVal;
  11. };
  12. })();
  13. gimmeSomething(); // 1
  14. gimmeSomething(); // 9
  15. gimmeSomething(); // 33
  16. gimmeSomething(); // 105

注意: 这里nextVal的计算逻辑已经被简化了,但从概念上讲,直到 下一次 gimmeSomething()调用发生之前,我们不想计算 下一个值(也就是nextVal),因为一般对于持久性更强的,或者比简单的number更有限的资源的发生器来说,那可能是一种资源泄漏的设计。

生成随意的数字序列不是是一个很真实的例子。但是如果你从一个数据源中生成记录呢?你可以想象很多相同的代码。

事实上,这种任务是一种非常常见的设计模式,通常用迭代器解决。一个 迭代器 是一个明确定义的接口,用来逐个通过一系列从发生器得到的值。迭代器的JS接口,和大多数语言一样,是在你每次想从发生器中得到下一个值时调用的next()

我们可以为我们的数字序列发生器实现标准的 迭代器

  1. var something = (function(){
  2. var nextVal;
  3. return {
  4. // `for..of`循环需要这个
  5. [Symbol.iterator]: function(){ return this; },
  6. // 标准的迭代器接口方法
  7. next: function(){
  8. if (nextVal === undefined) {
  9. nextVal = 1;
  10. }
  11. else {
  12. nextVal = (3 * nextVal) + 6;
  13. }
  14. return { done:false, value:nextVal };
  15. }
  16. };
  17. })();
  18. something.next().value; // 1
  19. something.next().value; // 9
  20. something.next().value; // 33
  21. something.next().value; // 105

注意: 我们将在“Iterables”一节中讲解为什么我们在这个代码段中需要[Symbol.iterator]: ..这一部分。在语法上讲,两个ES6特性在发挥作用。首先,[ .. ]语法称为一个 计算型属性名(参见本系列的 this与对象原型)。它是一种字面对象定义方法,用来指定一个表达式并使用这个表达式的结果作为属性名。另一个,Symbol.iterator是ES6预定义的特殊Symbol值。

next()调用返回一个对象,它带有两个属性:done是一个boolean值表示 迭代器 的完成状态;value持有迭代的值。

ES6还增加了for..of循环,它意味着一个标准的 迭代器 可以使用原生的循环语法来自动地被消费:

  1. for (var v of something) {
  2. console.log( v );
  3. // 不要让循环永无休止!
  4. if (v > 500) {
  5. break;
  6. }
  7. }
  8. // 1 9 33 105 321 969

注意: 因为我们的something迭代器总是返回done:false,这个for..of循环将会永远运行,这就是为什么我们条件性地放进一个break。对于迭代器来说永不终结是完全没有问题的,但是也有一些情况 迭代器 将运行在有限的值的集合上,而最终返回done:true

for..of循环为每一次迭代自动调用next()——他不会给next()传入任何值——而且他将会在收到一个done:true时自动终结。这对于在一个集合的数据中进行循环十分方便。

当然,你可以手动循环一个迭代器,调用next()并检查done:true条件来知道什么时候停止:

  1. for (
  2. var ret;
  3. (ret = something.next()) && !ret.done;
  4. ) {
  5. console.log( ret.value );
  6. // 不要让循环永无休止!
  7. if (ret.value > 500) {
  8. break;
  9. }
  10. }
  11. // 1 9 33 105 321 969

注意: 这种手动的for方式当然要比ES6的for..of循环语法难看,但它的好处是它提供给你一个机会,在有必要时传值给next(..)调用。

除了制造你自己的 迭代器 之外,许多JS中(就ES6来说)内建的数据结构,比如array,也有默认的 迭代器

  1. var a = [1,3,5,7,9];
  2. for (var v of a) {
  3. console.log( v );
  4. }
  5. // 1 3 5 7 9

for..of循环向a要来它的迭代器,并自动使用它迭代a的值。

注意: 看起来像是一个ES6的奇怪省略,普通的object有意地不带有像array那样的默认 迭代器。原因比我们要在这里讲的深刻得多。如果你想要的只是迭代一个对象的属性(不特别保证顺序),Object.keys(..)返回一个array,它可以像for (var k of Object.keys(obj)) { ..这样使用。像这样用for..of循环一个对象上的键,与用for..in循环内很相似,除了在for..in中会包含[[Prototype]]链的属性,而Object.keys(..)不会(参见本系列的 this与对象原型)。

Iterables

在我们运行的例子中的something对象被称为一个 迭代器,因为它的接口中有next()方法。但一个紧密关联的术语是 iterable,它指 包含有 一个可以迭代它所有值的迭代器的对象。

在ES6中,从一个 iterable 中取得一个 迭代器 的方法是,iterable 上必须有一个函数,它的名称是特殊的ES6符号值Symbol.iterator。当这个函数被调用时,它就会返回一个 迭代器。虽然不是必须的,但一般来说每次调用应当返回一个全新的 迭代器

前一个代码段的a就是一个 iterablefor..of循环自动地调用它的Symbol.iterator函数来构建一个 迭代器。我们当然可以手动地调用这个函数,然后使用它返回的 iterator

  1. var a = [1,3,5,7,9];
  2. var it = a[Symbol.iterator]();
  3. it.next().value; // 1
  4. it.next().value; // 3
  5. it.next().value; // 5
  6. ..

在前面定义something的代码段中,你可能已经注意到了这一行:

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

这段有点让人困惑的代码制造了something值——something迭代器 的接口——也是一个 iterable;现在它既是一个 iterable 也是一个 迭代器。然后,我们把something传递给for..of循环:

  1. for (var v of something) {
  2. ..
  3. }

for..of循环期待something是一个 iterable,所以它会寻找并调用它的Symbol.iterator函数。我们将这个函数定义为简单地return this,所以它将自己给出,而for..of不会知道这些。

Generator迭代器

带着 迭代器 的背景知识,让我们把注意力移回generator。一个generator可以被看做一个值的发生器,我们通过一个 迭代器 接口的next()调用每次从中抽取一个值。

所以,一个generator本身在技术上讲并不是一个 iterable,虽然很相似——当你执行generator时,你就得到一个 迭代器

  1. function *foo(){ .. }
  2. var it = foo();

我们可以用generator实现早前的something无限数字序列发生器,就像这样:

  1. function *something() {
  2. var nextVal;
  3. while (true) {
  4. if (nextVal === undefined) {
  5. nextVal = 1;
  6. }
  7. else {
  8. nextVal = (3 * nextVal) + 6;
  9. }
  10. yield nextVal;
  11. }
  12. }

注意: 在一个真实的JS程序中含有一个while..true循环通常是一件非常不好的事情,至少如果它没有一个breakreturn语句,那么它就很可能永远运行,并同步地,阻塞/锁定浏览器UI。然而,在generator中,如果这样的循环含有一个yield,那它就是完全没有问题的,因为generator将在每次迭代后暂停,yield回主程序和/或事件轮询队列。说的明白点儿,“generator把while..true带回到JS编程中了!”

这变得相当干净和简单点儿了,对吧?因为generator会暂停在每个yield*something()函数的状态(作用域)被保持着,这意味着没有必要用闭包的模板代码来跨调用保留变量的状态了。

不仅是更简单的代码——我们不必自己制造 迭代器 接口了——它实际上是更合理的代码,因为它更清晰地表达了意图。比如,while..true循环告诉我们这个generator将要永远运行——只要我们一直向它请求,它就一直 产生 值。

现在我们可以在for..of循环中使用新得发亮的*something()generator了,而且你会看到它工作起来基本一模一样:

  1. for (var v of something()) {
  2. console.log( v );
  3. // 不要让循环永无休止!
  4. if (v > 500) {
  5. break;
  6. }
  7. }
  8. // 1 9 33 105 321 969

不要跳过for (var v of something()) ..!我们不仅仅像之前的例子那样将something作为一个值引用了,而是调用*something()generator来得到它的 迭代器,并交给for..of使用。

如果你仔细观察,在这个generator和循环的互动中,你可能会有两个疑问:

  • 为什么我们不能说for (var v of something) ..?因为这个something是一个generator,而不是一个 iterable。我们不得不调用something()来构建一个发生器给for..of,以便它可以迭代。
  • something()调用创建一个 迭代器,但是for..of想要一个 iterable,对吧?对,generator的 迭代器 上也有一个Symbol.iterator函数,这个函数基本上就是return this,就像我们刚才定义的somethingiterable。换句话说generator的 迭代器 也是一个 iterable

停止Generator

在前一个例子中,看起来在循环的break被调用后,*something()generator的 迭代器 实例基本上被留在了一个永远挂起的状态。

但是这里有一个隐藏的行为为你处理这件事。for..of循环的“异常完成”(“提前终结”等等)——一般是由breakreturn,或未捕捉的异常导致的——会向generator的 迭代器 发送一个信号,以使它终结。

注意: 技术上讲,for..of循环也会在循环正常完成时向 迭代器 发送这个信号。对于generator来说,这实质上是一个无实际意义的操作,因为generator的 迭代器 要首先完成,for..of循环才能完成。然而,自定义的 迭代器 可能会希望从for..of循环的消费者那里得到另外的信号。

虽然一个for..of循环将会自动发送这种信号,你可能会希望手动发送信号给一个 迭代器;你可以通过调用return(..)来这么做。

如果你在generator内部指定一个try..finally从句,它将总是被执行,即便是generator从外部被完成。这在你需要进行资源清理时很有用(数据库连接等):

  1. function *something() {
  2. try {
  3. var nextVal;
  4. while (true) {
  5. if (nextVal === undefined) {
  6. nextVal = 1;
  7. }
  8. else {
  9. nextVal = (3 * nextVal) + 6;
  10. }
  11. yield nextVal;
  12. }
  13. }
  14. // 清理用的从句
  15. finally {
  16. console.log( "cleaning up!" );
  17. }
  18. }

前面那个在for..of中带有break的例子将会触发finally从句。但是你可以用return(..)从外部来手动终结generator的 迭代器 实例:

  1. var it = something();
  2. for (var v of it) {
  3. console.log( v );
  4. // 不要让循环永无休止!
  5. if (v > 500) {
  6. console.log(
  7. // 使generator得迭代器完成
  8. it.return( "Hello World" ).value
  9. );
  10. // 这里不需要`break`
  11. }
  12. }
  13. // 1 9 33 105 321 969
  14. // cleaning up!
  15. // Hello World

当我们调用it.return(..)时,它会立即终结generator,从而运行finally从句。而且,它会将返回的value设置为你传入return(..)的任何东西,这就是Hellow World如何立即返回来的。我们现在也不必再包含一个break,因为generator的 迭代器 会被设置为done:true,所以for..of循环会在下一次迭代时终结。

generator的命名大部分源自于这种 消费生产的值 的用法。但要重申的是,这只是generator的用法之一,而且坦白的说,在这本书的背景下这甚至不是我们主要关注的。

但是现在我们更加全面地了解它们的机制是如何工作的,我们接下来可以将注意力转向generator如何实施于异步并发。

异步地迭代Generator

generator要怎样处理异步编码模式,解决回调和类似的问题?让我们开始回答这个重要的问题。

我们应当重温一下第三章的一个场景。回想一下这个回调方式:

  1. function foo(x,y,cb) {
  2. ajax(
  3. "http://some.url.1/?x=" + x + "&y=" + y,
  4. cb
  5. );
  6. }
  7. foo( 11, 31, function(err,text) {
  8. if (err) {
  9. console.error( err );
  10. }
  11. else {
  12. console.log( text );
  13. }
  14. } );

如果我们想用generator表示相同的任务流控制,我们可以:

  1. function foo(x,y) {
  2. ajax(
  3. "http://some.url.1/?x=" + x + "&y=" + y,
  4. function(err,data){
  5. if (err) {
  6. // 向`*main()`中扔进一个错误
  7. it.throw( err );
  8. }
  9. else {
  10. // 使用收到的`data`来继续`*main()`
  11. it.next( data );
  12. }
  13. }
  14. );
  15. }
  16. function *main() {
  17. try {
  18. var text = yield foo( 11, 31 );
  19. console.log( text );
  20. }
  21. catch (err) {
  22. console.error( err );
  23. }
  24. }
  25. var it = main();
  26. // 使一切开始运行!
  27. it.next();

一眼看上去,这个代码段要比以前的回调代码更长,而且也许看起来更复杂。但不要让这种印象误导你。generator的代码段实际上要好 太多 了!但是这里有很多我们需要讲解的。

首先,让我们看看代码的这一部分,也是最重要的部分:

  1. var text = yield foo( 11, 31 );
  2. console.log( text );

花一点时间考虑一下这段代码如何工作。我们调用了一个普通的函数foo(..),而且我们显然可以从Ajax调用那里得到text,即便它是异步的。

这怎么可能?如果你回忆一下第一章的最开始,我们有一个几乎完全一样的代码:

  1. var data = ajax( "..url 1.." );
  2. console.log( data );

但是这段代码不好用!你能发现不同吗?它就是在generator中使用的yield

这就是魔法发生的地方!是它允许我们拥有一个看起来是阻塞的,同步的,但实际上不会阻塞整个程序的代码;它仅仅暂停/阻塞在generator本身的代码。

yield foo(11,31)中,首先foo(11,31)调用被发起,它什么也不返回(也就是undefined),所以我们发起了数据请求,然后我们实际上做的是yield undefined。这没问题,因为这段代码现在没有依赖yield的值来做任何有趣的事。我们在本章稍后再重新讨论这个问题。

在这里,我们没有将yield作为消息传递的工具,只是作为进行暂停/阻塞的流程控制的工具。实际上,它会传递消息,但是只是单向的,在generator被继续运行之后。

那么,generator暂停在了yield,它实质上再问一个问题,“我该将什么值返回并赋给变量text?”谁来回答这个问题?

看一下foo(..)。如果Ajax请求成功,我们调用:

  1. it.next( data );

这将使generator使用应答数据继续运行,这意味着我们暂停的yield表达式直接收到这个值,然后因为它重新开始以运行generator代码,所以这个值被赋给本地变量text

很酷吧?

退一步考虑一下它的意义。我们在generator内部的代码看起来完全是同步的(除了yield关键字本身),但隐藏在幕后的是,在foo(..)内部,操作可以完全是异步的。

这很伟大! 这几乎完美地解决了我们前面遇到的问题:回调不能像我们的大脑可以关联的那样,以一种顺序,同步的风格表达异步处理。

实质上,我们将异步处理作为实现细节抽象出去,以至于我们可以同步地/顺序地推理我们的流程控制:“发起Ajax请求,然后在它完成之后打印应答。” 当然,我们仅仅在这个流程控制中表达了两个步骤,但同样的能力可以无边界地延伸,让我们需要表达多少步骤,就表达多少。

提示: 这是一个如此重要的认识,为了充分理解,现在回过头去再把最后三段读一遍!

同步错误处理

但是前面的generator代码会 出更多的好处给我们。让我们把注意力移到generator内部的try..catch上:

  1. try {
  2. var text = yield foo( 11, 31 );
  3. console.log( text );
  4. }
  5. catch (err) {
  6. console.error( err );
  7. }

这是怎么工作的?foo(..)调用是异步完成的,try..catch不是无法捕捉异步错误吗?就像我们在第三章中看到的?

我们已经看到了yield如何让赋值语句暂停,来等待foo(..)去完成,以至于完成的响应可以被赋予text。牛X的是,yield暂停 允许generator来catch一个错误。我们在前面的例子,我们用这一部分代码将这个错误抛出到generator中:

  1. if (err) {
  2. // 向`*main()`中扔进一个错误
  3. it.throw( err );
  4. }

generator的yield暂停特性不仅意味着我们可以从异步的函数调用那里得到看起来同步的return值,还意味着我们可以同步地捕获这些异步函数调用的错误!

那么我们看到了,我们可以将错误 抛入 generator,但是将错误 抛出 一个generator呢?和你期望的一样:

  1. function *main() {
  2. var x = yield "Hello World";
  3. yield x.toLowerCase(); // 引发一个异常!
  4. }
  5. var it = main();
  6. it.next().value; // Hello World
  7. try {
  8. it.next( 42 );
  9. }
  10. catch (err) {
  11. console.error( err ); // TypeError
  12. }

当然,我们本可以用throw ..手动地抛出一个错误,而不是制造一个异常。

我们甚至可以catch我们throw(..)进generator的同一个错误,实质上给了generator一个机会来处理它,但如果generator没处理,那么 迭代器 代码必须处理它:

  1. function *main() {
  2. var x = yield "Hello World";
  3. // 永远不会跑到这里
  4. console.log( x );
  5. }
  6. var it = main();
  7. it.next();
  8. try {
  9. // `*main()`会处理这个错误吗?我们走着瞧!
  10. it.throw( "Oops" );
  11. }
  12. catch (err) {
  13. // 不,它没处理!
  14. console.error( err ); // Oops
  15. }

使用异步代码的,看似同步的错误处理(通过try..catch)在可读性和可推理性上大获全胜。

Generators + Promises

在我们前面的讨论中,我们展示了generator如何可以异步地迭代,这是一个用顺序的可推理性来取代混乱如面条的回调的一个巨大进步。但我们丢掉了两个非常重要的东西:Promise的可靠性和可组合性(见第三章)!

别担心——我们会把它们拿回来。在ES6的世界中最棒的就是将generator(看似同步的异步代码)与Promise(可靠性和可组合性)组合起来。

但怎么做呢?

回想一下第三章中我们基于Promise的方式运行Ajax的例子:

  1. function foo(x,y) {
  2. return request(
  3. "http://some.url.1/?x=" + x + "&y=" + y
  4. );
  5. }
  6. foo( 11, 31 )
  7. .then(
  8. function(text){
  9. console.log( text );
  10. },
  11. function(err){
  12. console.error( err );
  13. }
  14. );

在我们早先的运行Ajax的例子的generator代码中,foo(..)什么也不返回(undefined),而且我们的 迭代器 控制代码也不关心yield的值。

但这里的Promise相关的foo(..)在发起Ajax调用后返回一个promise。这暗示着我们可以用foo(..)构建一个promise,然后从generator中yield出来,而后 迭代器 控制代码将可以收到这个promise。

那么 迭代器 应当对promise做什么?

它应当监听promise的解析(完成或拒绝),然后要么使用完成消息继续运行generator,要么使用拒绝理由向generator抛出错误。

让我重复一遍,因为它如此重要。发挥Promise和generator的最大功效的自然方法是 yield一个Promise,并将这个Promise连接到generator的 迭代器 的控制端。

让我们试一下!首先,我们将Promise相关的foo(..)与generator*main()放在一起:

  1. function foo(x,y) {
  2. return request(
  3. "http://some.url.1/?x=" + x + "&y=" + y
  4. );
  5. }
  6. function *main() {
  7. try {
  8. var text = yield foo( 11, 31 );
  9. console.log( text );
  10. }
  11. catch (err) {
  12. console.error( err );
  13. }
  14. }

在这个重构中最强大的启示是,*main()内部的代码 更本就没变! 在generator内部,无论什么样的值被yield出去都是一个不可见的实现细节,所以我们甚至不会察觉它发生了,也不用担心它。

那么我们现在如何运行*main()?我们还有一些管道的实现工作要做,接收并连接yield的promise,使它能够根据解析来继续运行generator。我们从手动这么做开始:

  1. var it = main();
  2. var p = it.next().value;
  3. // 等待`p` promise解析
  4. p.then(
  5. function(text){
  6. it.next( text );
  7. },
  8. function(err){
  9. it.throw( err );
  10. }
  11. );

其实,根本不费事,对吧?

这段代码应当看起来与我们早前做的很相似:手动地连接被错误优先的回调控制的generator。与if (err) { it.throw..不同的是,promise已经为我们分割为完成(成功)与拒绝(失败),否则 迭代器 控制是完全相同的。

现在,我们已经掩盖了一些重要的细节。

最重要的是,我们利用了这样一个事实:我们知道*main()里面只有一个Promise相关的步骤。如果我们想要能用Promise驱动一个generator而不管它有多少步骤呢?我们当然不想为每一个generator手动编写一个不同的Promise链!要是有这样一种方法该多好:可以重复(也就是“循环”)迭代的控制,而且每次一有Promise出来,就在继续之前等待它的解析。

另外,如果generator在it.next()调用期间抛出一个错误怎么办?我们是该退出,还是应该catch它并把它送回去?相似地,要是我们it.throw(..)一个Promise拒绝给generator,但是没有被处理,又直接回来了呢?

带有Promise的Generator运行器

你在这条路上探索得越远,你就越能感到,“哇,要是有一些工具能帮我做这些就好了。”而且你绝对是对的。这是一种如此重要的模式,而且你不想把它弄错(或者因为一遍又一遍地重复它而把自己累死),所以你最好的选择是把赌注压在一个工具上,而它以我们将要描述的方式使用这种特定设计的工具来 运行 yieldPromise的generator。

有几种Promise抽象库提供了这样的工具,包括我的 asynquence 库和它的runner(..),我们将在本书的在附录A中讨论它。

但看在学习和讲解的份儿上,让我们定义我们自己的名为run(..)的独立工具:

  1. // 感谢Benjamin Gruenbaum (@benjamingr在GitHub)在此做出的巨大改进!
  2. function run(gen) {
  3. var args = [].slice.call( arguments, 1), it;
  4. // 在当前的上下文环境中初始化generator
  5. it = gen.apply( this, args );
  6. // 为generator的完成返回一个promise
  7. return Promise.resolve()
  8. .then( function handleNext(value){
  9. // 运行至下一个让出的值
  10. var next = it.next( value );
  11. return (function handleResult(next){
  12. // generator已经完成运行了?
  13. if (next.done) {
  14. return next.value;
  15. }
  16. // 否则继续执行
  17. else {
  18. return Promise.resolve( next.value )
  19. .then(
  20. // 在成功的情况下继续异步循环,将解析的值送回generator
  21. handleNext,
  22. // 如果`value`是一个拒绝的promise,就将错误传播回generator自己的错误处理g
  23. function handleErr(err) {
  24. return Promise.resolve(
  25. it.throw( err )
  26. )
  27. .then( handleResult );
  28. }
  29. );
  30. }
  31. })(next);
  32. } );
  33. }

如你所见,它可能比你想要自己编写的东西复杂得多,特别是你将不会想为每个你使用的generator重复这段代码。所以,一个帮助工具/库绝对是可行的。虽然,我鼓励你花几分钟时间研究一下这点代码,以便对如何管理generator+Promise交涉得到更好的感觉。

你如何在我们 正在讨论 的Ajax例子中将run(..)*main()一起使用呢?

  1. function *main() {
  2. // ..
  3. }
  4. run( main );

就是这样!按照我们连接run(..)的方式,它将自动地,异步地推进你传入的generator,直到完成。

注意: 我们定义的run(..)返回一个promise,它被连接成一旦generator完成就立即解析,或者收到一个未捕获的异常,而generator没有处理它。我们没有在这里展示这种能力,但我们会在本章稍后回到这个话题。

ES7: asyncawait

前面的模式——generator让出一个Promise,然后这个Promise控制generator的 迭代器 向前推进至它完成——是一个如此强大和有用的方法,如果我们能不通过乱七八糟的帮助工具库(也就是run(..))来使用它就更好了。

在这方面可能有一些好消息。在写作这本书的时候,后ES6,ES7化的时间表上已经出现了草案,对这个问题提供早期但强大的附加语法支持。显然,现在还太早而不能保证其细节,但是有相当大的可能性它将蜕变为类似于下面的东西:

  1. function foo(x,y) {
  2. return request(
  3. "http://some.url.1/?x=" + x + "&y=" + y
  4. );
  5. }
  6. async function main() {
  7. try {
  8. var text = await foo( 11, 31 );
  9. console.log( text );
  10. }
  11. catch (err) {
  12. console.error( err );
  13. }
  14. }
  15. main();

如你所见,这里没有run(..)调用(意味着不需要工具库!)来驱动和调用main()——它仅仅像一个普通函数那样被调用。另外,main()不再作为一个generator函数声明;它是一种新型的函数:async function。而最后,与yield一个Promise相反,我们await它解析。

如果你await一个Promise,async function会自动地知道做什么——它会暂停这个函数(就像使用generator那样)直到Promise解析。我们没有在这个代码段中展示,但是调用一个像main()这样的异步函数将自动地返回一个promise,它会在函数完全完成时被解析。

提示: async / await的语法应该对拥有C#经验的读者看起来非常熟悉,因为它们基本上是一样的。

这个草案实质上是为我们已经衍生出的模式进行代码化的支持,成为一种语法机制:用看似同步的流程控制代码与Promise组合。将两个世界的最好部分组合,来有效解决我们用回调遇到的几乎所有主要问题。

这样的ES7化草案已经存在,并且有了早期的支持和热忱的拥护。这一事实为这种异步模式在未来的重要性上信心满满地投了有力的一票。

Generator中的Promise并发

至此,所有我们展示过的是一种使用Promise+generator的单步异步流程。但是现实世界的代码将总是有许多异步步骤。

如果你不小心,generator看似同步的风格也许会蒙蔽你,使你在如何构造你的异步并发上感到自满,导致性能次优的模式。那么我们想花一点时间来探索一下其他选项。

想象一个场景,你需要从两个不同的数据源取得数据,然后将这些应答组合来发起第三个请求,最后打印出最终的应答。我们在第三章中用Promise探索过类似的场景,但这次让我们在generator的环境下考虑它。

你的第一直觉可能是像这样的东西:

  1. function *foo() {
  2. var r1 = yield request( "http://some.url.1" );
  3. var r2 = yield request( "http://some.url.2" );
  4. var r3 = yield request(
  5. "http://some.url.3/?v=" + r1 + "," + r2
  6. );
  7. console.log( r3 );
  8. }
  9. // 使用刚才定义的`run(..)`工具
  10. run( foo );

这段代码可以工作,但在我们特定的这个场景中,它不是最优的。你能发现为什么吗?

因为r1r2请求可以——而且为了性能的原因,应该——并发运行,但在这段代码中它们将顺序地运行;直到"http://some.url.1"请求完成之前,"http://some.url.2"URL不会被Ajax取得。这两个请求是独立的,所以性能更好的方式可能是让它们同时运行。

但是使用generator和yield,到底应该怎么做?我们知道yield在代码中只是一个单独的暂停点,所以你根本不能再同一时刻做两次暂停。

最自然和有效的答案是基于Promise的异步流程,特别是因为它们的时间无关的状态管理能力(参见第三章的“未来的值”)。

最简单的方式:

  1. function *foo() {
  2. // 使两个请求“并行”
  3. var p1 = request( "http://some.url.1" );
  4. var p2 = request( "http://some.url.2" );
  5. // 等待两个promise都被解析
  6. var r1 = yield p1;
  7. var r2 = yield p2;
  8. var r3 = yield request(
  9. "http://some.url.3/?v=" + r1 + "," + r2
  10. );
  11. console.log( r3 );
  12. }
  13. // 使用刚才定义的`run(..)`工具
  14. run( foo );

为什么这与前一个代码段不同?看看yield在哪里和不在哪里。p1p2是并发地(也就是“并行”)发起的Ajax请求promise。它们哪一个先完成都不要紧,因为promise会一直保持它们的解析状态。

然后我们使用两个连续的yield语句等待并从promise中取得解析值(分别取到r1r2中)。如果p1首先解析,yield p1会首先继续执行然后等待yield p2继续执行。如果p2首先解析,它将会耐心地保持解析值知道被请求,但是yield p1将会首先停住,直到p1解析。

不管是哪一种情况,p1p2都将并发地运行,并且在r3 = yield request..Ajax请求发起之前,都必须完成,无论以哪种顺序。

如果这种流程控制处理模型听起来很熟悉,那是因为它基本上和我们在第三章中介绍的,因Promise.all([ .. ])工具成为可能的“门”模式是相同的。所以,我们也可以像这样表达这种流程控制:

  1. function *foo() {
  2. // 使两个请求“并行”并等待两个promise都被解析
  3. var results = yield Promise.all( [
  4. request( "http://some.url.1" ),
  5. request( "http://some.url.2" )
  6. ] );
  7. var r1 = results[0];
  8. var r2 = results[1];
  9. var r3 = yield request(
  10. "http://some.url.3/?v=" + r1 + "," + r2
  11. );
  12. console.log( r3 );
  13. }
  14. // 使用前面定义的`run(..)`工具
  15. run( foo );

注意: 就像我们在第三章中讨论的,我们甚至可以用ES6解构赋值来把var r1 = .. var r2 = ..赋值简写为var [r1,r2] = results

换句话说,在generator+Promise的方式中,Promise所有的并发能力都是可用的。所以在任何地方,如果你需要比“这个然后那个”要复杂的顺序异步流程步骤时,Promise都可能是最佳选择。

Promises,隐藏起来

作为代码风格的警告要说一句,要小心你在 你的generator内部 包含了多少Promise逻辑。以我们描述过的方式在异步性上使用generator的全部意义,是要创建简单,顺序,看似同步的代码,并尽可能多地将异步性细节隐藏在这些代码之外。

比如,这可能是一种更干净的方式:

  1. // 注意:这是一个普通函数,不是generator
  2. function bar(url1,url2) {
  3. return Promise.all( [
  4. request( url1 ),
  5. request( url2 )
  6. ] );
  7. }
  8. function *foo() {
  9. // 将基于Promise的并发细节隐藏在`bar(..)`内部
  10. var results = yield bar(
  11. "http://some.url.1",
  12. "http://some.url.2"
  13. );
  14. var r1 = results[0];
  15. var r2 = results[1];
  16. var r3 = yield request(
  17. "http://some.url.3/?v=" + r1 + "," + r2
  18. );
  19. console.log( r3 );
  20. }
  21. // 使用刚才定义的`run(..)`工具
  22. run( foo );

*foo()内部,它更干净更清晰地表达了我们要做的事情:我们要求bar(..)给我们一些results,而我们将用yield等待它的发生。我们不必关心在底层一个Promise.all([ .. ])的Promise组合将被用来完成任务。

我们将异步性,特别是Promise,作为一种实现细节。

如果你要做一种精巧的序列流控制,那么将你的Promise逻辑隐藏在一个仅仅从你的generator中调用的函数里特别有用。举个例子:

  1. function bar() {
  2. return Promise.all( [
  3. baz( .. )
  4. .then( .. ),
  5. Promise.race( [ .. ] )
  6. ] )
  7. .then( .. )
  8. }

有时候这种逻辑是必须的,而如果你直接把它扔在你的generator内部,你就违背了大多数你使用generator的初衷。我们 应当 有意地将这样的细节从generator代码中抽象出去,以使它们不会搞乱更高层的任务表达。

在创建功能强与性能好的代码之上,你还应当努力使代码尽可能地容易推理和维护。

注意: 对于编程来说,抽象不总是一种健康的东西——许多时候它可能在得到简洁的同时增加复杂性。但是在这种情况下,我相信你的generator+Promise异步代码要比其他的选择健康得多。虽然有所有这些建议,你仍然要注意你的特殊情况,并为你和你的团队做出合适的决策。

Generator 委托

在上一节中,我们展示了从generator内部调用普通函数,和它如何作为一种有用的技术来将实现细节(比如异步Promise流程)抽象出去。但是为这样的任务使用普通函数的缺陷是,它必须按照普通函数的规则行动,也就是说它不能像generator那样用yield来暂停自己。

在你身上可能发生这样的事情:你可能会试着使用我们的run(..)帮助函数,从一个generator中调用另个一generator。比如:

  1. function *foo() {
  2. var r2 = yield request( "http://some.url.2" );
  3. var r3 = yield request( "http://some.url.3/?v=" + r2 );
  4. return r3;
  5. }
  6. function *bar() {
  7. var r1 = yield request( "http://some.url.1" );
  8. // 通过`run(..)`“委托”到`*foo()`
  9. var r3 = yield run( foo );
  10. console.log( r3 );
  11. }
  12. run( bar );

通过再一次使用我们的run(..)工具,我们在*bar()内部运行*foo()。我们利用了这样一个事实:我们早先定义的run(..)返回一个promise,这个promise在generator运行至完成时才解析(或发生错误),所以如果我们从一个run(..)调用中yield出一个promise给另一个run(..),它就会自动暂停*bar()直到*foo()完成。

但这里有一个更好的办法将*foo()调用整合进*bar(),它称为yield委托。yield委托的特殊语法是:yield * __(注意额外的*)。让它在我们前面的例子中工作之前,让我们看一个更简单的场景:

  1. function *foo() {
  2. console.log( "`*foo()` starting" );
  3. yield 3;
  4. yield 4;
  5. console.log( "`*foo()` finished" );
  6. }
  7. function *bar() {
  8. yield 1;
  9. yield 2;
  10. yield *foo(); // `yield`-delegation!
  11. yield 5;
  12. }
  13. var it = bar();
  14. it.next().value; // 1
  15. it.next().value; // 2
  16. it.next().value; // `*foo()` starting
  17. // 3
  18. it.next().value; // 4
  19. it.next().value; // `*foo()` finished
  20. // 5

注意: 在本章早前的一个注意点中,我解释了为什么我偏好function *foo() ..而不是function* foo() ..,相似地,我也偏好——与关于这个话题的其他大多数文档不同——说yield *foo()而不是yield* foo()*的摆放是纯粹的风格问题,而且要看你的最佳判断。但我发现保持统一风格很吸引人。

yield *foo()委托是如何工作的?

首先,正如我们看到过的那样,调用foo()创建了一个 迭代器。然后,yield *将(当前*bar()generator的) 迭代器 的控制委托/传递给这另一个*foo()迭代器

那么,前两个it.next()调用控制着*bar(),但当我们发起第三个it.next()调用时,*foo()就启动了,而且这时我们控制的是*foo()而非*bar()。这就是为什么它称为委托——*bar()将它的迭代控制委托给*foo()

只要it迭代器 的控制耗尽了整个*foo()迭代器,它就会自动地将控制返回到*bar()

那么现在回到前面的三个顺序Ajax请求的例子:

  1. function *foo() {
  2. var r2 = yield request( "http://some.url.2" );
  3. var r3 = yield request( "http://some.url.3/?v=" + r2 );
  4. return r3;
  5. }
  6. function *bar() {
  7. var r1 = yield request( "http://some.url.1" );
  8. // 通过`run(..)`“委托”到`*foo()`
  9. var r3 = yield *foo();
  10. console.log( r3 );
  11. }
  12. run( bar );

这个代码段和前面使用的版本的唯一区别是,使用了yield *foo()而不是前面的yield run(foo)

注意: yield *让出了迭代控制,不是generator控制;当你调用*foo()generator时,你就yield委托给它的 迭代器。但你实际上可以yield委托给任何 迭代器yield *[1,2,3]将会消费默认的[1,2,3]数组值 迭代器

为什么委托?

yield委托的目的很大程度上是为了代码组织,而且这种方式是与普通函数调用对称的。

想象两个分别提供了foo()bar()方法的模块,其中bar()调用foo()。它们俩分开的原因一般是由于为了程序将它们作为分离的程序来调用而进行的恰当组织。例如,可能会有一些情况foo()需要被独立调用,而其他地方bar()来调用foo()

由于这些完全相同的原因,将generator分开可以增强程序的可读性,可维护性,与可调试性。从这个角度讲,yield *是一种快捷的语法,用来在*bar()内部手动地迭代*foo()的步骤。

如果*foo()中的步骤是异步的,这样的手动方式可能会特别复杂,这就是为什么你可能会需要那个run(..)工具来做它。正如我们已经展示的,yield *foo()消灭了使用run(..)工具的子实例(比如run(foo))的需要。

委托消息

你可能想知道,这种yield委托在除了与 迭代器 控制一起工作以外,是如何与双向消息传递一起工作的。仔细查看下面这些通过yield委托进进出出的消息流:

  1. function *foo() {
  2. console.log( "inside `*foo()`:", yield "B" );
  3. console.log( "inside `*foo()`:", yield "C" );
  4. return "D";
  5. }
  6. function *bar() {
  7. console.log( "inside `*bar()`:", yield "A" );
  8. // `yield`-委托!
  9. console.log( "inside `*bar()`:", yield *foo() );
  10. console.log( "inside `*bar()`:", yield "E" );
  11. return "F";
  12. }
  13. var it = bar();
  14. console.log( "outside:", it.next().value );
  15. // outside: A
  16. console.log( "outside:", it.next( 1 ).value );
  17. // inside `*bar()`: 1
  18. // outside: B
  19. console.log( "outside:", it.next( 2 ).value );
  20. // inside `*foo()`: 2
  21. // outside: C
  22. console.log( "outside:", it.next( 3 ).value );
  23. // inside `*foo()`: 3
  24. // inside `*bar()`: D
  25. // outside: E
  26. console.log( "outside:", it.next( 4 ).value );
  27. // inside `*bar()`: 4
  28. // outside: F

特别注意一下it.next(3)调用之后的处理步骤:

  1. 3被传入(通过*bar里的yield委托)在*foo()内部等待中的yield "C"表达式。
  2. 然后*foo()调用return "D",但是这个值不会一路返回到外面的it.next(3)调用。
  3. 相反地,值"D"作为结果被发送到在*bar()内部等待中的yield *foo()表示式——这个yield委托表达式实质上在*foo()被耗尽之前一直被暂停着。所以"D"被送到*bar()内部来让它打印。
  4. yield "E"*bar()内部被调用,而且值"E"被让出到外部作为it.next(3)调用的结果。

从外部 迭代器it)的角度来看,在初始的generator和被委托的generator之间的控制没有任何区别。

事实上,yield委托甚至不必指向另一个generator;它可以仅被指向一个非generator的,一般的 iterable。比如:

  1. function *bar() {
  2. console.log( "inside `*bar()`:", yield "A" );
  3. // `yield`-委托至一个非generator
  4. console.log( "inside `*bar()`:", yield *[ "B", "C", "D" ] );
  5. console.log( "inside `*bar()`:", yield "E" );
  6. return "F";
  7. }
  8. var it = bar();
  9. console.log( "outside:", it.next().value );
  10. // outside: A
  11. console.log( "outside:", it.next( 1 ).value );
  12. // inside `*bar()`: 1
  13. // outside: B
  14. console.log( "outside:", it.next( 2 ).value );
  15. // outside: C
  16. console.log( "outside:", it.next( 3 ).value );
  17. // outside: D
  18. console.log( "outside:", it.next( 4 ).value );
  19. // inside `*bar()`: undefined
  20. // outside: E
  21. console.log( "outside:", it.next( 5 ).value );
  22. // inside `*bar()`: 5
  23. // outside: F

注意这个例子与前一个之间,被接收/报告的消息的不同之处。

最惊人的是,默认的array迭代器 不关心任何通过next(..)调用被发送的消息,所以值23,与4实质上被忽略了。另外,因为这个 迭代器 没有明确的return值(不像前面使用的*foo()),所以yield *表达式在它完成时得到一个undefined

异常也委托!

yield委托在两个方向上透明地传递消息的方式相同,错误/异常也在双向传递:

  1. function *foo() {
  2. try {
  3. yield "B";
  4. }
  5. catch (err) {
  6. console.log( "error caught inside `*foo()`:", err );
  7. }
  8. yield "C";
  9. throw "D";
  10. }
  11. function *bar() {
  12. yield "A";
  13. try {
  14. yield *foo();
  15. }
  16. catch (err) {
  17. console.log( "error caught inside `*bar()`:", err );
  18. }
  19. yield "E";
  20. yield *baz();
  21. // note: can't get here!
  22. yield "G";
  23. }
  24. function *baz() {
  25. throw "F";
  26. }
  27. var it = bar();
  28. console.log( "outside:", it.next().value );
  29. // outside: A
  30. console.log( "outside:", it.next( 1 ).value );
  31. // outside: B
  32. console.log( "outside:", it.throw( 2 ).value );
  33. // error caught inside `*foo()`: 2
  34. // outside: C
  35. console.log( "outside:", it.next( 3 ).value );
  36. // error caught inside `*bar()`: D
  37. // outside: E
  38. try {
  39. console.log( "outside:", it.next( 4 ).value );
  40. }
  41. catch (err) {
  42. console.log( "error caught outside:", err );
  43. }
  44. // error caught outside: F

在这段代码中有一些事情要注意:

  1. 但我们调用it.throw(2)时,它发送一个错误消息2*bar(),而*bar()将它委托至*foo(),然后*foo()catch它并平静地处理。之后,yield "C""C"作为返回的value发送回it.throw(2)调用。
  2. 接下来值"D"被从*foo()内部throw出来并传播到*bar()*bar()catch它并平静地处理。然后yield "E""E"作为返回的value发送回it.next(3)调用。
  3. 接下来,一个异常从*baz()throw出来,而没有被*bar()捕获——我们没在外面catch它——所以*baz()*bar()都被设置为完成状态。这段代码结束后,即便有后续的next(..)调用,你也不会得到值"G"——它们的value将返回undefined

异步委托

最后让我们回到早先的多个顺序Ajax请求的例子,使用yield委托:

  1. function *foo() {
  2. var r2 = yield request( "http://some.url.2" );
  3. var r3 = yield request( "http://some.url.3/?v=" + r2 );
  4. return r3;
  5. }
  6. function *bar() {
  7. var r1 = yield request( "http://some.url.1" );
  8. var r3 = yield *foo();
  9. console.log( r3 );
  10. }
  11. run( bar );

*bar()内部,与调用yield run(foo)不同的是,我们调用yield *foo()就可以了。

在前一个版本的这个例子中,Promise机制(通过run(..)控制的)被用于将值从*foo()中的return r3传送到*bar()内部的本地变量r3。现在,这个值通过yield *机制直接返回。

除此以外,它们的行为是一样的。

“递归”委托

当然,yield委托可以一直持续委托下去,你想连接多少步骤就连接多少。你甚至可以在具有异步能力的generator上“递归”使用yield委托——一个yield委托至自己的generator:

  1. function *foo(val) {
  2. if (val > 1) {
  3. // 递归委托
  4. val = yield *foo( val - 1 );
  5. }
  6. return yield request( "http://some.url/?v=" + val );
  7. }
  8. function *bar() {
  9. var r1 = yield *foo( 3 );
  10. console.log( r1 );
  11. }
  12. run( bar );

注意: 我们的run(..)工具本可以用run( foo, 3 )来调用,因为它支持用额外传递的参数来进行generator的初始化。然而,为了在这里高调展示yield *的灵活性,我们使用了无参数的*bar()

这段代码之后的处理步骤是什么?坚持住,它的细节要描述起来可是十分错综复杂:

  1. run(bar)启动了*bar()generator。
  2. foo(3)*foo(..)创建了 迭代器 并传递3作为它的val参数。
  3. 因为3 > 1foo(2)创建了另一个 迭代器 并传递2作为它的val参数。
  4. 因为2 > 1foo(1)又创建了另一个 迭代器 并传递1作为它的val参数。
  5. 1 > 1false,所以我们接下来用值1调用request(..),并得到一个代表第一个Ajax调用的promise。
  6. 这个promise被yield出来,回到*foo(2)generator实例。
  7. yield *将这个promise传出并回到*foo(3)生成generator。另一个yield *把这个promise传出到*bar()generator实例。而又有另一个yield *把这个promise传出到run(..)工具,而它将会等待这个promise(第一个Ajax请求)再处理。
  8. 当这个promise解析时,它的完成消息会被发送以继续*bar()*bar()通过yield *把消息传递进*foo(3)实例,*foo(3)实例通过yield *把消息传递进*foo(2)generator实例,*foo(2)实例通过yield *把消息传给那个在*foo(3)generator实例中等待的一般的yield
  9. 这第一个Ajax调用的应答现在立即从*foo(3)generator实例中被return,作为*foo(2)实例中yield *表达式的结果发送回来,并赋值给本地val变量。
  10. *foo(2)内部,第二个Ajax请求用request(..)发起,它的promise被yield回到*foo(1)实例,然后一路yield *传播到run(..)(回到第7步)。当promise解析时,第二个Ajax应答一路传播回到*foo(2)generator实例,并赋值到他本地的val变量。
  11. 最终,第三个Ajax请求用request(..)发起,它的promise走出到run(..),然后它的解析值一路返回,最后被return到在*bar()中等待的yield *表达式。

天!许多疯狂的头脑杂技,对吧?你可能想要把它通读几遍,然后抓点儿零食放松一下大脑!

Generator并发

正如我们在第一章和本章早先讨论过的,另个同时运行的“进程”可以协作地穿插它们的操作,而且许多时候这可以产生非常强大的异步表达式。

坦白地说,我们前面关于多个generator并发穿插的例子,展示了这真的容易让人糊涂。但我们也受到了启发,有些地方这种能力十分有用。

回想我们在第一章中看过的场景,两个不同但同时的Ajax应答处理需要互相协调,来确保数据通信不是竟合状态。我们这样把应答分别放在res数组的不同位置中:

  1. function response(data) {
  2. if (data.url == "http://some.url.1") {
  3. res[0] = data;
  4. }
  5. else if (data.url == "http://some.url.2") {
  6. res[1] = data;
  7. }
  8. }

但是我们如何在这种场景下使用多generator呢?

  1. // `request(..)` 是一个基于Promise的Ajax工具
  2. var res = [];
  3. function *reqData(url) {
  4. res.push(
  5. yield request( url )
  6. );
  7. }

注意: 我们将在这里使用两个*reqData(..)generator的实例,但是这和分别使用两个不同generator的一个实例没有区别;这两种方式在道理上完全一样的。我们过一会儿就会看到两个generator的协调操作。

与不得不将res[0]res[1]赋值手动排序不同,我们将使用协调过的顺序,让res.push(..)以可预见的顺序恰当地将值放在预期的位置。如此被表达的逻辑会让人感觉更干净。

但是我们将如何实际安排这种互动呢?首先,让我们手动实现它:

  1. var it1 = reqData( "http://some.url.1" );
  2. var it2 = reqData( "http://some.url.2" );
  3. var p1 = it1.next().value;
  4. var p2 = it2.next().value;
  5. p1
  6. .then( function(data){
  7. it1.next( data );
  8. return p2;
  9. } )
  10. .then( function(data){
  11. it2.next( data );
  12. } );

*reqData(..)的两个实例都开始发起它们的Ajax请求,然后用yield暂停。之后我们再p1解析时继续运行第一个实例,而后来的p2的解析将会重启第二个实例。以这种方式,我们使用Promise的安排来确保res[0]将持有第一个应答,而res[1]持有第二个应答。

但坦白地说,这是可怕的手动,而且它没有真正让generator组织它们自己,而那才是真正的力量。让我们用不同的方法试一下:

  1. // `request(..)` 是一个基于Promise的Ajax工具
  2. var res = [];
  3. function *reqData(url) {
  4. var data = yield request( url );
  5. // 传递控制权
  6. yield;
  7. res.push( data );
  8. }
  9. var it1 = reqData( "http://some.url.1" );
  10. var it2 = reqData( "http://some.url.2" );
  11. var p1 = it1.next().value;
  12. var p2 = it2.next().value;
  13. p1.then( function(data){
  14. it1.next( data );
  15. } );
  16. p2.then( function(data){
  17. it2.next( data );
  18. } );
  19. Promise.all( [p1,p2] )
  20. .then( function(){
  21. it1.next();
  22. it2.next();
  23. } );

好的,这看起来好些了(虽然仍然是手动),因为现在两个*reqData(..)的实例真正地并发运行了,而且(至少是在第一部分)是独立的。

在前一个代码段中,第二个实例在第一个实例完全完成之前没有给出它的数据。但是这里,只要它们的应答一返回这两个实例就立即分别收到他们的数据,然后每个实例调用另一个yield来传送控制。最后我们在Promise.all([ .. ])的处理器中选择用什么样的顺序继续它们。

可能不太明显的是,这种方式因其对称性启发了一种可复用工具的简单形式。让我们想象使用一个称为runAll(..)的工具:

  1. // `request(..)` 是一个基于Promise的Ajax工具
  2. var res = [];
  3. runAll(
  4. function*(){
  5. var p1 = request( "http://some.url.1" );
  6. // 传递控制权
  7. yield;
  8. res.push( yield p1 );
  9. },
  10. function*(){
  11. var p2 = request( "http://some.url.2" );
  12. // 传递控制权
  13. yield;
  14. res.push( yield p2 );
  15. }
  16. );

注意: 我们没有包含runAll(..)的实现代码,不仅因为它长得无法行文,也因为它是一个我们已经在先前的 run(..)中实现的逻辑的扩展。所以,作为留给读者的一个很好的补充性练习,请你自己动手改进run(..)的代码,来使它像想象中的runAll(..)那样工作。另外,我的 asynquence 库提供了一个前面提到过的runner(..)工具,它内建了这种能力,我们将在本书的附录A中讨论它。

这是runAll(..)内部的处理将如何操作:

  1. 第一个generator得到一个代表从"http://some.url.1"来的Ajax应答,然后将控制权yield回到runAll(..)工具。
  2. 第二个generator运行,并对"http://some.url.2"做相同的事,将控制权yield回到runAll(..)工具。
  3. 第一个generator继续,然后yield出他的promisep1。在这种情况下runAll(..)工具和我们前面的run(..)做同样的事,它等待promise解析,然后继续这同一个generator(没有控制传递!)。当p1解析时,runAll(..)使用解析值再一次继续第一个generator,而后res[0]得到它的值。在第一个generator完成之后,有一个隐式的控制权传递。
  4. 第二个generator继续,yield出它的promisep2,并等待它的解析。一旦p2解析,runAll(..)使用这个解析值继续第二个generator,于是res[1]被设置。

在这个例子中,我们使用了一个称为res的外部变量来保存两个不同的Ajax应答的结果——这是我们的并发协调。

但是这样做可能十分有帮助:进一步扩展runAll(..)使它为多个generator实例提供 分享的 内部的变量作用域,比如一个我们将在下面称为data的空对象。另外,它可以接收被yield的非Promise值,并把它们交给下一个generator。

考虑这段代码:

  1. // `request(..)` 是一个基于Promise的Ajax工具
  2. runAll(
  3. function*(data){
  4. data.res = [];
  5. // 传递控制权(并传递消息)
  6. var url1 = yield "http://some.url.2";
  7. var p1 = request( url1 ); // "http://some.url.1"
  8. // 传递控制权
  9. yield;
  10. data.res.push( yield p1 );
  11. },
  12. function*(data){
  13. // 传递控制权(并传递消息)
  14. var url2 = yield "http://some.url.1";
  15. var p2 = request( url2 ); // "http://some.url.2"
  16. // 传递控制权
  17. yield;
  18. data.res.push( yield p2 );
  19. }
  20. );

在这个公式中,两个generator不仅协调控制传递,实际上还互相通信:通过data.res,和交换url1url2的值的yield消息。这强大到不可思议!

这样的认识也是一种更为精巧的称为CSP(Communicating Sequential Processes——通信顺序处理)的异步技术的概念基础,我们将在本书的附录B中讨论它。

Thunks

至此,我们都假定从一个generator中yield一个Promise——让这个Promise使用像run(..)这样的帮助工具来推进generator——是管理使用generator的异步处理的最佳方法。明白地说,它是的。

但是我们跳过了一个被轻度广泛使用的模式,为了完整性我们将简单地看一看它。

在一般的计算机科学中,有一种老旧的前JS时代的概念,称为“thunk”。我们不在这里赘述它的历史,一个狭隘的表达是,thunk是一个JS函数——没有任何参数——它连接并调用另一个函数。

换句话讲,你用一个函数定义包装函数调用——带着它需要的所有参数——来 推迟 这个调用的执行,而这个包装用的函数就是thunk。当你稍后执行thunk时,你最终会调用那个原始的函数。

举个例子:

  1. function foo(x,y) {
  2. return x + y;
  3. }
  4. function fooThunk() {
  5. return foo( 3, 4 );
  6. }
  7. // 稍后
  8. console.log( fooThunk() ); // 7

所以,一个同步的thunk是十分直白的。但是一个异步的thunk呢?我们实质上可以扩展这个狭隘的thunk定义,让它接收一个回调。

考虑这段代码:

  1. function foo(x,y,cb) {
  2. setTimeout( function(){
  3. cb( x + y );
  4. }, 1000 );
  5. }
  6. function fooThunk(cb) {
  7. foo( 3, 4, cb );
  8. }
  9. // 稍后
  10. fooThunk( function(sum){
  11. console.log( sum ); // 7
  12. } );

如你所见,fooThunk(..)仅需要一个cb(..)参数,因为它已经预先制定了值34(分别为xy)并准备传递给foo(..)。一个thunk只是在外面耐心地等待着它开始工作所需的最后一部分信息:回调。

但是你不会想要手动制造thunk。那么,让我们发明一个工具来为我们进行这种包装。

考虑这段代码:

  1. function thunkify(fn) {
  2. var args = [].slice.call( arguments, 1 );
  3. return function(cb) {
  4. args.push( cb );
  5. return fn.apply( null, args );
  6. };
  7. }
  8. var fooThunk = thunkify( foo, 3, 4 );
  9. // 稍后
  10. fooThunk( function(sum) {
  11. console.log( sum ); // 7
  12. } );

提示: 这里我们假定原始的(foo(..))函数签名希望它的回调的位置在最后,而其它的参数在这之前。这是一个异步JS函数的相当普遍的“标准”。你可以称它为“回调后置风格”。如果因为某些原因你需要处理“回调优先风格”的签名,你只需要制造一个使用args.unshift(..)而非args.push(..)的工具。

前面的thunkify(..)公式接收foo(..)函数的引用,和任何它所需的参数,并返回thunk本身(fooThunk(..))。然而,这并不是你将在JS中发现的thunk的典型表达方式。

thunkify(..)制造thunk本身相反,典型的——可能有点儿让人困惑的——thunkify(..)工具将产生一个制造thunk的函数。

额…是的。

考虑这段代码:

  1. function thunkify(fn) {
  2. return function() {
  3. var args = [].slice.call( arguments );
  4. return function(cb) {
  5. args.push( cb );
  6. return fn.apply( null, args );
  7. };
  8. };
  9. }

这里主要的不同之处是有一个额外的return function() { .. }。这是它在用法上的不同:

  1. var whatIsThis = thunkify( foo );
  2. var fooThunk = whatIsThis( 3, 4 );
  3. // 稍后
  4. fooThunk( function(sum) {
  5. console.log( sum ); // 7
  6. } );

明显地,这段代码隐含的最大的问题是,whatIsThis叫什么合适?它不是thunk,它是一个从foo(..)调用生产thunk的东西。它是一种“thunk”的“工厂”。而且看起来没有任何标准的意见来命名这种东西。

所以,我的提议是“thunkory”(”thunk” + “factory”)。于是,thunkify(..)制造了一个thunkory,而一个thunkory制造thunks。这个道理与第三章中我的“promisory”提议是对称的:

  1. var fooThunkory = thunkify( foo );
  2. var fooThunk1 = fooThunkory( 3, 4 );
  3. var fooThunk2 = fooThunkory( 5, 6 );
  4. // 稍后
  5. fooThunk1( function(sum) {
  6. console.log( sum ); // 7
  7. } );
  8. fooThunk2( function(sum) {
  9. console.log( sum ); // 11
  10. } );

注意: 这个例子中的foo(..)期望的回调不是“错误优先风格”。当然,“错误优先风格”更常见。如果foo(..)有某种合理的错误发生机制,我们可以改变而使它期望并使用一个错误优先的回调。后续的thunkify(..)不会关心回调被预想成什么样。用法的唯一区别是fooThunk1(function(err,sum){..

暴露出thunkory方法——而不是像早先的thunkify(..)那样将中间步骤隐藏起来——可能看起来像是没必要的混乱。但是一般来讲,在你的程序一开始就制造一些thunkory来包装既存API的方法是十分有用的,然后你就可以在你需要thunk的时候传递并调用这些thunkory。这两个区别开的步骤保证了功能上更干净的分离。

来展示一下的话:

  1. // 更干净:
  2. var fooThunkory = thunkify( foo );
  3. var fooThunk1 = fooThunkory( 3, 4 );
  4. var fooThunk2 = fooThunkory( 5, 6 );
  5. // 而这个不干净:
  6. var fooThunk1 = thunkify( foo, 3, 4 );
  7. var fooThunk2 = thunkify( foo, 5, 6 );

不管你是否愿意明确对付thunkory,thunk(fooThunk1(..)fooThunk2(..))的用法还是一样的。

s/promise/thunk/

那么所有这些thunk的东西与generator有什么关系?

一般性地比较一下thunk和promise:它们是不能直接互换的,因为它们在行为上不是等价的。比起单纯的thunk,Promise可用性更广泛,而且更可靠。

但从另一种意义上讲,它们都可以被看作是对一个值的请求,这个请求可能被异步地应答。

回忆第三章,我们定义了一个工具来promise化一个函数,我们称之为Promise.wrap(..)——我们本来也可以叫它promisify(..)的!这个Promise化包装工具不会生产Promise;它生产那些继而生产Promise的promisories。这和我们当前讨论的thunkory和thunk是完全对称的。

为了描绘这种对称性,让我们首先将foo(..)的例子改为假定一个“错误优先风格”回调的形式:

  1. function foo(x,y,cb) {
  2. setTimeout( function(){
  3. // 假定 `cb(..)` 是“错误优先风格”
  4. cb( null, x + y );
  5. }, 1000 );
  6. }

现在,我们将比较thunkify(..)promisify(..)(也就是第三章的Promise.wrap(..)):

  1. // 对称的:构建问题的回答者
  2. var fooThunkory = thunkify( foo );
  3. var fooPromisory = promisify( foo );
  4. // 对称的:提出问题
  5. var fooThunk = fooThunkory( 3, 4 );
  6. var fooPromise = fooPromisory( 3, 4 );
  7. // 取得 thunk 的回答
  8. fooThunk( function(err,sum){
  9. if (err) {
  10. console.error( err );
  11. }
  12. else {
  13. console.log( sum ); // 7
  14. }
  15. } );
  16. // 取得 promise 的回答
  17. fooPromise
  18. .then(
  19. function(sum){
  20. console.log( sum ); // 7
  21. },
  22. function(err){
  23. console.error( err );
  24. }
  25. );

thunkory和promisory实质上都是在问一个问题(一个值),thunk的fooThunk和promise的fooPromise分别代表这个问题的未来的答案。这样看来,对称性就清楚了。

带着这个视角,我们可以看到为了异步而yieldPromise的generator,也可以为异步而yieldthunk。我们需要的只是一个更聪明的run(..)工具(就像以前一样),它不仅可以寻找并连接一个被yield的Promise,而且可以给一个被yield的thunk提供回调。

考虑这段代码:

  1. function *foo() {
  2. var val = yield request( "http://some.url.1" );
  3. console.log( val );
  4. }
  5. run( foo );

在这个例子中,request(..)既可以是一个返回一个promise的promisory,也可以是一个返回一个thunk的thunkory。从generator的内部代码逻辑的角度看,我们不关心这个实现细节,这就它强大的地方!

所以,request(..)可以使以下任何一种形式:

  1. // promisory `request(..)` (见第三章)
  2. var request = Promise.wrap( ajax );
  3. // vs.
  4. // thunkory `request(..)`
  5. var request = thunkify( ajax );

最后,作为一个让我们早先的run(..)工具支持thunk的补丁,我们可能会需要这样的逻辑:

  1. // ..
  2. // 我们收到了一个回调吗?
  3. else if (typeof next.value == "function") {
  4. return new Promise( function(resolve,reject){
  5. // 使用一个错误优先回调调用thunk
  6. next.value( function(err,msg) {
  7. if (err) {
  8. reject( err );
  9. }
  10. else {
  11. resolve( msg );
  12. }
  13. } );
  14. } )
  15. .then(
  16. handleNext,
  17. function handleErr(err) {
  18. return Promise.resolve(
  19. it.throw( err )
  20. )
  21. .then( handleResult );
  22. }
  23. );
  24. }

现在,我们generator既可以调用promisory来yieldPromise,也可以调用thunkory来yieldthunk,而不论那种情况,run(..)都将处理这个值并等待它的完成,以继续generator。

在对称性上,这两个方式是看起来相同的。然而,我们应当指出这仅仅从Promise或thunk表示延续generator的未来值的角度讲是成立的。

从更高的角度讲,与Promise被设计成的那样不同,thunk没有提供,它们本身也几乎没有任何可靠性和可组合性的保证。在这种特定的generator异步模式下使用一个thunk作为Promise的替代品是可以工作的,但与Promise提供的所有好处相比,这应当被看做是一种次理想的方法。

如果你有选择,那就偏向yield pr而非yield th。但是使run(..)工具可以处理两种类型的值本身没有什么问题。

注意: 在我们将要在附录A中讨论的,我的 asynquence 库中的runner(..)工具,可以处理yield的Promise,thunk和 asynquence 序列。

前ES6时代的Generator

我希望你已经被说服了,generator是一个异步编程工具箱里的非常重要的增强工具。但它是ES6中的新语法,这意味着你不能像填补Promise(它只是新的API)那样填补generator。那么如果我们不能奢望忽略前ES6时代的浏览器,我们该如何将generator带到浏览器中呢?

对所有ES6中的新语法的扩展,有一些工具——称呼他们最常见的名词是转译器(transpilers),也就是转换编译器(trans-compilers)——它们会拿起你的ES6语法,并转换为前ES6时代的等价代码(但是明显地变难看了!)。所以,generator可以被转译为具有相同行为但可以在ES5或以下版本进行工作的代码。

但是怎么做到的?yield的“魔法”听起来不像是那么容易转译的。在我们早先的基于闭包的 迭代器 例子中,实际上提示了一种解决方法。

手动变形

在我们讨论转译器之前,让我们延伸一下,在generator的情况下如何手动转译。这不仅是一个学院派的练习,因为这样做实际上可以帮助我们进一步理解它们如何工作。

考虑这段代码:

  1. // `request(..)` 是一个支持Promise的Ajax工具
  2. function *foo(url) {
  3. try {
  4. console.log( "requesting:", url );
  5. var val = yield request( url );
  6. console.log( val );
  7. }
  8. catch (err) {
  9. console.log( "Oops:", err );
  10. return false;
  11. }
  12. }
  13. var it = foo( "http://some.url.1" );

第一个要注意的事情是,我们仍然需要一个可以被调用的普通的foo()函数,而且它仍然需要返回一个 迭代器。那么让我们来画出非generator的变形草图:

  1. function foo(url) {
  2. // ..
  3. // 制造并返回 iterator
  4. return {
  5. next: function(v) {
  6. // ..
  7. },
  8. throw: function(e) {
  9. // ..
  10. }
  11. };
  12. }
  13. var it = foo( "http://some.url.1" );

下一个需要注意的地方是,generator通过挂起它的作用域/状态来施展它的“魔法”,但我们可以用函数闭包来模拟。为了理解如何写出这样的代码,我们将先用状态值注释generator不同的部分:

  1. // `request(..)` 是一个支持Promise的Ajax工具
  2. function *foo(url) {
  3. // 状态 *1*
  4. try {
  5. console.log( "requesting:", url );
  6. var TMP1 = request( url );
  7. // 状态 *2*
  8. var val = yield TMP1;
  9. console.log( val );
  10. }
  11. catch (err) {
  12. // 状态 *3*
  13. console.log( "Oops:", err );
  14. return false;
  15. }
  16. }

注意: 为了更准去地讲解,我们使用TMP1变量将val = yield request..语句分割为两部分。request(..)发生在状态*1*,而将完成值赋给val发生在状态*2*。在我们将代码转换为非generator的等价物后,我们就可以摆脱中间的TMP1

换句话所,*1*是初始状态,*2*request(..)成功的状态,*3*request(..)失败的状态。你可能会想象额外的yield步骤将如何编码为额外的状态。

回到我们被转译的generator,让我们在这个闭包中定义一个变量state,用它来追踪状态:

  1. function foo(url) {
  2. // 管理 generator 状态
  3. var state;
  4. // ..
  5. }

现在,让我们在闭包内部定义一个称为process(..)的内部函数,它用switch语句来处理各种状态。

  1. // `request(..)` 是一个支持Promise的Ajax工具
  2. function foo(url) {
  3. // 管理 generator 状态
  4. var state;
  5. // generator-范围的变量声明
  6. var val;
  7. function process(v) {
  8. switch (state) {
  9. case 1:
  10. console.log( "requesting:", url );
  11. return request( url );
  12. case 2:
  13. val = v;
  14. console.log( val );
  15. return;
  16. case 3:
  17. var err = v;
  18. console.log( "Oops:", err );
  19. return false;
  20. }
  21. }
  22. // ..
  23. }

在我们的generator中每种状态都在switch语句中有它自己的case。每当我们需要处理一个新状态时,process(..)就会被调用。我们一会就回来讨论它如何工作。

对任何generator范围的变量声明(val),我们将它们移动到process(..)外面的var声明中,这样它们就可以在process(..)的多次调用中存活下来。但是“块儿作用域”的err变量仅在*3*状态下需要,所以我们将它留在原处。

在状态*1*,与yield request(..)相反,我们return request(..)。在终结状态*2*,没有明确的return,所以我们仅仅return;也就是return undefined。在终结状态*3*,有一个return false,我们保留它。

现在我们需要定义 迭代器 函数的代码,以便人们恰当地调用process(..)

  1. function foo(url) {
  2. // 管理 generator 状态
  3. var state;
  4. // generator-范围的变量声明
  5. var val;
  6. function process(v) {
  7. switch (state) {
  8. case 1:
  9. console.log( "requesting:", url );
  10. return request( url );
  11. case 2:
  12. val = v;
  13. console.log( val );
  14. return;
  15. case 3:
  16. var err = v;
  17. console.log( "Oops:", err );
  18. return false;
  19. }
  20. }
  21. // 制造并返回 iterator
  22. return {
  23. next: function(v) {
  24. // 初始状态
  25. if (!state) {
  26. state = 1;
  27. return {
  28. done: false,
  29. value: process()
  30. };
  31. }
  32. // 成功地让出继续值
  33. else if (state == 1) {
  34. state = 2;
  35. return {
  36. done: true,
  37. value: process( v )
  38. };
  39. }
  40. // generator 已经完成了
  41. else {
  42. return {
  43. done: true,
  44. value: undefined
  45. };
  46. }
  47. },
  48. "throw": function(e) {
  49. // 在状态 *1* 中,有唯一明确的错误处理
  50. if (state == 1) {
  51. state = 3;
  52. return {
  53. done: true,
  54. value: process( e )
  55. };
  56. }
  57. // 否则,是一个不会被处理的错误,所以我们仅仅把它扔回去
  58. else {
  59. throw e;
  60. }
  61. }
  62. };
  63. }

这段代码如何工作?

  1. 第一个对 迭代器next()调用将把gtenerator从未初始化的状态移动到状态1,然后调用process()来处理这个状态。request(..)的返回值是一个代表Ajax应答的promise,它作为value属性从next()调用被返回。
  2. 如果Ajax请求成功,第二个next(..)调用应当送进Ajax的应答值,它将我们的状态移动到2process(..)再次被调用(这次它被传入Ajax应答的值),而从next(..)返回的value属性将是undefined
  3. 然而,如果Ajax请求失败,应当用错误调用throw(..),它将状态从1移动到3(而不是2)。process(..)再一次被调用,这词被传入了错误的值。这个case返回false,所以false作为throw(..)调用返回的value属性。

从外面看——也就是仅仅与 迭代器 互动——这个普通的foo(..)函数与*foo(..)generator的工作方式是一样的。所以我们有效地将ES6 generator“转译”为前ES6可兼容的!

然后我们就可以手动初始化我们的generator并控制它的迭代器——调用var it = foo("..")it.next(..)等等——或更好地,我们可以将它传递给我们先前定义的run(..)工具,比如run(foo,"..")

自动转译

前面的练习——手动编写从ES6 generator到前ES6的等价物的变形过程——教会了我们generator在概念上是如何工作的。但是这种变形真的是错综复杂,而且不能很好地移植到我们代码中的其他generator上。手动做这些工作是不切实际的,而且将会把generator的好处完全抵消掉。

但走运的是,已经存在几种工具可以自动地将ES6 generator转换为我们在前一节延伸出的东西。它们不仅帮我们做力气活儿,还可以处理几种我们敷衍而过的情况。

一个这样的工具是regenerator(https://facebook.github.io/regenerator/),由Facebook的聪明伙计们开发的。

如果我们用regenerator来转译我们前面的generator,这就是产生的代码(在编写本文时):

  1. // `request(..)` 是一个支持Promise的Ajax工具
  2. var foo = regeneratorRuntime.mark(function foo(url) {
  3. var val;
  4. return regeneratorRuntime.wrap(function foo$(context$1$0) {
  5. while (1) switch (context$1$0.prev = context$1$0.next) {
  6. case 0:
  7. context$1$0.prev = 0;
  8. console.log( "requesting:", url );
  9. context$1$0.next = 4;
  10. return request( url );
  11. case 4:
  12. val = context$1$0.sent;
  13. console.log( val );
  14. context$1$0.next = 12;
  15. break;
  16. case 8:
  17. context$1$0.prev = 8;
  18. context$1$0.t0 = context$1$0.catch(0);
  19. console.log("Oops:", context$1$0.t0);
  20. return context$1$0.abrupt("return", false);
  21. case 12:
  22. case "end":
  23. return context$1$0.stop();
  24. }
  25. }, foo, this, [[0, 8]]);
  26. });

这和我们的手动推导有明显的相似性,比如switch/case语句,而且我们甚至可以看到,val被拉到了闭包外面,正如我们做的那样。

当然,一个代价是这个generator的转译需要一个帮助工具库regeneratorRuntime,它持有全部管理一个普通generator/迭代器 所需的可复用逻辑。它的许多模板代码看起来和我们的版本不同,但即便如此,概念还是可以看到的,比如使用context$1$0.next = 4追踪generator的下一个状态。

主要的结论是,generator不仅限于ES6+的环境中才有用。一旦你理解了它的概念,你可以在你的所有代码中利用他们,并使用工具将代码变形为旧环境兼容的。

这比使用PromiseAPI的填补来实现前ES6的Promise要做更多的工作,但是努力完全是值得的,因为对于以一种可推理的,合理的,看似同步的顺序风格来表达异步流程控制来说,generator实在是好太多了。

一旦你适应了generator,你将永远不会回到面条般的回调地狱了!

复习

generator是一种ES6的新函数类型,它不像普通函数那样运行至完成。相反,generator可以暂停在一种中间完成状态(完整地保留它的状态),而且它可以从暂停的地方重新开始。

这种暂停/继续的互换是一种协作而非抢占,这意味着generator拥有的唯一能力是使用yield关键字暂停它自己,而且控制这个generator的 迭代器 拥有的唯一能力是继续这个generator(通过next(..))。

yield/next(..)的对偶不仅是一种控制机制,它实际上是一种双向消息传递机制。一个yield ..表达式实质上为了等待一个值而暂停,而下一个next(..)调用将把值(或隐含的undefined)传递回这个暂停的yield表达式。

与异步流程控制关联的generator的主要好处是,在一个generator内部的代码以一种自然的同步/顺序风格表达一个任务的各个步骤的序列。这其中的技巧是我们实质上将潜在的异步处理隐藏在yield关键字的后面——将异步处理移动到控制generator的 迭代器 代码中。

换句话说,generator为异步代码保留了顺序的,同步的,阻塞的代码模式,这允许我们的大脑更自然地推理代码,解决了基于回调的异步产生的两个关键问题中的一个。



你不懂JS(系列丛书)-网页版目录
上一篇 ●【你不懂JS: 异步与性能】第三章: Promise
下一篇 ● 【你不懂JS: 异步与性能】第五章: 程序性能

写于 2018年05月25日2788

如非特别注明,文章皆为原创。

转载请注明出处: https://www.liayal.com/article/5b07ec054b90272bebb17a67

记小栈小程序上线啦~搜索【记小栈】【点击扫码】体验

你不想说点啥么?
😀😃😄😁😆😅😂🤣☺️😊😇🙂🙃😉😌😍😘😗😙😚😋😜😝😛🤑🤗🤓😎🤡🤠😏😒😞😔😟😕🙁☹️😣😖😫😩😤😠😡😶😐😑😯😦😧😮😲😵😳😱😨😰😢😥🤤😭😓😪😴🙄🤔🤥😬🤐🤢🤧😷🤒🤕😈👿👹👺💩👻💀☠️👽👾🤖🎃😺😸😹😻😼😽🙀😿😾👐👐🏻👐🏼👐🏽👐🏾👐🏿🙌🙌🏻🙌🏼🙌🏽🙌🏾🙌🏿👏👏🏻👏🏼👏🏽👏🏾👏🏿🙏🙏🏻🙏🏼🙏🏽🙏🏾🙏🏿🤝👍👍🏻👍🏼👍🏽👍🏾👍🏿👎👎🏻👎🏼👎🏽👎🏾👎🏿👊👊🏻👊🏼👊🏽👊🏾👊🏿✊🏻✊🏼✊🏽✊🏾✊🏿

评论

~ 评论还没有,沙发可以有 O(∩_∩)O~