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

你不知道JS:异步

第四章:生成器(Generators)

接上篇4-1

生成器委托(Generator Delegation)

在前一节中,我们展示了从生成器内部调用普通函数,以及为什么抽离实现细节是个有用的技术(像异步Promise流)。但采用普通函数的主要缺点是必须遵循不同函数规则,这意味着无法像生成器一样使用yield来暂停函数自身。

你突然想到,通过辅助函数run(..),可以试着从另一个生成器中调用生成器,形如:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

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

    // "delegating" to `*foo()` via `run(..)`
    var r3 = yield run( foo );

    console.log( r3 );
}

run( bar );

通过使用run(..) utility,我们在*bar()内部运行*foo()。此处,我们利用了这一事实,即早先定义的run(..)返回一个promise,当该生成器运行直至结束(或者发生错误)时,该promise得到解析。因此,如果我们yield出另一个run(..)调用生成的promise给run(..)实例,它会自动暂停*bar()直至*foo()完成。

但是有个更好的方法来整合*bar()内的*foo()调用,称为yield委托。yield委托的特殊语法是:yield * _(注意多出的*)。在我们看它如何在之前例子中工作之前,先看一个简单点的场景:

function *foo() {
    console.log( "`*foo()` starting" );
    yield 3;
    yield 4;
    console.log( "`*foo()` finished" );
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo();   // `yield`-delegation!
    yield 5;
}

var it = bar();

it.next().value;    // 1
it.next().value;    // 2
it.next().value;    // `*foo()` starting
                    // 3
it.next().value;    // 4
it.next().value;    // `*foo()` finished
                    // 5

注意: 类似于本章早些时候的注意事项,即为什么我偏爱function *foo() ..而不是function* foo() ..,同样,我也偏爱--不同于其它大多数相关文档--用yield *foo(),而不是yield* foo()*的位置纯粹是风格上的喜好,由你自己决定。但我觉得风格一致比较有吸引力。

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

首先,foo()调用创建了一个迭代器。之后,yield *委托/转移迭代器实例的控制权(当前*bar()生成器的)给另一个*foo()迭代器

因此,前两个it.next()调用控制*bar(),但是当进行第三个it.next()调用时,*foo()启动了,现在我们控制foo()而不是*bar()了。这就是称为委托的原因--*bar()将自己迭代的控制权委托给*foo()

一旦it迭代器控制迭代完整个*foo()迭代器,就会自动返回到*bar()控制之中。

现在回到之前的三个序列化Ajax请求的例子:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

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

    // "delegating" to `*foo()` via `yield*`
    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

和早先版本唯一的不同是采用了yield *foo(),而不是之前的yield run(foo)

注意: yield * yield出迭代控制权,而不是生成器的控制权;当你激活*foo()生成器时,yield委托到它的迭代器。但实际上也可以yield委托任何iterableyield *[1,2,3]会处理[1,2,3]的默认迭代器

为什么委托?(Why Delegation?)

yield委托的主要目的是组织代码,那样的话就和普通的函数调用没什么区别了。

假设两个模块分别提供了foo()bar()方法,bar()调用foo()
分开的原因通常是为合理的代码组织考虑,即可能在不同的函数中调用它们。比如,可能有些时候,foo()是单独调用的,有时是bar()调用foo()

几乎基于同样的原因,即保持生成器分离有助于提高程序的可读性、可维护性和可调试性。从那个角度讲,当在*bar()内部时,yield *是手动迭代*foo()步骤的简写形式。

如果*foo()的步骤是异步的,手动方法可能特别复杂,这就是为什么需要run(..)utility。如上所示,yield *foo()就不需要run(..)utility的子实例(比如run(foo))了。

委托信息(Delegating Messages)

你可能想知道yield委托是如何实现迭代器控制和两路信息传递的。通过yield委托,仔细观察信息的流入、流出:

function *foo() {
    console.log( "inside `*foo()`:", yield "B" );

    console.log( "inside `*foo()`:", yield "C" );

    return "D";
}

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-delegation!
    console.log( "inside `*bar()`:", yield *foo() );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// inside `*foo()`: 3
// inside `*bar()`: D
// outside: E

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: 4
// outside: F

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

  1. 3被传入*foo()内(通过*bar()内的yield委托)等待的yield "C"表达式。
  2. 之后*foo()调用return "D",但这个值并没有返回给外面的it.next(3)
  3. 反而,D值返回作为*bar()内等待的yield *foo()表达式的结果--当*foo()被穷尽时,这种yield委托表达本质上已经被暂停了。因此*bar()内的"D"最终被打印出来了。
  4. yield "E"*bar()内部被调用,E值被yield到外部,作为it.next(3)调用的结果。

从外部迭代器it)的角度来看,控制初始生成器和委托生成器似乎没什么区别。

事实上,yield委托甚至没有必要定向到另一个生成器,可以只定向到一个非生成器、通用iterable。比如:

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-delegation to a non-generator!
    console.log( "inside `*bar()`:", yield *[ "B", "C", "D" ] );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// outside: C

console.log( "outside:", it.next( 3 ).value );
// outside: D

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: undefined
// outside: E

console.log( "outside:", it.next( 5 ).value );
// inside `*bar()`: 5
// outside: F

注意下这个例子和前一个例子中信息的接收和报告的区别。

最不可思议的是,array的默认迭代器不关心通过next(..)调用传入的任何信息,因此值234会被忽略。另外,因为那个迭代器没有显式的return值(不像之前的*foo()),当结束的时候,yield *表达式获得一个undefined

也委托异常!(Exceptions Delegated, Too!)

yield委托两路透明传值一样,错误/异常也是两路传值的:

function *foo() {
    try {
        yield "B";
    }
    catch (err) {
        console.log( "error caught inside `*foo()`:", err );
    }

    yield "C";

    throw "D";
}

function *bar() {
    yield "A";

    try {
        yield *foo();
    }
    catch (err) {
        console.log( "error caught inside `*bar()`:", err );
    }

    yield "E";

    yield *baz();

    // note: can't get here!
    yield "G";
}

function *baz() {
    throw "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// outside: B

console.log( "outside:", it.throw( 2 ).value );
// error caught inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// error caught inside `*bar()`: D
// outside: E

try {
    console.log( "outside:", it.next( 4 ).value );
}
catch (err) {
    console.log( "error caught outside:", err );
}
// error caught outside: F

这段代码中有些东西需要注意:

  1. 当调用it.throw(2)时,会将错误信息2发送给*bar()*bar()会将其委托给*foo(),之后*foo() catch它并处理。之后yield "C"C返回作为it.throw(2)调用的返回value
  2. *foo()内部下一个throw抛出的"D"值传播到*bar()中,*bar() catch它并处理。之后yield "E"返回E作为it.next(3)调用的返回value
  3. 之后,*baz() throw出的异常没有在*bar()中捕获--尽管我们确实在外面catch它--因此,*baz()*bar()都被设为完成状态。这段代码之后,你可能无法利用随后的next(..)调用获得"G"值--它们只会简单地返回undefined作为value

委托异步(Delegating Asynchrony)

让我们回到早先的多个序列化Ajax请求的yield委托例子:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

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

    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

我们只是简单地在*bar()内部调用yield *foo(),而不是yield run(foo)

在这一例子的前一版本中,Promise机制(由run(..)控制)用来传递*foo()return r3的值给*bar()内的局部变量r3。现在,现在,那个值通过yield *机制直接返回。

除此之外,行为完全一致。

委托“递归”(Delegating "Recursion")

当然,yield委托能够跟踪尽可能多的委托步骤。你甚至可以对异步的生成器“递归”--生成器yield委托给自己--使用yield委托:

function *foo(val) {
    if (val > 1) {
        // generator recursion
        val = yield *foo( val - 1 );
    }

    return yield request( "http://some.url/?v=" + val );
}

function *bar() {
    var r1 = yield *foo( 3 );
    console.log( r1 );
}

run( bar );

注意: run(..) utility本该以run(foo,3)的形式调用,因为它支持附加参数,用来传递给生成器的初始化过程。然而,这里我们用了没有参数的*bar(),来突出yield *的灵活性。

那段代码遵循什么执行步骤?坚持住,细节描述有点复杂:

  1. run(bar)启动*bar()生成器。
  2. foo(3)创建一个*foo(3)迭代器,传入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)生成器实例。
  7. yield *将那个promise传回到*foo(3)生成器实例。另一个yield *将promise传出给*bar()生成器实例。再一次,另一个yield *将promise传出给run() utility,它会等待那个promise(第一个Ajax请求)解析。
  8. 当promise解析后,它的fulfillment信息被发送出去用来恢复*bar(),信息通过yield *传入到*foo(3)实例,之后通过yield *传入到*foo(2)实例,之后通过yield *传入到*foo(3)中等待的普通yield中。
  9. 现在,第一个调用的Ajax响应立即从*foo(3)生成器实例中return出来,之后将其返回作为*foo(2)实例中yield *表达式的结果,并将其赋给局部变量val
  10. *foo(2)内部,request(..)发起了第二个Ajax请求,它的promise被yield*foo(1)实例,之后yield *将其一路传到run(..)(重复第7步)。当promise解析后,第二个Ajax响应一路传回*foo(2)生成器实例,赋给局部变量val
  11. 最后,request(..)发起了第三个Ajax请求,它的promise返回给run(..),之后它的解析值一路返回,直至被return,以便回到*bar()中等待的yield *表达式。

唷!精神饱受摧残了?你可能想再读几次,之后吃点零食清空下大脑!

生成器并发(Generator Concurrency)

如第一章和本章早些时候提到的,两个同时运行的“进程”可以协作式的交叉各自的操作,很多时候能够yield出强大的异步表达式。

坦白来讲,早先的多个生成器并发交叉的例子证明了是多么的令人感到混乱。但我们暗示了某些场合,这种能力非常有用。

回想下第一章中的场景,两个不同的同时Ajax响应处理函数需要相互协调,以便数据通信不会造成竞态。我们把响应像这样放入到res数组中:

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

但在这一场景中,我们如何并发使用多个生成器呢?

// `request(..)` is a Promise-aware Ajax utility

var res = [];

function *reqData(url) {
    res.push(
        yield request( url )
    );
}

注意: 此处我们打算使用*reqData(..)的两个生成器实例,但是和运行两个不同生成器的单个实例没有什么区别;两种方法的思考方式是一致的。一会我们看看两个不同生成器的协调。

我们会使用协调排序,以便res.push(..)能够将值以可预料的顺序放置
,而不是手动地分为res[0]res[1]赋值。表达逻辑因此也可以更清晰些。

但实际上该如何编排这种交互呢?首先,让我们手动用Promise实现一下:

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next().value;
var p2 = it2.next().value;

p1
.then( function(data){
    it1.next( data );
    return p2;
} )
.then( function(data){
    it2.next( data );
} );

*reqData(..)的两个实例都开始发起Ajax请求,之后通过yield暂停。之后当p1解析后,我们选择恢复第一个实例,之后p2的解析结果会重启第二个实例。这样的话,我们用promise编排来确保res[0]存放第一个响应,res[1]存放第二个响应。

但坦白来讲,这种方式太手动化了,并没有真正让生成器编排它们,这才是强大之处(译者注:指让生成器编排)。让我们换个方式试一下:

// `request(..)` is a Promise-aware Ajax utility

var res = [];

function *reqData(url) {
    var data = yield request( url );

    // transfer control
    yield;

    res.push( data );
}

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next().value;
var p2 = it2.next().value;

p1.then( function(data){
    it1.next( data );
} );

p2.then( function(data){
    it2.next( data );
} );

Promise.all( [p1,p2] )
.then( function(){
    it1.next();
    it2.next();
} );

OK,好一点了(尽管仍然是手动的!),因为现在*reqData(..)的两个实例真的并发、独立(至少从第一部分来讲)运行。

在上一个代码中,直到第一个实例完全结束之后,第二个才给出它的数据。但这里,只要各自的响应返回,两个实例都能尽快的接收到数据,之后每个实例为了控制转移目的,作了另一个yield。之后通过在Promise.all([ .. ])的处理函数中来选择恢复顺序。

不大明显的一点是,由于对称性,这个方法暗含了一种更简单的复用utility形式。我们可以做的更好。假设使用一个称为runAll(..)的utility:

// `request(..)` is a Promise-aware Ajax utility

var res = [];

runAll(
    function*(){
        var p1 = request( "http://some.url.1" );

        // transfer control
        yield;

        res.push( yield p1 );
    },
    function*(){
        var p2 = request( "http://some.url.2" );

        // transfer control
        yield;

        res.push( yield p2 );
    }
);

注意: 我们没有展示出runAll(..)的实现代码,不仅因为太长,而且还是早先实现的run(..)逻辑的拓展。因此,作为读者很好的练习补充,试着从run(..)演化代码,使其运行原理和想象的runAll(..)一样。另外,我的asynquence库提供了之前提到了runner(..)utility,其内建有这种功能,会在本书的附录A中讨论。

以下是runAll(..)内部的运行方式:

  1. 第一个生成器获得来自"http://some.url.1"的第一个Ajax响应的promise,之后yield控制权回到runAll(..)utility。
  2. 第二个生成器运行,同样处理"http://some.url.2"yield控制权回到runAll(..)utility。
  3. 第一个生成器恢复,之后yield出它的promisep1,这种情况下,runAll(..)utility和之前的run(..)做的一样,在其内部,等待promise的解析,之后恢复同一个生成器(不是控制权转移!)。当p1解析后,runAll(..)用解析值再次恢复第一个生成器,之后res[0]就被赋了该值。当第一个生成器结束之后,有个隐式的控制权转移。
  4. 第二个生成器恢复,yield出promisep2,等待其解析。一旦解析,runAll(..)以解析值恢复第二个生成器,并且设置res[1]

在这个运行例子中,我们使用了外部变量res来保存两个不同Ajax响应的结果--这使得并发协调成为可能。

但进一步扩展下runAll(..),提供一个由多个生成器实例共享的内部变量空间可能更好,比如下面我们称为data的空对象。另外,也可以yield出非Promise变量,并把它们传递给下一个生成器。

考虑如下:

// `request(..)` is a Promise-aware Ajax utility

runAll(
    function*(data){
        data.res = [];

        // transfer control (and message pass)
        var url1 = yield "http://some.url.2";

        var p1 = request( url1 ); // "http://some.url.1"

        // transfer control
        yield;

        data.res.push( yield p1 );
    },
    function*(data){
        // transfer control (and message pass)
        var url2 = yield "http://some.url.1";

        var p2 = request( url2 ); // "http://some.url.2"

        // transfer control
        yield;

        data.res.push( yield p2 );
    }
);

这种形式中,两个生成器不仅协调控制权转移,而且还相互通信,都是通过data.res和交换rul1rul2yield出的信息。相当强大!

这样的实现也为一种更复杂的称为CSP(通信序列进程,Communicating Sequential Processes)的异步技术充当了概念基础,我们会在本书的附录B中讨论。

Thunks

迄今为止,我们一直假定生成器yield出Promise--通过如run(..)的辅助utility让Promise恢复生成器的运行--是用生成器管理异步的最好的方法。说明白点,它就是。

但我们跳过了另一种被广泛接受的模式,因此,为了保证完整性,我们简单看下。

在一般的计算机科学中,有一个很老的、在JS之前的概念,叫"thunk"。就不提它的历史了,在JS中,thunk简单点的表达就是一个调用另一个函数的函数(没有任何参数)。

换句话说,就是用函数定义包装一个函数调用--用它所需的任何参数--来推迟调用的执行,包装函数就称为一个thunk。当之后执行thunk时,最终会调用初始的函数。

例如:

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

function fooThunk() {
    return foo( 3, 4 );
}

// later

console.log( fooThunk() );  // 7

因此,同步的thunk相当直接。但要是异步的thunk呢?我们可以简单地扩展thunk定义,允许接收一个回调。

考虑如下:

function foo(x,y,cb) {
    setTimeout( function(){
        cb( x + y );
    }, 1000 );
}

function fooThunk(cb) {
    foo( 3, 4, cb );
}

// later

fooThunk( function(sum){
    console.log( sum );     // 7
} );

如你所见,fooThunk(..)只期望一个cb(..)参数,因为它已经预设有值34(分别对应xy),并且准备传给foo(..)。thunk只需耐心等待做最后一件事:回调。

然而,你并不想手动实现thunk。因此,让我们实现一种utility来为我们做这种包装工作。

考虑如下:

function thunkify(fn) {
    var args = [].slice.call( arguments, 1 );
    return function(cb) {
        args.push( cb );
        return fn.apply( null, args );
    };
}

var fooThunk = thunkify( foo, 3, 4 );

// later

fooThunk( function(sum) {
    console.log( sum );     // 7
} );

提示: 此处我们假定初始函数(foo(..))希望回调处在最后的位置,其余任何参数都是在它之前。这是异步JS函数标准中相当常见的“标准”。可以称之为“callback-last style”,如果处于某种原因,你需要处理”callback-first style“的形式,只需在utility中使用args.unshift(..),而不是args.push(..)

之前的thunkify(..)实现采用foo(..)函数引用和其它任何需要的参数,之后返回thunk本身(fooThunk(..))。然而,这并不是JS中典型的thunk方法。

如果不是太困惑的话,thunkify(..)utility会生成一个函数,该函数能生成thunks,而不是thunkify(..)直接生成thunks。

哦。。。耶。

考虑如下:

function thunkify(fn) {
    return function() {
        var args = [].slice.call( arguments );
        return function(cb) {
            args.push( cb );
            return fn.apply( null, args );
        };
    };
}

主要区别在于额外的return function() { .. }层,以下是用法的不同:

var whatIsThis = thunkify( foo );

var fooThunk = whatIsThis( 3, 4 );

// later

fooThunk( function(sum) {
    console.log( sum );     // 7
} );

很明显,这段代码暗含的大问题是whatIsThis如何称呼。它不是thunk,它会生成thunk。有点像"thunk"“工厂”,似乎对其命名没有个统一的标准。

因此,我的提议是“thunkory”(“thunk”+“factory”)。那么,thunkify(..)生成一个thunkory,之后thunkory生成thunks。原因和我第三章提议的promisory差不多:

var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// later

fooThunk1( function(sum) {
    console.log( sum );     // 7
} );

fooThunk2( function(sum) {
    console.log( sum );     // 11
} );

注意: 运行的foo(..)期望的回调形式不是“error-first style”。当然,“error-first style”更常见。如果foo(..)预期会有某种合理的错误生成,我们可以修改一下,采用错误优先回调。随后的thunkify(..)不关心假定的是哪种形式的回调。使用方法上的唯一区别就是fooThunk1(function(err,sum){..

暴露thunkory方法--而不是早先的thunkify(..)将中间步骤隐藏起来--似乎增加了不必要的复杂度。但通常而言,在程序开始之前,生成thunkory来包装现有的API方法很有用,当需要thunk的时候,可以传入并调用这些thunkory。两个分开的步骤实现了更清晰的功能分离。

举例如下:

// cleaner:
var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// instead of:
var fooThunk1 = thunkify( foo, 3, 4 );
var fooThunk2 = thunkify( foo, 5, 6 );

不管你喜欢显式还是隐式地处理thunkory,thunk fooThunk1(..)fooThunk2(..)的用法仍然一样。

s/promise/thunk/

那么,thunk如何处理生成器呢?

通常将thunk比作promise:它们不是直接可以相互取代的,因为在行为上并不对等。相比于裸奔的thunk,Promise功能更强,更值得信任。

但从另一方面来说,它们都可以视作请求一个值,并且都是异步的。

回想下第三章中我们定义的用来promisify函数的utility,称为Promise.wrap(..)--我们也可称之为promisify(..)!这个Promise 包装utility并不生成Promise;它生成promisory,promisory能够生成Promise。这与讨论的thunkory和thunk完全一致。

为了说明一致性,首先将早先的foo(..)例子改为“error-first style”回调:

function foo(x,y,cb) {
    setTimeout( function(){
        // assume `cb(..)` as "error-first style"
        cb( null, x + y );
    }, 1000 );
}

现在,我们比较下使用thunkify(..)promisify(..)(即第三章中的Promise.wrap(..)):

// symmetrical: constructing the question asker
var fooThunkory = thunkify( foo );
var fooPromisory = promisify( foo );

// symmetrical: asking the question
var fooThunk = fooThunkory( 3, 4 );
var fooPromise = fooPromisory( 3, 4 );

// get the thunk answer
fooThunk( function(err,sum){
    if (err) {
        console.error( err );
    }
    else {
        console.log( sum );     // 7
    }
} );

// get the promise answer
fooPromise
.then(
    function(sum){
        console.log( sum );     // 7
    },
    function(err){
        console.error( err );
    }
);

本质而言,thunkory和promisory都在问一个问题(请求值),thunk fooThunk和promise fooPromise分别代表问题的未来答案。从那个角度而言,一致性很明显。

有这样的想法之后,为了实现异步,yield Promise的生成器也可以yield thunk。我们所需的只是个更精简的run(..) utility(和之前的差不多),不仅能够搜寻并连接到一个yield出的Promise,而且能够为yield出的thunk提供回调函数。

考虑如下:

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

run( foo );

在这个例子中,request(..)既可以是个返回promise的promisory,又可以是个返回thunk的thunkory。从生成器内部代码逻辑角度而言,我们不关心实现细节,这相当强大!

因此,request(..)可以是这样:

// promisory `request(..)` (see Chapter 3)
var request = Promise.wrap( ajax );

// vs.

// thunkory `request(..)`
var request = thunkify( ajax );

最后,作为早先run(..)utility的thunk式补丁,可能需要如下的逻辑:

// ..
// did we receive a thunk back?
else if (typeof next.value == "function") {
    return new Promise( function(resolve,reject){
        // call the thunk with an error-first callback
        next.value( function(err,msg) {
            if (err) {
                reject( err );
            }
            else {
                resolve( msg );
            }
        } );
    } )
    .then(
        handleNext,
        function handleErr(err) {
            return Promise.resolve(
                it.throw( err )
            )
            .then( handleResult );
        }
    );
}

现在,我们的生成器既可以调用promisory yield Promise,也可以调用thunkory yield thunk,并且无论哪一种情形,run(..)都能够处理那个值并且使用它来等待其完成,继而恢复生成器。

由于对称性,这两个方法看起来一致。然而,我们应该指出的是,只有从Promise或者thunk代表能够推进生成器执行的未来值的角度来说,这才是正确的。

从更大角度而言,thunk内部几乎没有任何Promise所具备的可信任性和可组合性保证。在这种特定的生成器异步模式中,使用thunk作为Promise的替身是有效地,但相比于Promise提供的种种好处(见第三章),使用thunk应视为不太理想的方案。

如果可以选择,优先用yield pr而不是yield th。但让run(..)utility可以处理这两种值类型没什么问题。

提示: 我的asynquence库中的runner(..) utility,能够处理Promise,thunk和asynquence序列。

ES6前的生成器(Pre-ES6 Generators)

现在,你很希望相信生成器是异步编程工具箱中一个非常重要的添加项。但它是ES6中新增的语法,意味着你无法像Promise(只是一个新的API)那样polyfill 生成器。那么如果无法忽略ES6前的浏览器,我们如何将生成器引入浏览器中呢?

对于所有ES6中的语法扩展,有些工具--最常用的术语叫转译器,全称转换-编译--可以提供给你ES6语法,并将其转换为对等的(但相当丑陋)ES6前的语法。因此,生成器可以转译为具有同样的行为的代码,能够在ES5或者更低的版本JS中运行。

但怎么转呢?yield“魔法”听起来很明显不容易转译。其实在早先的基于闭包的迭代器中,我们已经暗示了一种解决方案。

手动转换(Manual Transformation)

在我们讨论转译器之前,让我们研究一下手动转译生成器是如何工作的。这不仅仅是个学术活动,也可以帮助你增强对其工作原理的理解。

考虑如下:

// `request(..)` is a Promise-aware Ajax utility

function *foo(url) {
    try {
        console.log( "requesting:", url );
        var val = yield request( url );
        console.log( val );
    }
    catch (err) {
        console.log( "Oops:", err );
        return false;
    }
}

var it = foo( "http://some.url.1" );

第一点需要注意的是,我们仍然需要一个能被调用的普通foo()函数,并且仍然需要返回一个迭代器。因此,简单勾画一下非生成器转译:

function foo(url) {

    // ..

    // make and return an iterator
    return {
        next: function(v) {
            // ..
        },
        throw: function(e) {
            // ..
        }
    };
}

var it = foo( "http://some.url.1" );

接下来要关心的是生成器通过暂停它的域/状态来实现其“魔法”,但我们可以用函数闭包来模拟。为理解如何写这些代码,我们首先用状态值注释一下生成器的不同部分:

// `request(..)` is a Promise-aware Ajax utility

function *foo(url) {
    // STATE *1*

    try {
        console.log( "requesting:", url );
        var TMP1 = request( url );

        // STATE *2*
        var val = yield TMP1;
        console.log( val );
    }
    catch (err) {
        // STATE *3*
        console.log( "Oops:", err );
        return false;
    }
}

注意: 为了更准确地说明,我们采用临时变量TMP1,将val = yield request..语句分成两部分。request(..)在状态*1*时发生,将其完成值赋给变量val在状态*2*时发生。当将代码转换为它的非生成器对等时,我们需要去掉中间的TMP1

换句话说,*1*是开始状态,*2*request(..)成功状态,*3*request(..)失败状态。你可以想象一下附加的yield步骤是如何编码成附加的状态。

回到我们转译的生成器,让我们在闭包中定义一个变量state,用来追踪状态:

function foo(url) {
    // manage generator state
    var state;

    // ..
}

现在,在处理状态的闭包中定义一个称为process(..)的函数,采用switch语句:

// `request(..)` is a Promise-aware Ajax utility

function foo(url) {
    // manage generator state
    var state;

    // generator-wide variable declarations
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // ..
}

生成器中的每个状态对应switch语句中的case。每次需要处理新状态时,就要调用process(..)。之后我们会讲下它的工作原理。

对于任何一般的生成器变量申明(val),我们将其移至process(..)外的var声明中,这样可供多次process(..)调用使用。但是“块域”变量err仅状态*3*需要使用,因此我们将其放在块里。

在状态*1*时,我们作了return request(..),而不是yield request(..)。在终止状态*2*中,没有显式的需要return,所以只有个简单的return;这和return undefined一样。在终止状态*3*中,有个return false,我们能够保存该值。

现在我们需要在迭代器内部定义代码,以便能够合适地调用process(..)

function foo(url) {
    // manage generator state
    var state;

    // generator-wide variable declarations
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // make and return an iterator
    return {
        next: function(v) {
            // initial state
            if (!state) {
                state = 1;
                return {
                    done: false,
                    value: process()
                };
            }
            // yield resumed successfully
            else if (state == 1) {
                state = 2;
                return {
                    done: true,
                    value: process( v )
                };
            }
            // generator already completed
            else {
                return {
                    done: true,
                    value: undefined
                };
            }
        },
        "throw": function(e) {
            // the only explicit error handling is in
            // state *1*
            if (state == 1) {
                state = 3;
                return {
                    done: true,
                    value: process( e )
                };
            }
            // otherwise, an error won't be handled,
            // so just throw it right back out
            else {
                throw e;
            }
        }
    };
}

这段代码是如何工作的呢?

  1. 对迭代器next()的第一次调用会将生成器从未初始状态转到状态1,之后调用process()来处理该状态。request()的返回值,即Ajax的响应promise,被返回作为next()调用的value属性值。
  2. 如果Ajax请求成功,第二个next(..)调用需要传入Ajax响应值,会将状态切换为2.process(..)被再次调用(这次需要传入Ajax响应值),从next(..)返回的value属性就为undefined
  3. 然而,如果Ajax请求失败,应当以error调用throw(..),会将状态从1变成3(而不是2)。process(..)再次被调用,这次是以错误值。那个case返回false,会被设为throw(..)调用返回的value属性值。

从外部看--它只和迭代器交互--这个foo(..)普通函数和*foo(..)表现的完全一样。因此,我们已经有效地将ES6生成器转译为pre-ES6的兼容代码!

之后,我们可以手动实例化生成器并控制其迭代器--调用var it = foo("..")it.next(..),诸如此类--或者更好的方法,我们可以把它传给之前定义的run(..)utility,即run(foo,"..")

自动转译(Automatic Transpilation)

之前的手动转译ES6生成器为pre-ES6等价代码练习从概念上教会了我们生成器是如何工作的。但那种转译真的很复杂,并且不好移植到其它生成器。手动实现相当不切实际,会完全消除生成器的好处。

但幸运的是,已经有几个工具库能够将ES6生成器转译成类似我们之前转译的结果。它们不仅为我们做了繁重的工作,而且也处理了我们一带而过的几个复杂问题。

其中一个工具是regenerator(https://facebook.github.io/regenerator/),来自Facebook的小folk。

如果我们使用regenerator转译之前的生成器,以下是转译后的代码:

// `request(..)` is a Promise-aware Ajax utility

var foo = regeneratorRuntime.mark(function foo(url) {
    var val;

    return regeneratorRuntime.wrap(function foo$(context$1$0) {
        while (1) switch (context$1$0.prev = context$1$0.next) {
        case 0:
            context$1$0.prev = 0;
            console.log( "requesting:", url );
            context$1$0.next = 4;
            return request( url );
        case 4:
            val = context$1$0.sent;
            console.log( val );
            context$1$0.next = 12;
            break;
        case 8:
            context$1$0.prev = 8;
            context$1$0.t0 = context$1$0.catch(0);
            console.log("Oops:", context$1$0.t0);
            return context$1$0.abrupt("return", false);
        case 12:
        case "end":
            return context$1$0.stop();
        }
    }, foo, this, [[0, 8]]);
});

和我们之前的手动版本相比,有一定的相似性,比如switch/case语句,我们甚至看到了从闭包中抽出的val

当然,有个折中,即regenerator转译需要一个辅助库regeneratorRuntime,其中包含了管理通用生成器/迭代器的所有复用逻辑。许多那样的样版代码看起来不同于我们的版本,但即使是那样,也能看到相关的概念,比如用来追踪生成器状态的context$1$0.next = 4

主要的挑战是,生成器不仅仅限于ES6+环境中。一旦你理解了概念,就可以在代码中使用它们,采用工具来将其转译成旧环境兼容的代码。

相比pre-ES6 Promise,只需用个Promise API polyfill,这里的工作量显然更多,但努力是完全值得的,因为生成器能够以合理、有意义、看起来同步、序列化的方式更好地表达异步流控制。

一旦你迷上了生成器,你就再也不想回到异步的意大利面条式的回调地狱了!

回顾(Review)

生成器是一种新的ES6函数类型,它不是像普通函数那样运行直至结束的。而是,生成器可以在中间过程(完全保持自身状态)暂停,并且之后可以从暂停的地方恢复。

这种暂停/恢复的切换是协作式的,而不是抢占式的。这意味着生成器有独有的能力暂停自己(采用yield关键字),之后控制生成器的迭代器能够恢复生成器(通过next(..))。

yield/next()对不仅仅是一种控制机制,实际上还是一种两路信息传递机制。本质上,yield..表达式暂停生成器并等待值,下一个next(..)调用传回一个值(或者隐式的undefined)来恢复生成器。

生成器关于异步流控制的关键优点是,生成器内部的代码能够以同步/序列化的方式表示任务序列。技巧在于我们将异步隐藏到yield关键字之后了--将异步移到生成器的迭代器控制的代码部分。

换句话说,生成器实现了异步代码的序列化、同步化和阻塞性,这可以让我们的大脑能够更自然地推演代码,解决基于回调的异步的两大缺点之一。

推荐阅读更多精彩内容