深入 Generator 函数(三) (Going Async With ES6 Generators)

Going Async With ES6 Generators
作者简介:Kyle Simpson is an Open Web Evangelist from Austin, TX, passionate about all things JavaScript. He's an author, workshop trainer, tech speaker, and OSS contributor/leader.

现在你对 generator 函数已经有了深入的了解了,是时候用它们来改进现实世界中的代码了。

Generator 函数的主要优点是它们提供了单线程、同步编码样式的代码风格,同时允许你通过实习细节隐藏异步操作。这让我们可以非常自然地去表达我们程序的 步骤/语句 的流程,而不是像以前一样要同时兼顾着异步语法和错误。

换句话说,通过将值(我们 generator 的逻辑 -- yield)的消费与异步实现这些值(通过 generator 迭代器的 next() 方法)的实现细节分开,我们可以达到 功能/关注点 分离的目的。

结果?功能强大的异步代码具有了和同步代码一样的易读性和可维护性。

那么我们如何完成这个壮举呢?

Simplest Async

在最简单的情况下,generator 不需要额外的程序不具备的异步处理能力。

例如,我们假定你已经有如下代码:

function makeAjaxCall(url,cb) {
    // do some ajax fun
    // call `cb(result)` when complete
}

makeAjaxCall( "http://some.url.1", function(result1){
    var data = JSON.parse( result1 );

    makeAjaxCall( "http://some.url.2/?id=" + data.id, function(result2){
        var resp = JSON.parse( result2 );
        console.log( "The value you asked for: " + resp.value );
    });
} );

使用 generator (没有任何额外的东西)来表达这个相同的程序,可以这样操作:

function request(url) {
    // this is where we're hiding the asynchronicity,
    // away from the main code of our generator
    // `it.next(..)` is the generator's iterator-resume
    // call
    makeAjaxCall( url, function(response){
        it.next( response );
    } );
    // Note: nothing returned here!
}

function *main() {
    var result1 = yield request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = yield request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
}

var it = main();
it.next(); // get it all started

我们来看看这是如何工作的。

request() 函数包装了我们常规的 makeAjaxCall(),以确保它的回调函数会调用 generator 迭代器的 next() 方法。

你会注意到,request() 函数的调用并没有返回值(换句话说,它的返回值为 undefined)。这并不是什么大不了的事情。但是,与我们文章后面的代码(函数方法)作 比较/联系 的话,这又是一件很重要的事情,因为我们在这里有效地 yield undefined

因此,我们认为 yield .. (yield undefined) 在这一点上,除了暂停我们的 generator 函数外,没有做任何其他事情。它将处于等待状态,直到我们的 Ajax 调用完成,然后调用 it.next() (作为回调)来恢复我们 generator 的执行。

但是,对于 yield .. 的结果发生了什么?我们把它赋值给了变量 result1。那么它是如何拥有第一个 Ajax 请求结果的呢?

因为在 Ajax 的回调中,我们调用了 it.next(),并且将 Ajax 的 response 传入了其中,这就意味着 response 会在暂停点传回到我们的 generator 中,就是通过 result1 = yield .. 这一语句实现的。

这真的很酷、很强大。实质上,result1 = yield request(..) 正在请求值,但是对于我们来说,这一行为(几乎)完全是隐蔽的,至少在这里我们不用去担心它。隐藏的实现造成了这一步的异步操作。通过 yield 隐藏的暂停能力来实现异步,并将 generator 的 恢复/重启 能力分离到另一个函数中,以便我们的主干代码进行的只是同步(看起来)值请求。

对于 result2 = yield result(..) 来说是同样的剧情,暂停&恢复执行,返回我们的请求值,这个过程中你不用担心异步实现细节对你的代码造成影响。

当然,yield 关键字是会存在于代码中的,以便给你一个微妙的提示 —— 在这个点上可能会发生神奇的事情(又称为异步)哦。但是,与嵌套回调的地狱噩梦(又或者是无穷无尽的 promise 链)相比,yield 真的只是一个相当小的语法 标记/开销。

你也应该注意到我说的是"可能发生"。这本身就是一个非常强大的事情。上面的程序都是在做一个异步的 Ajax 请求,但是如果没有呢?如果我们的程序稍后要更改为获取先前(预存) Ajax 请求结果的缓存,该怎么办呢?或者我们应用的 URL 路由中有一些复杂的情况,它们在某些情况下可能要立即执行完成 Ajax 请求,而不需要真正的从服务器上获取它?

我们可以将 request(..) 的实现修改为如下:

var cache = {};

function request(url) {
    if (cache[url]) {
        // "defer" cached response long enough for current
        // execution thread to complete
        setTimeout( function(){
            it.next( cache[url] );
        }, 0 );
    }
    else {
        makeAjaxCall( url, function(resp){
            cache[url] = resp;
            it.next( resp );
        } );
    }
}

注意:这里有一处微妙的、狡猾的细节(就是一个非常容易忽略而造成错误的坑),当请求的缓存存在的情况下,需要 setTimeout(.. 0) 来延时。如果我们只是立即去调用 it.next(..) 的话,它就会产生一个错误,因为(这就是棘手的部分)generator 函数从技术上来说它还没有处于暂停状态。我们的函数首先会对 request(..) 进行完全地计算,然后才会 yield 暂停。所以,我们不能在 request(..) 中立即调用 it.next(..),因为在那一刻,generator 依然在运行(yield 还没有被处理)。但是我们可以在“稍稍等一下”之后,也就是当前执行线程完成之后,立即调用 it.next(..) ,我们的黑科技 setTimeout(.. 0) 随后完成。我们下面会有一个更好的答案。

对于上面提到的那个棘手的部分,可以看看这两篇文章,应该会有一个更好的理解:
深入探讨JavaScript的执行环境和栈
什么是 Event Loop?

现在,我们的主 generator() 函数的代码仍然如下所示:

var result1 = yield request( "http://some.url.1" );
var data = JSON.parse( result1 );
..

看!?我们 generator 的逻辑(也称为控制流)根本不需要从上面的非缓存版本进行更改。

*main() 的代码仍然是请求一个值,直到得到返回值,然后再继续执行。在我们目前的情况下,“pause”可能是一个相对较长的时间(实际的服务器请求可能是 300-800 ms),或者几乎可以立即执行完成(setTimeout(.. 0) 的黑科技)。但我们的控制流不在乎这些。

这就是将异步作为一个实现细节抽象出来的真正强大之处。

Better Async

上述方法对于简单的异步 generators 是非常好的。但是很快就会发现它的局限性,所以我们需要一个更强大的异步机制与我们的 generators 配合,这样可以处理更多繁重的工作。这个机制就是——Promise

如果你对 ES6 Promises 不太清楚呢,我写了一系列关于它的文章,去阅读一下吧。我将会等着你归来。<chuckle, chukle>。可怕的异步笑话!(外国友人的幽默...)

早期的 Ajax 代码示例都遭受着所有相同的 Inversion of Control 问题(通俗的来讲就是“回调地狱”),它们会作为我们初始的嵌套回调示例。到目前为止,我们缺少的是以下这些:

  1. 没有明确的错误处理路径。正如我们上篇文章提到的,我们可以检测到 Ajax 调用的错误(以某种方式),并使用 it.throw() 将错误传回到我们的 generator 中,然后在我们的 generator 逻辑中使用 try .. catch 处理它。但是,这相当于在 "back-end" 做了很多手动的工作(这些代码处理我们 generator 的迭代器),如果在我们的程序中要做大量的 generators 的话,代码可能是无法复用的。

  2. 如果 makeAjaxCall(..) 的功能不在我们的控制之下,它可能调用回调很多次,又或者是同时发出成功和错误信息,等等,那么我们的 generator 将会变的乱七八糟(无法捕获错误,没有期望的输出值,等等)。处理和防止这些问题是需要很多重复的手动工作的,而且代码也可能不可移植。

  3. 通常,我们需要“并行”执行多个任务(例如,同时进行两个 Ajax 调用)。由于 generator 的 yield 语句每个都是单独的暂停点,所以两个或更多个不能同时运行——它们必须按照顺序一个一个地运行。所以,没有大量的手动代码去处理逻辑,在单个 generator yield 点是无法很好地去发起多个任务的。

正如你所看到的,所有这些问题都是可以解决的,但是我们时刻都想发明一个更加完美的解决方案。我们需要一个更强大的模式,专门为我们基于 generator 异步编码提供可信赖的、可重复使用的解决方案。

那个模式?yield 语句生成 promises,让它们履行恢复 generator 的职能。

回想一下,我们确实进行了 request(..) 的请求,而 request(..) 没有返回任何值,所以它实际上只是 yield undefined?

我们来调整一下。让我们以 promises 为基础改造我们的 request(..) 的实现,以便它能够返回一个 promise 对象,这样我们 yield 实际上是一个 promise 对象(而不是 undefined)。

function request(url) {
    // Note: returning a promise now!
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } );
}

request(..) 现在构建了一个 promise 对象用来进行 Ajax 调用,并在 Ajax 调用完成时 resolve promise,并且返回该 promise 对象,以便我们可以得到这个对象。接下来是什么?

我们需要一个控制 generator 的迭代器,它将接收到这些 yield 产生的 promise 对象,并将它们连接起来以恢复 generator (通过迭代器的 next(..))。现在我将调用 runGenerator(..)。

// run (async) a generator to completion
// Note: simplified approach: no error handling here
function runGenerator(g) {
    var it = g(), ret;

    // asynchronously iterate over generator
    (function iterate(val){
        ret = it.next( val );

        if (!ret.done) {
            // poor man's "is it a promise?" test
            if ("then" in ret.value) {
                // wait on the promise
                ret.value.then( iterate );
            }
            // immediate value: just send right back in
            else {
                // avoid synchronous recursion
                setTimeout( function(){
                    iterate( ret.value );
                }, 0 );
            }
        }
    })();
}

要注意的主要事项:

  1. 我们自动初始化 generator (创建它的迭代器),然后我们异步地将其运行到完成状态(done: true)。
  2. 我们会得到一个 promise 对象(也就是 yield 点,it.next() 调用的返回值)。如果是这样,我们通过在 promise 对象上注册 then() 方法等待它的完成。
  3. 如果值是立即(也就是 non-promise )返回的,我们只需要将该值返回 generator,以便它立即继续执行。

现在,我们如何使用它?

runGenerator( function *main(){
    var result1 = yield request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = yield request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
} );

Bam!! 等等。。。这是与之前完全相同的 generator 函数代码?是的,这就是 generator 的力量。事实上,我们现在正在生成 promise 对象,yield 返回它们,并恢复 generator 的执行到执行完成 -- 所有这些都是“隐藏”来实现细节!它不是真的隐藏来,它只是与业务代码(我们 generator 中的控制流代码)分离了。

通过等待 yield 的 promise,并将它的完成返回值传回 it.next() ,然后 result1 = yield request("http://some.url.1") 会获得与之前完全相同的值。

但是现在我们正在使用 promise 来管理 generator 代码的异步部分,通过回调函数解决所有的 inversion/trust 问题。我们通过使用 generator + promise ,“免费的” 解决了上面提到的那些问题。

我们现在拥有内置的错误处理,而且易扩展。我们没有在 runGrenerator() 中展示这些处理,但是在 promise 中监听错误是不能的。然后可以通过 it.throw() 抛出 —— 我们可以在 generator 中使用 try .. catch 捕获并处理错误。我们可以得到所有 promise 的 控制/可依赖 性。不用担心,也不用大惊小怪。promise 对象上面有很多强大的抽象,可以自动处理多个“并行”任务的复杂性。

例如,yield Promise.all([ .. ]) 将会处理一个 promise 对象数组,然后生成一个单一的 promise 对象(为了让 generator 处理),等所有的 sub-promises 完成(以任何顺序),然后继续执行。你从 yield 表达式处获得的返回值(当所有 promise 完成后)是一个所有 su-promises 的 responses 组成的数组,元素的顺序按照它们的请求顺序排列(因此,无论完成顺序如何,结果都是可以预测的)。

首先,我们来探讨错误处理:

// assume: `makeAjaxCall(..)` now expects an "error-first style" callback (omitted for brevity)
// assume: `runGenerator(..)` now also handles error handling (omitted for brevity)

function request(url) {
    return new Promise( function(resolve,reject){
        // pass an error-first style callback
        makeAjaxCall( url, function(err,text){
            if (err) reject( err );
            else resolve( text );
        } );
    } );
}

runGenerator( function *main(){
    try {
        var result1 = yield request( "http://some.url.1" );
    }
    catch (err) {
        console.log( "Error: " + err );
        return;
    }
    var data = JSON.parse( result1 );

    try {
        var result2 = yield request( "http://some.url.2?id=" + data.id );
    } catch (err) {
        console.log( "Error: " + err );
        return;
    }
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
} );

如果在请求 URL 时发生 promise rejection(或者任何其他类型的错误/异常),则 promise rejection 将映射到一个 generator error(在 runGenerator 中使用未显示的 it.throw(..)),error 将被 try .. catch 语句所捕获。

现在,我们来看一个更复杂的例子,它使用 promise 来管理更多的异步操作:

function request(url) {
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } )
    // do some post-processing on the returned text
    .then( function(text){
        // did we just get a (redirect) URL back?
        if (/^https?:\/\/.+/.test( text )) {
            // make another sub-request to the new URL
            return request( text );
        }
        // otherwise, assume text is what we expected to get back
        else {
            return text;
        }
    } );
}

runGenerator( function *main(){
    var search_terms = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" ),
        request( "http://some.url.3" )
    ] );

    var search_results = yield request(
        "http://some.url.4?search=" + search_terms.join( "+" )
    );
    var resp = JSON.parse( search_results );

    console.log( "Search results: " + resp.value );
} );

Promise.all([ .. ]) 构建来一个由三个 sub-promise 组成的 promise,这个 promise 是 runGenerator( .. ) 监听的 generator 恢复执行时 yield 返回的主 promise。如果 sub-promise 收到的 response 是另一个重定向的 URL,那么就再次 request(URL) 。

你可以使用 promise 处理任何类型的 capability/complexity 异步请求,通过在 generator 中的 yield 点使用 promise,你还可以像写同步逻辑代码一样去处理异步业务。Generator 和 Promise 真是世界上最好的两个东西了。

runGenerator( .. ): Library Utility

我们必须定义自己的 runGenerator( .. ) 函数,以便能够启用和平滑的发挥出 generator + promise 的强大之处。 为了简洁起见,我们省略了程序的实现细节,和更多的错误处理的相关细节。

但是,你不想自己编写 runGenerator( .. ) 吗?

我不这么想。

各种 promise/async 库提供了这样的实用程序,我们这里就会不讲解了,但是你可以看看 Q.spawn( .. )co( .. )等等这样的库。

然而,我将简要介绍我自己库的 utility: asynquence runner( .. ) 插件,因为我认为它提供了独一无二的功能。想要了解详情可以阅读我的这篇文章

首先,asynquence 提供的 utilities 会优先处理上述代码片段中的错误回调。

function request(url) {
    return ASQ( function(done){
        // pass an error-first style callback
        makeAjaxCall( url, done.errfcb );
    } );
}

这样很不错,对不对?

接着,asynquence 的 runner( .. ) 插件执行异步序列(一系列异步操作)中的 generator,你可以从前面的步骤传递消息,generator 也可以传回消息,在下一步中,所有的 errors 都会自动如你所期的一般去传递。

// first call `getSomeValues()` which produces a sequence/promise,
// then chain off that sequence for more async steps
getSomeValues()

// now use a generator to process the retrieved values
.runner( function*(token){
    // token.messages will be prefilled with any messages
    // from the previous step
    var value1 = token.messages[0];
    var value2 = token.messages[1];
    var value3 = token.messages[2];

    // make all 3 Ajax requests in parallel, wait for
    // all of them to finish (in whatever order)
    // Note: `ASQ().all(..)` is like `Promise.all(..)`
    var msgs = yield ASQ().all(
        request( "http://some.url.1?v=" + value1 ),
        request( "http://some.url.2?v=" + value2 ),
        request( "http://some.url.3?v=" + value3 )
    );

    // send this message onto the next step
    yield (msgs[0] + msgs[1] + msgs[2]);
} )

// now, send the final result of previous generator
// off to another request
.seq( function(msg){
    return request( "http://some.url.4?msg=" + msg );
} )

// now we're finally all done!
.val( function(result){
    console.log( result ); // success, all done!
} )

// or, we had some error!
.or( function(err) {
    console.log( "Error: " + err );
} );

asynquence 的 runner( .. ) utility 接收(可选)调用 generator 时的参数,参数来自之前的步骤中,并且可以在 generator 中通过 token.message 数组访问到。

然后,类似上面使用的 runGenerator( .. ) 所演示的,runner( .. ) 监听 yield 返回的 promise 对象或一系列异步操作(这个例子中是 ASQ().all( .. ) “并行”步骤),并在 gennerator 中等待它的完成。

当 generator 完成时,yield 的最终值传递到序列中的下一步。

而且,如果任何错误发生在这个序列中,即使再 generator 内部,这个错误也会冒泡到注册的错误处理器那里。

asynquence 试图将 promises 和 generators 混合配合使用的尽可能的简单。你可以基于 promise 逻辑处理流程进行扩展,只要你认为合适就 OK 了。

ES7 async

现在 ES7 有一个提议,从时间线上看起来相当可能被接受,这个提议创造另一类函数:异步函数,它就像 generator 一样,自动包装一个 utility ,如 runGenerator() (或者 asynquence 的 runner())。这样,你可以传递出 promise 对象,并且异步函数自动将其连接起来,以便在完成时恢复自身执行(甚至不需要迭代器)。

他可能看起来像这样:

async function main() {
    var result1 = await request( "http://some.url.1" );
    var data = JSON.parse( result1 );

    var result2 = await request( "http://some.url.2?id=" + data.id );
    var resp = JSON.parse( result2 );
    console.log( "The value you asked for: " + resp.value );
}

main();

正如你所看到的,async function 可以被直接调用(就像 main()),不需要像 runGenerator() 和 ASQ().runner() 那样去包装它。在函数内部,不是使用 yield,而是使用 await (另一个新的关键字)告诉异步函数等 promise 完成后再继续执行。

基本上,我们将拥有大量的类似包装 generators 库提供的功能,但是是直接有原生语法提供的。

很酷,对不对?

与此同时,像 asynquence 给我们 runner 函数能够更加容易地利用异步的 generators。

Summary

简单地说:generator + yield promise 的组合可以让你像写同步逻辑的代码一样去处理异步逻辑的代码。使用一个简单的包装器(很多库已经提供的),我们能够自动执行我们 generator 函数到完成状态,包括像处理同步代码一样的错误处理机制。

在 ES7 的领地里,我们可能将会有原生的 异步函数 可以使用,这样我们可以不用库提供的实现去处理这些逻辑(至少一般情况下是不需要的)。

JavaScript 异步的未来是光明的,而且只会越来越光明!我们必须穿过阴影。

但这并不止于此。我们还要讨论最后一个部分:

如果你将两个或更多的 generator 绑在一起,让它们“并行”地独立运行,并让它们在进行时传递回来消息?这将会是一种强大的能力,对不对?这种模式称为“CSP”(通信顺序过程)。我们将在下一篇文章中探索和解锁 CSP 的力量。留心了!

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值