美文网首页
你不知道JS:异步(翻译)系列4-2

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

作者: purple_force | 来源:发表于2016-08-24 18:15 被阅读0次

    你不知道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关键字之后了--将异步移到生成器的迭代器控制的代码部分。

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

    相关文章

      网友评论

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

          本文链接:https://www.haomeiwen.com/subject/fzjosttx.html