你不知道JS:异步(翻译)系列4-1

你不知道JS:异步

第四章:生成器(Generators)

在第二章,我们明确了采用回调表示异步流的两个关键缺点:

  • 基于回调的异步和我们大脑按步规划任务的方式不相吻合。
  • 由于控制权反转,回调不可信且无法组合。

在第三章中,我们详细描述了Promise如何反逆转回调的控制权反转问题的,是我们重新获得了可信任/可组合的能力。

现在,我们把目光转向一种序列化的、看起来像同步的异步流表示形式。实现这一功能的“魔法”是ES6 生成器

打破运行直至结束(Breaking Run-to-Completion)

在第一章,我们解释了一个JS开发者在代码中普遍依赖的期望:一旦函数开始执行,它会一直运行到结束,在此之间没有其它代码(译者注:不包括其中的错误代码哈)能够中断和执行。

尽管看起来有点奇怪,ES6引入了一个新的函数类型,它的行为不同于Run-to-Completion。这种新类型的函数称为“生成器”。

为理解其含义,考虑下这个例子:

var x = 1;

function foo() {
    x++;
    bar();              // <-- what about this line?
    console.log( "x:", x );
}

function bar() {
    x++;
}

foo();                  // x: 3

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

现在,动动你的脑筋。要是bar()语句不存在,但是由于某种原因,仍然在x++console.log(x)语句之间运行?怎么可能?

抢占式多线程语言中,bar()中断这两个语句(译者注:指x++console.log(x)语句)并且就在两者之间执行是可能的。但JS不是抢占式的,也不是(目前)多线程的。另外,如果foo()本身在代码的某个部分可以“暂停”,则这种“中断”(并发)的协作(cooperative)形式是可能的。

注意: 我使用了“协作”(cooperative)一词,不仅仅是因为和传统的并发术语的联系(见第一章),还因为ES6语法中采用yield指明代码中的暂停点--表示一个礼貌性地协作式控制让步。

以下是实现此类协作并发的ES6代码:

var x = 1;

function *foo() {
    x++;
    yield; // pause!
    console.log( "x:", x );
}

function bar() {
    x++;
}

注意:你可能看到绝大多数其它JS文档/代码会以function* foo() { .. }的形式声明一个生成器,而不是我此处使用的function *foo() { .. }--唯一的不同是*的位置。这两种格式从功能/语法上来说是一致的,和第三种形式,function*foo() { .. }(没有空格)也是一样的。这两种形式都有争议,但是我偏向于function *foo..,因为这与我以*foo()形式引用生成器的方式相吻合。如果我只说foo(),你不知道我是在说生成器还是一个普通的函数。完全是风格上的偏爱而已。

现在,我们该如何运行前面的代码,使得bar()运行到*foo()中的yield点?

// construct an iterator `it` to control the generator
var it = foo();

// start `foo()` here!
it.next();
x;                      // 2
bar();
x;                      // 3
it.next();              // x: 3

好,这两段代码中有些新的并且有些令人困惑的东西,因此我们需要了解的更多才行。但在我们解释ES6生成器的不同机制/语法之前,让我们简单过下代码的行为流程:

  1. it = foo()操作还没有执行*foo()生成器(译者注:指内部的代码),而是仅仅构建了控制执行的迭代器(iterator)。关于迭代器的更多细节会在后面提及。
  2. 第一个it.next()启动了*foo()生成器,执行了*foo()中第一行的x++
  3. *foo()yield语句处暂停,即第一个it.next()调用结束的点。此时*foo()仍然在运行并活动,只是处在暂停状态。
  4. 我们检查了x的值,现在是2
  5. 我们调用bar(),通过x++又增加了x的值。
  6. 我们再次检查x的值,现在是3
  7. 最后的it.next()调用从暂停的地方恢复了*foo()生成器,运行console.log(..)语句,使用当前x的值3

很明显,*foo()启动时,并没有运行直至结束(run-to-completion)--它在yield处暂停。我们随后恢复了*foo(),让它结束,但那并不是必须的。

因此,生成器是一种特殊类型的函数,可以启动和停止一次或多次,甚至没必要结束。尽管它为什么如此强大还不是很明显,随着我们深入本章的其它部分,我们会发现,它会是用来构建生成器异步流控制(generators-as-async-flow-control)模式的重要构建块之一。

输入和输出(Input and Output)

生成器函数是一种特殊的函数,我们刚刚简单提及了它的新的处理模式。但是它仍然是函数,这意味着它仍然有一些不变的基本原则--即,仍然接收参数(即“输入”),并且仍会返回一个值(即“输出”):

function *foo(x,y) {
    return x * y;
}

var it = foo( 6, 7 );

var res = it.next();

res.value;      // 42

我们向*foo()中传入了实参67,分别对应于参数xy*foo()向调用代码返回值42

现在,我们看一下,相比于普通函数,生成器的激活方式有何不同。foo(6,7)看起来很熟悉。但有些微妙区别,不像普通函数,*foo(..)还没真的运行。

我们只是创建了一个迭代器(iterator)对象,将它赋给变量it,用来控制*foo(..)生成器。之后我们调用it.next(),命令*foo(..)生成器从当前位置前进到下一个yield或者生成器末尾后停止。

next(..)调用结果是个包含value属性的对象,存放着*foo(..)返回的任何值(如果有的话)。换句话说,yield会在程序执行中间将值发送至生成器之外,有点像中间的return

再说一次,为什么我们需要这种完全不直接的迭代器(iterator)对象来控制生成器呢,原因还不是很明显。我们会搞清楚的,我保证。

迭代信息传递(Iteration Messaging)

除了接收实参和返回值,生成器内建有更强大的输入/输出信息传递能力,通过yieldnext(..)

考虑如下代码:

function *foo(x) {
    var y = x * (yield);
    return y;
}

var it = foo( 6 );

// start `foo(..)`
it.next();

var res = it.next( 7 );

res.value;      // 42

首先,我们向x传入6。之后我们调用it.next(),启动*foo(..)

*foo(..)内部,var y = x ..语句开始执行,但是之后碰到了yield表达式。那时,var y = x ..语句暂停了*foo(..)(在赋值语句中间!),请求调用代码为yield表达式提供一个结果值。之后,我们调用it.next( 7 ),将7传回作为暂停的yield的结果。

那么,此时,赋值语句的本质上就是var y = 6 * 7。现在,return y返回值42作为it.next( 7 )调用的结果。

即使对有经验的JS开发者来说,有些非常重要但很容易混淆的东西需要注意:从你角度看,yieldnext(..)调用二者不相匹配。通常,next(..)调用比yield语句多一个--前面的代码中有一个yield和两个next(..)调用。

为什么会不匹配?

因为第一个next(..)总是用来启动生成器,运行到第一个yield。但是执行第一个暂停的yield表达式的是第二个next(..)调用,执行第二个暂停的yield表达式的是第三个next(..)调用,以此类推。

两个问题的故事(Tale of Two Questions)

事实上,主要关注的代码会影响你对是否有感观上不匹配的判断。

只考虑生成器代码:

var y = x * (yield);
return y;

这里第一个yield简单地问一个问题:“我应该在这里插入哪个值?”

谁来回答这个问题呢?好吧,第一个next()已经让生成器运行到这里了,因此,很明显无法回答这个问题。因此第二个next()调用必须回答由第一个yield提出的问题。

看到不匹配了吗--第二个对第一个?

让我们变换一下角度。不要从生成器角度看,而从迭代器的角度看。

为了恰当地说明这个角度,我们也需要解释下信息可以向两个方向传递--作为表达式,yield..可以响应next(..)调用,向外发出信息,next(..)也可以将值发送给暂停的yield表达式。考虑如下代码,稍微作了修改:

function *foo(x) {
    var y = x * (yield "Hello");    // <-- yield a value!
    return y;
}

var it = foo( 6 );

var res = it.next();    // first `next()`, don't pass anything
res.value;              // "Hello"

res = it.next( 7 );     // pass `7` to waiting `yield`
res.value;              // 42

在生成器执行期间yield..next(..)对一起,充当了两路信息传递系统。

那么,只看迭代器代码:

var res = it.next();    // first `next()`, don't pass anything
res.value;              // "Hello"

res = it.next( 7 );     // pass `7` to waiting `yield`
res.value;              // 42

注意: 我们没有向第一个next()调用传递值,那是有目的的。只有暂停的yield才能接收next(..)传递的值,当我们调用第一个next()时,没有暂停的yield来接收值。规范和所有兼容的浏览器只是静默的丢弃任何传入第一个next()的值。

第一个next()调用(没有传值给它)只是简单地问了个问题:“*foo(..)生成器下一个该给我的值是什么?”谁来回答这个问题呢?第一个yield "hello"表达式。

看到了吗?没有不匹配。

yieldnext(..)调用之间有没有不匹配,取决于你认为来回答这个问题。

但等等!相比于yield语句,仍然多一个next()。因此最后一个it.next(7)调用再次发问,生成器产生的下一个值是什么。但是没有剩余的yield语句来回答了,不是吗?那么谁来回答呢?

return语句回答这个问题!

要是生成器中没有return--return不再像普通函数那样显得十分必要了--总有个假定的/隐式地return;(即return undefined;),充当了默认回答最后it.next(7)调用提出的问题的角色。

这些问题和回答--采用yieldnext(..)两路信息传递--相当强大,但是这些机制如何和异步流控制连接在一起,还不是很清楚。我们会明白的!

多个迭代器(Multiple Iterators)

可能出现一种语法使用情况,即当你使用一个迭代器控制生成器时,你要控制的是声明的生成器函数本身。但很容易忽略些细微之处:每次构建一个迭代器,你都隐式地构建了一个生成器实例,由那个迭代器控制。

你可以同时让同一个生成器的多个实例同时运行,它们之间甚至可以交互:

function *foo() {
    var x = yield 2;
    z++;
    var y = yield (x * z);
    console.log( x, y, z );
}

var z = 1;

var it1 = foo();
var it2 = foo();

var val1 = it1.next().value;            // 2 <-- yield 2
var val2 = it2.next().value;            // 2 <-- yield 2

val1 = it1.next( val2 * 10 ).value;     // 40  <-- x:20,  z:2
val2 = it2.next( val1 * 5 ).value;      // 600 <-- x:200, z:3

it1.next( val2 / 2 );                   // y:300
                                        // 20 300 3
it2.next( val1 / 4 );                   // y:10
                                        // 200 10 3

警告: 同一个生成器的多个实例并发运行,这种方式最常见的用法不是这样交互的,而是生成器生成自己的值,或许来自某些不相关的独立资源,不需要输入。我们会在下一节讨论更多关于值生成的内容。

让我们简单过下流程:

  1. *foo()的两个实例同时启动,并且两个next()调用分别从yield 2语句中得到为2value
  2. val2 * 102 * 10,被传入第一个生成器实例it1,因此x获得值20z1增加到2,之后20*2yield出来,将val1设为40
  3. val1 * 540 * 5,被传入第二个生成器实例it2,因此x获得值200z2增加到3,之后200*3yield出来,将val2设为600
  4. val2 / 2600 / 2,被传入第一个生成器实例it1,因此y获得值300,之后打印出20 300 3,分别对应于x y z
  5. val1 / 440 / 4,被传入第二个生成器实例it2,因此y获得值10,之后打印出20 10 3,分别对应于x y z

这是在你心里运行的一个“有趣”的例子。你弄清楚了吗?

交叉(Interleaving)

回想下第一章中“Run-to-completion”一节的场景:

var a = 1;
var b = 2;

function foo() {
    a++;
    b = b * a;
    a = b + 3;
}

function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

对于普通JS函数,当然foo()可以先运行完,bar()也可以先运行完,但是foo()不能将内部的语句穿插到bar()中。因此,之前的程序只能有两个可能结果。

然而,对于生成器,很明显,交叉是可能的(甚至在语句中):

var a = 1;
var b = 2;

function *foo() {
    a++;
    yield;
    b = b * a;
    a = (yield b) + 3;
}

function *bar() {
    b--;
    yield;
    a = (yield 8) + b;
    b = a * (yield 2);
}

根据控制*foo()*bar()迭代器调用的相对顺序不同,这个程序可能生成多个结果。换句话说,通过共享同一变量交叉两个生成器,实际上我们可以实现(以一种虚假的方式)第一章中理论上的“线程竞态”情形。

首先,我们实现一个辅助函数step(..),用来控制迭代器

function step(gen) {
    var it = gen();
    var last;

    return function() {
        // whatever is `yield`ed out, just
        // send it right back in the next time!
        last = it.next( last ).value;
    };
}

step(..)初始化生成器,生成自己的it迭代器,之后返回了一个函数,该函数单步推进(advance)迭代器的执行。另外,之前yield出的值被传回下一步中。因此,yield 8只会变成8yield b只会变成byield的是什么就是什么)。

现在,只是为了好玩,让我们做个试验,来看看交叉*foo()*bar()的不同代码块会有什么效果。我们先以基本的用例开始,确保在*bar()之前,*foo()已经完全运行完了(就像我们在第一章做的那样):

// make sure to reset `a` and `b`
a = 1;
b = 2;

var s1 = step( foo );
var s2 = step( bar );

// run `*foo()` completely first
s1();
s1();
s1();

// now run `*bar()`
s2();
s2();
s2();
s2();

console.log( a, b );    // 11 22

最终结果为1122,和第一章中的版本一样。现在打乱下交叉顺序,看会如何改变ab的最终值:

// make sure to reset `a` and `b`
a = 1;
b = 2;

var s1 = step( foo );
var s2 = step( bar );

s2();       // b--;
s2();       // yield 8
s1();       // a++;
s2();       // a = 8 + b;
            // yield 2
s1();       // b = b * a;
            // yield b
s1();       // a = b + 3;
s2();       // b = a * 2;

在我告诉你结果之前,你能明白程序执行完后ab的值是多少吗?不许作弊!

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

(译者注:可能有的读者认为结果是12 24,注意在第三次s2()调用完成后,a的值为9。此时*bar()中的暂停点在b = a * (yield 2)处,其已经变成了b = 9 * (yield 2)。若将b = a * (yield 2)改为b = (yield 2) * a,则最终结果为12 24。)
注意: 作为读者练习,重排s1()s2()的调用顺序,看能有多少种其它结果。别忘了总是需要三次s1()调用和四次s2()调用。要想知道原因,回想一下之前讨论的next()yield匹配问题。

很确定的是,你基本上不会故意创建这种级别的交叉混淆,因为代码太难理解了。但是这一练习很有趣,并且有助于对多个生成器共享作用域时并发运行方式的理解,因为有时候这种能力很有用。

我们会在本章末尾讨论更多关于生成器并发的细节。

Generator'ing Values

在前一节,我们提到了生成器的一个很有意思的用法,用来生成值。这不是本章的重点,但如果我们不讲这些基本的,就是我们失职,因为这种用法契合其名称的本来意义:生成器。

我们稍微转向迭代器主题,但之后我们会绕回来讲讲它们是如何和生成器挂上钩的,以及使用生成器生成值。

发生器和迭代器(Producers and Iterators)

假设你要生成一系列值,每个值和前一个值有明确的关系。为了实现这个,你需要一个带状态的发生器(producer)来记住它给出的上一个值。

你可以直接采用类似函数闭包的方式来实现:

var gimmeSomething = (function(){
    var nextVal;

    return function(){
        if (nextVal === undefined) {
            nextVal = 1;
        }
        else {
            nextVal = (3 * nextVal) + 6;
        }

        return nextVal;
    };
})();

gimmeSomething();       // 1
gimmeSomething();       // 9
gimmeSomething();       // 33
gimmeSomething();       // 105

注意: 此处nextVal的计算逻辑本应该很简单,但从概念上,我们想直到下一个(next)gimmeSomething()调用发生的时候再计算下一个(next) 值,因为通常来说,对于更持久的或资源受限值(而不是简单的number)发生器而言,这是一种资源泄露式的设计。

生成一个随机数字序列并不是太合实际的例子。但要是你想从数据源生成记录呢?代码基本上差不多。

事实上,这是一个很常见的设计模式,通常由迭代器解决。对于步进遍历一系列由发生器生成的值,迭代器是个定义良好的接口。JS中的迭代器接口,和其它绝大多数语言一样,每当你想要从发生器中取下一个值时,只需调用next()

对于数字序列发生器,我们可以实现标准的迭代器接口:

var something = (function(){
    var nextVal;

    return {
        // needed for `for..of` loops
        [Symbol.iterator]: function(){ return this; },

        // standard iterator interface method
        next: function(){
            if (nextVal === undefined) {
                nextVal = 1;
            }
            else {
                nextVal = (3 * nextVal) + 6;
            }

            return { done:false, value:nextVal };
        }
    };
})();

something.next().value;     // 1
something.next().value;     // 9
something.next().value;     // 33
something.next().value;     // 105

注意: 我们会在"Iterables"一节中解释为什么这段代码中需要[Symbol.iterator]: ..部分。从语法上来说,此处有两个ES6特性。首先,[..]语法称为计算属性名(computed property name)。它是一种对象字面量的定义方式,指定一个表达式,并用表达式的结果作为属性名。其次,Symbol.iterator是ES6预定义的特殊Symbol值之一。

next()调用返回一个包含两个属性的对象:done是个boolean值,表示迭代器的完成状态;value保存着迭代的值。

ES6还加了for..of循环,这意味着可以通过原生的循环语法来自动处理标准的的迭代器:

for (var v of something) {
    console.log( v );

    // don't let the loop run forever!
    if (v > 500) {
        break;
    }
}
// 1 9 33 105 321 969

注意: 因为something迭代器总是返回done:false,这个for..of循环会永远运行,这就是我们放置一个break条件判断的原因。迭代器永不结束也没关系,但也有一些其它情况,比如迭代器会遍历一组有限数据集并且最终返回done:true

每次迭代,for..of循环自动调用next()--它并不会向next()中传递任何值--并且依据接收的done:true自动终止迭代。对于遍历数据集非常方便。

当然,你也可以手动遍历迭代器,调用next()并检查done:true条件来判断何时停止:

for (
    var ret;
    (ret = something.next()) && !ret.done;
) {
    console.log( ret.value );

    // don't let the loop run forever!
    if (ret.value > 500) {
        break;
    }
}
// 1 9 33 105 321 969

注意: 这种手动的for方法当然比for..of遍历难看,但是其优点是,如果需要,你可以向next(..)调用中传递值。

除了实现自己的迭代器,JS(自ES6起)中许多内建的数据结构,比如array,同样有默认的迭代器

var a = [1,3,5,7,9];

for (var v of a) {
    console.log( v );
}
// 1 3 5 7 9

for..of循环请求a的迭代器,并自动使用它来遍历a的值。

注意: 似乎ES6有个奇怪的遗漏,但是普通的objects没有像array一样的默认迭代器。原因超出了本文的范围。如果你只想遍历对象的属性(无法保证特定顺序),Object.keys(..)返回一个array,该array可以用作for (var k of Object.keys(obj)) { ..。这种for..of遍历对象的键和for..in遍历类似,除了Object.keys(..)不包含[[Prototype]]链的属性,而for..in包含。

Iterables

例子中的something对象称为迭代器,因为它的接口中有next()方法。但另一个紧密相关的术语是iterable,它是一个object,包含能够迭代自己值的迭代器

自ES6起,从iterable得到迭代器的方法是iterable必须有一个函数,名称为特殊的ES6 symbol值Symbol.iterator。当这个函数调用的时候,它返回一个迭代器。尽管不是必须的,通常每次调用应该返回一个全新的迭代器

前面的a是个iterablefo..of循环自动调用它的Symbol.iterator函数来构建一个迭代器。当然,我们也可以手动调用该函数,使用它返回的迭代器

var a = [1,3,5,7,9];

var it = a[Symbol.iterator]();

it.next().value;    // 1
it.next().value;    // 3
it.next().value;    // 5
..

在之前定义something的代码中,你可能注意到这一行:

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

这段有点混乱的代码是使something值--something迭代器的接口--也是一个iterable。之后,我们将something传入for..of循环:

for (var v of something) {
    ..
}

for..of循环希望something是个iterable,因此它会寻找并调用somethingSymbol.iterator函数。我们简单地定义函数为return this。因此,它只是简单地返回自身,for..of循环并不知情。

生成器 迭代器(Generator Iterator)

让我们把注意力转回生成器,在迭代器背景下。生成器可视作值发生器,通过迭代器接口的next()调用,我们每次抽取一个值。

因此,从技术上来说,生成器本身不是iterable,尽管很像--当你执行生成器的时候,会得到一个迭代器

function *foo(){ .. }

var it = foo();

我们可以用生成器实现早先的something无限数值序列发生器,像这样:

function *something() {
    var nextVal;

    while (true) {
        if (nextVal === undefined) {
            nextVal = 1;
        }
        else {
            nextVal = (3 * nextVal) + 6;
        }

        yield nextVal;
    }
}

注意: 正常来说,while..true循环包含在JS程序中是一件很糟糕的事,至少在没有breakreturn时是如此,因为会永远同步运行,阻塞/锁死浏览器UI。然而,在生成器中,如果有yield则完全没关系,因为生成器会在每次迭代时暂停,yield回主程序或者事件轮询队列。简单点,“生成器把while..true带回了JS编程!”

是不是更简单明了了?因为生成器在每个yield处暂停,函数*something()状态(域)被保持,意味着整个调用过程中都不需要闭包样版来保存变量状态。

不仅仅代码更简单了--我们不需要实现自己的迭代器接口--而且更合理,因为更清晰地表达了我们的意图。例如,while..true循环告诉我们生成器打算永远运行--只要我们持续要求,就能够持续生成值。

现在,我们可以使用崭新的采用for..of循环的*something()生成器。你会发现工作原理基本一致。

for (var v of something()) {
    console.log( v );

    // don't let the loop run forever!
    if (v > 500) {
        break;
    }
}
// 1 9 33 105 321 969

但别跳过for (var v of something()) ..!我们并没有像以前一样简单地引用something作为值,而是调用*something()生成器来获得迭代器for..of循环使用。

如果你密切关注的话,关于生成器和循环的这种交互,可能有两个问题:

  • 为什么我们不能用for (var v of something) ..?因为something是个生成器,并不是个iterable。我们必须调用something()来构建一个发生器供for..of迭代。
  • something()调用生成一个迭代器,但是for..of循环需要一个iterable,不是吗?是的。生成器的迭代器也有一个内建的Symbol.iterator函数,基本上作return this,就像早先定义的somethingiterable。换句话说,生成器的迭代器也是一个iterable

停止生成器(Stopping the Generator)

在前一个例子中,似乎在循环中的break调用之后,*something()生成器基本上永远处于挂起状态。

但有个隐藏行为需要你注意。for..of“的非正常完成”(即“过早终止”)--通常由breakreturn ,或者未捕获的异常引发--会向生成器的迭代器发送终止信号。

注意: 从技术上讲,在循环正常完成时,for..of循环也会向迭代器发送这个信号。对生成器而言,本质上是个无意义的操作。因为生成器的迭代器必须首先完成,然后for..of循环才完成。然而,自定义的迭代器可能希望接收到来自for..of循环处理过程的附加信号。

尽管for..of循环会自动发送该信号,但是你可能希望手动向迭代器发送信号,可以通过调用return(..)实现。

如果在生成器内部指定try..finally,则即使生成器是在外部完成,try..finally也总是会运行。如果想清理一下资源(数据连接等等),这很有用:

function *something() {
    try {
        var nextVal;

        while (true) {
            if (nextVal === undefined) {
                nextVal = 1;
            }
            else {
                nextVal = (3 * nextVal) + 6;
            }

            yield nextVal;
        }
    }
    // cleanup clause
    finally {
        console.log( "cleaning up!" );
    }
}

早先例子for..of循环中的break会触发finally子句。但你可以通过外部return(..)手动终止生成器的迭代器实例:

var it = something();
for (var v of it) {
    console.log( v );

    // don't let the loop run forever!
    if (v > 500) {
        console.log(
            // complete the generator's iterator
            it.return( "Hello World" ).value
        );
        // no `break` needed here
    }
}
// 1 9 33 105 321 969
// cleaning up!
// Hello World

当我们调用it.return(..)时,它会立马终止生成器,当然会运行finally子句。同样的,它也可以向return(..)中传入参数来设置返回value,那就是"Hello World"立即返回的方式。现在我们不需要包含break了,因为生成器的迭代器被设为done:true了,因此for..of循环会在下一次迭代时终止。

之所以叫生成器,很大原因要归于处理生成值的用法。但这只是生成器的其中一个用法,坦白来说,甚至不是本书关注的重点。

但既然我们对其工作机制有了更全面的理解,下一步,我们就该把目光转向生成器是如何运用于异步并发的。

异步迭代生成器(Iterating Generators Asynchronously)

生成器和异步编程模式有什么关系,修复回调问题之类的?让我们来回到这个重要的问题。

我们重新回顾一下第三章的一个场景。回想一下回调方法:

function foo(x,y,cb) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        cb
    );
}

foo( 11, 31, function(err,text) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( text );
    }
} );

如果想用生成器表示同样的异步流,可以这样:

function foo(x,y) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        function(err,data){
            if (err) {
                // throw an error into `*main()`
                it.throw( err );
            }
            else {
                // resume `*main()` with received `data`
                it.next( data );
            }
        }
    );
}

function *main() {
    try {
        var text = yield foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

var it = main();

// start it all up!
it.next();

第一眼看上去,代码有点长,或许比之前的回调更复杂点。但别因第一印象而偏离轨道。生成器版代码实际上更好!但有些东西需要解释一下。

首先看一下这部分代码,是最重要的:

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

想想这段代码是怎么运行的。我们调用了一个普通的函数foo(..),很明显,我们能够取得Ajax调用返回的text,即使它是异步的。

怎么可能?如果你回想下第一章开始的部分,我们有几乎一样的代码:

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

那段代码(译者注:指第一章开始部分的那段代码)并没有起作用!你注意到不同了吗?生成器中使用了一个yield

那就是神奇所在!那才是允许我们实现看起来是阻塞的、同步的代码,但实际上并不是阻塞整个程序效果的东西;它只是暂停/阻塞了生成器内部的代码。

yield foo(11,31)中,首先foo(11,31)被调用,什么都不返回(即undefined),因此我们发了个请求来获取数据,但实际上并没有做yield undefined。没关系,因为当前代码并没有依赖yield的值来做任何有趣的事。我们会在本章后面再讨论这个问题。

此处,我们没有用yield来传递信息,只是一种暂停/阻塞的流控制。事实上,在生成器恢复之后,会有信息传递的,但只是单方向的。

那么,生成器在yield处暂停,本质上是问个问题,“我应该返回什么值来赋给变量text?”谁来回答这个问题呢?

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

it.next( data );

这是以响应的数据恢复生成器,意味着暂停的yield表达式直接接收那个值,之后重启生成器代码,之后值赋给局部变量text

相当酷,不是吗?

退一步考虑一下其含义。在生成器内部,我们已经有了看起来是同步的代码(除了yield关键词本身),但隐藏在幕后的,foo(..)内部,操作可以异步完成。

意义相当重大! 回调函数无法以序列化的、我们大脑能关联上的同步方式表达异步,那是针对这一问题的几乎完美的解决方法。

本质而言,我们将异步的实现细节抽象了出来,因此我们可以以同步/序列化的方式解释异步流:“发Ajax请求,当请求完成时,打印出响应”。当然,我们在异步流控制中只表示了两步。但同样的能力可以无界限地扩展,使得我们能够按需表示任意多步。

提示: 这是个重要的实现,赶快回去再读读后三段,让它沉入心里。

同步错误处理(Synchronous Error Handling)

但是对我们而言,前面的生成器代码有更多的好处。让我们把注意力转向生成器内部的try..catch

try {
    var text = yield foo( 11, 31 );
    console.log( text );
}
catch (err) {
    console.error( err );
}

这怎么运行的呢?foo(..)调用是异步完成的,try..catch不是无法捕获异步错误吗,正如第三章中看到的一样?

我们已经看到yield是如何让赋值语句暂停来等待foo(..)完成的,以至于完成的响应可以赋给text。最精彩的部分在于yield暂停也允许生成器catch错误。我们向生成器中抛入那个错误,如早先代码中的那样:

if (err) {
    // throw an error into `*main()`
    it.throw( err );
}

生成器的yield暂停特性意味着,我们不仅可以从异步函数调用中获取看起来是同步的return值,而且也可以从那些异步函数调用中同步地catch错误!

既然我们已经看过了如何向生成器中抛出错误,但要是在生成器外也抛出错误呢?正如你所想:

function *main() {
    var x = yield "Hello World";

    yield x.toLowerCase();  // cause an exception!
}

var it = main();

it.next().value;            // Hello World

try {
    it.next( 42 );
}
catch (err) {
    console.error( err );   // TypeError
}

当然,我们可以通过throw..手动抛出错误,而不是引发一个异常。

我们甚至可以捕获像throw(..)进生成器一样的错误,本质上是给生成器一个机会来处理它,但如果不处理,迭代器代码必须处理:

function *main() {
    var x = yield "Hello World";

    // never gets here
    console.log( x );
}

var it = main();

it.next();

try {
    // will `*main()` handle this error? we'll see!
    it.throw( "Oops" );
}
catch (err) {
    // nope, didn't handle it!
    console.error( err );           // Oops
}

就可读性和可推理性而言,看起来同步的错误处理(通过try..catch)实现异步错误处理是个巨大的胜利。

生成器 + Promise(Generators + Promises)

在之前的讨论中,我们展示了如何异步迭代生成器,相对于意大利面条式的回调混乱,序列化的可推理性是个巨大的进步。但是我们丢了一些非常重要的东西:Promise的可信任和可组合性(见第三章)!

别担心--我们会找回来的。ES6中最棒的部分是组合Promise和生成器(看起来同步的异步代码)。

但怎么做呢?

回想下第三章中基于Promise的Ajax例子:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

foo( 11, 31 )
.then(
    function(text){
        console.log( text );
    },
    function(err){
        console.error( err );
    }
);

早先的Ajax生成器代码中,foo(..)什么都不返回(undefined),迭代器控制代码不关心yield的值。

但Promise式的foo(..)在Ajax调用后返回了一个promise。表明我们可以用foo(..)构建一个promise,之后从生成器中yield出来。

迭代器如何处理promise呢?

它应该监听promise解析(fulfillment或者rejection),之后既可用fulfillment信息恢复生成器,又可用错误原因向生成器中抛出错误。

让我重复一遍,因为很重要!获取Promise和生成器精华最自然的方式是yield出一个Promise,连接那个Promise来控制生成器的迭代器

我们来试一下!首先,我们将生成器*main()和Promise式的foo(..)放在一起:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

function *main() {
    try {
        var text = yield foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

这个重构最强大的启示是*main()中的代码一点也不需要变!在生成器内部,yield出什么值是不透明的实现细节,因此我们甚至没有意识到它的发生,也不需要去担心了。

但现在,我们该如何运行*main()?仍然有一些实现探究工作要做,接收和连接yield出的promise,以便一旦解析,就恢复生成器。我们手动试下:

var it = main();

var p = it.next().value;

// wait for the `p` promise to resolve
p.then(
    function(text){
        it.next( text );
    },
    function(err){
        it.throw( err );
    }
);

事实上,一点也不痛苦,不是吗?

这段代码很像我们早先做的,即手动连接的由错误优先回调控制的生成器。promise已经为我们分成fulfillmeng(成功)和rejection(失败),而不是if (err) { it.throw..,但除此之外,迭代器控制是一样的。

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

最重要的是,我们利用这一事实,即我们知道*main()内部只有一个Promise式的步骤。要是我们想用Promise驱动生成器,不管它有几步呢?我们当然不想为每个生成器手动写不同的Promise链!如果有一种方法重复(即“循环”)迭代控制,每次返回一个Promise,在继续之前等待其解析结果就好了。

另外,要是在it.next(..)调用过程中,生成器抛出一个错误(有意的或者无意的)呢?我们是该停止,还是catch它并把它发送回去?同样的,要是我们it.throw(..)一个Promise rejection到生成器中,但没有被处理,又被抛出来了呢?

Promise-Aware Generator Runner

沿着这条道路探索越多,你就越会意识到,“哇哦,如果有utility给我用就太棒了。”你问对了。这是一种很重要的模式,你不想把它弄错(或者耗尽精力地一遍一遍重复),因此你最好的赌注是使用一个utility,专门用来运行Promise-以我们所举的方式yield出生成器。

有几个Promise抽象库提供了这样的utility,包括我的异步序列库和它的runner(..),会在本书的附录A中讨论。

但为了学习和说明,让我们简单定义下单独的utility,叫做run(..)

// thanks to Benjamin Gruenbaum (@benjamingr on GitHub) for
// big improvements here!
function run(gen) {
    var args = [].slice.call( arguments, 1), it;

    // initialize the generator in the current context
    it = gen.apply( this, args );

    // return a promise for the generator completing
    return Promise.resolve()
        .then( function handleNext(value){
            // run to the next yielded value
            var next = it.next( value );

            return (function handleResult(next){
                // generator has completed running?
                if (next.done) {
                    return next.value;
                }
                // otherwise keep going
                else {
                    return Promise.resolve( next.value )
                        .then(
                            // resume the async loop on
                            // success, sending the resolved
                            // value back into the generator
                            handleNext,

                            // if `value` is a rejected
                            // promise, propagate error back
                            // into the generator for its own
                            // error handling
                            function handleErr(err) {
                                return Promise.resolve(
                                    it.throw( err )
                                )
                                .then( handleResult );
                            }
                        );
                }
            })(next);
        } );
}

如你所见,如果想自己实现,可能更复杂,尤其是对每个使用的生成器,你肯定不想一遍一遍地重复这段代码。因此,一个utility/library辅助函数是必须的。然而,我鼓励你花几分钟研究一下这段代码,以便更好地理解如何管理生成器+Promise的协调。

在Ajax例子中,对*main(),如何使用'run(..)'呢?

function *main() {
    // ..
}

run( main );

就是这样!run(..)会自动异步推进(advance)传入生成器的执行,直至结束。

注意: 我们定义的run(..)返回一个promise,一旦生成器结束,该promise就得到解析,或者,如果生成器没有处理,该promise就会接收未捕获的异常。此处,我们不展示这一功能,会在本章后面提及。

ES7: asyncawait?

之前的模式--生成器生成Promise,之后Promise控制生成器的迭代器来推进(advance)生成器的执行直至结束--是个强大和有用的方法,如果没有乱糟糟的库或者utility辅助函数(即run(..))也能实现就好了。

关于这方面,可能有好消息。在写这本书时,很早就有人提议在后ES6、ES7中添加更多关于这一领域的语法项。很明显,现在确认细节还为时尚早,但很可能与下面的类似:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

async function main() {
    try {
        var text = await foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

main();

如你所见,没有run(..)调用(意味着不需要库或者utility!)来激活和驱动main()--仅仅如普通函数调用一般。另外,main()再也不用声明成生成器函数了;它是一种新的函数:async function。最后,我们await promise解析,而不是yield它。

如果你await 一个Promise,async function自动知晓该怎么做--它会暂停函数(就和生成器一样)直至Promise解析完。这段代码中我们没有对此作说明,但是调用像main()async function结束之后会自动返回一个解析后的promise。

提示: async/await语法对有C#经验的读者而言很熟悉,因为基本上一致。

提案建议支持这种模式,使之成为一种语法机制:将Promise组合成看起来同步的流控制代码。那是两者最好的组合,能够有效处理我们列出的关于回调的所有主要问题。

起码的事实是,这样的ES7式提案已经存在了,并且得到了早期的支持,热情(译者注:指业界对这种模式很青睐)给予将来这种重要的异步模式极大信心。

生成器中的Promise并发(Promise Concurrency in Generators)

目前为止,我们说明的只是用Promise+generators实现的单步异步流。但实际中的代码通常需要多个异步步骤。

如果不注意,同步风格的生成器会麻痹你,让你满足于构建异步并发的方式,从而导致性能次优。因此,我们需要花些时间来探索一下这方面。

假设有个场景,你需要从两个不同的源获取数据,之后将两个响应合并,作第三个请求,最终打印出最后的响应。在第三章中,我们探索过类似的场景,现在在生成器背景下重新考虑。

你的第一反应可能像这样:

function *foo() {
    var r1 = yield request( "http://some.url.1" );
    var r2 = yield request( "http://some.url.2" );

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

这段代码有效,但在我们的特定场景中,这不是最优的。你能指出原因吗?

因为r1r2请求能够--并且,从性能考虑,应该--并发运行,但这段代码中,它们是序列运行的;直到"http://some.url.1"请求完成后"http://some.url.2"才开始进行Ajax数据获取。这两个请求是独立的,因此,性能更好的方式是让这两个请求同时运行。

但用生成器和yield如何做呢?我们知道yield只是代码中的单个暂停点,因此无法同时作两次暂停。

最自然和有效的答案是基于Promise的异步流,尤其是它以与时间无关的方式管理状态的功能(见第三章的Future Value)。

最简单的方法:

function *foo() {
    // make both requests "in parallel"
    var p1 = request( "http://some.url.1" );
    var p2 = request( "http://some.url.2" );

    // wait until both promises resolve
    var r1 = yield p1;
    var r2 = yield p2;

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

为什么这个不同于前一段代码呢?看下yield的位置,p1p2是并发(即并行)执行Ajax请求的promise。谁先完成并不重要,因为promise会保存着解析后的状态。

之后我们使用两个连续的yield表达式来从promise(p1p2)中等待并获取解析结果。如果p1先解析完,yield p1会首先恢复,之后等待yield p2恢复。如果p2首先解析,只是会耐心地保持解析值直至被访问,但是yield p1会首先挂起,直至p1解析。

无论哪一种情况,p1p2都会并发运行,在r3 = yield request..Ajax请求之前,无论什么顺序,p1p2都得完成。

如果那种流控制处理模型听起来很熟悉,它基本上和第三章中的gate模式一样,由Promise.all([ .. ]) utility提供。因此,我们也可以这样表示:

function *foo() {
    // make both requests "in parallel," and
    // wait until both promises resolve
    var results = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" )
    ] );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

注意: 如在第三章讨论的一样,我们甚至可以使用ES6的解构赋值来简化var r1 = .. var r2 = ..,即var [r1,r2] = results

换句话说,Promise的所有并发功能都可以用在generator+Promise中。因此,在任何需要不止一个this-then-that的异步流控制步骤的地方,Promise是你最好的选择。

Promise,隐藏(Promises, Hidden)

代码风格上需要当心的一点是,注意生成器内包含多少Promise逻辑。我们在异步中使用生成器只是为了创建简单、序列化、同步风格的代码,所以尽可能从代码层面隐藏异步细节。

例如,这种方式可能更清晰:

// note: normal function, not generator
function bar(url1,url2) {
    return Promise.all( [
        request( url1 ),
        request( url2 )
    ] );
}

function *foo() {
    // hide the Promise-based concurrency details
    // inside `bar(..)`
    var results = yield bar(
        "http://some.url.1",
        "http://some.url.2"
    );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// use previously defined `run(..)` utility
run( foo );

*foo()内部,我们所做的只是请求bar(..)获取一些results,之后yield--等待它的发生,很清晰明了。我们不需要关心在其下面的,即采用Promise.all([ .. ]) Promise组合让其发生。

我们将异步,尤其是Promise,视作实现细节。

在函数中隐藏Promise逻辑,只从生成器中调用该函数,这种方式特别有用,尤其是在做一些特别复杂的序列化流控制时。例如:

function bar() {
    Promise.all( [
        baz( .. )
        .then( .. ),
        Promise.race( [ .. ] )
    ] )
    .then( .. )
}

这种逻辑有时是需要的,如果把它直接丢到生成器内,你首先想到是为什么要这样使用生成器。我们应该从生成器中抽离出这些细节,防止这些细节弄乱更高级别任务的表达。

编写代码时除了要保证功能和性能,也要保证代码尽可能的合理和易于维护。

注意: 对编程而言,抽象也不见得总是好的--很多时候,为了简洁而增加了代码的复杂度。但在这个例子中,我认为generator+Promise异步代码比其它方案更好。总之一句话,关注特定的情形,为你和你的团队做出合理的决定。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容