【译】ES6 生成器 - 3. ES6 生成器异步编程

达芬奇密码2018-06-25 18:23

原文:https://davidwalsh.name/async-generators

现在你已经了解了 ES6 生成器的基础知识和相关特性,是时候让我们来改进下现实中的代码了。

生成器的主要能力在于提供了一个单线程的类似同步模式的代码风格,同时允许将异步操作作为实现细节进行隐藏。这使得我们可以通过非常自然的方式来查看我们的代码流程,而不必面对异步的语法和相关问题。

换句话说,我们获得了一个不错的功能与关注点的分离,这是通过将对数据的消费(生成器逻辑)与异步获取数据的实现细节(next(..))隔离开得到的。

结果呢?我们得到了异步代码的所有能力,以及阅读和维护同步(看起来)代码的容易性。

那我们如何来实现呢?

最简单的异步

简单来说,生成器并不要求程序具有任何额外的东西来获得控制异步的能力。

例如,假设已经有如下的代码。

function makeAjaxCall(url,cb) {
    // 执行 Ajax
    // 完成后调用 `cb(result)`
}

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 );
    });
} );

通过生成器(不增加任何修饰)来实现上面的程序,可以这样做:

function request(url) {
    // 这是我们因此异步过程的地方,与生成器的主要代码分开,
    // `it.next(..)` 是生成器继续下一个迭代的调用
    makeAjaxCall( url, function(response){
        it.next( response );
    } );
    // 注意:这里什么也不返回!
}

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(); // 开始执行所有的代码

下面来说明上述代码是如何工作的。

帮助函数 request(..) 只是将原来的 makeAjaxCall(..) 函数进行了包装,确保其回调函数可以调用生成器的迭代器的 next(..) 方法。

对于 request("..") 调用,可以注意到它并没有 任何返回值(换句话说,返回值是 undefined)。这里 yield undefined,没什么大不了,不过文章后面要介绍的东西就比较重要了。

接着调用了 yield ..(值为 undefined),这里什么也没做,只是让生成器在这里暂停执行了而已。生成器会一直等待,直到通过调用 it.next(..) 使其继续执行,而这在 Ajax 调用完成时就会发生。

但是 yield .. 表达式的结果发生什么了呢?我们将表达式的结果赋值给了遍历 result1。变量是如何在第一个 Ajax 调用里面获得这个值的呢?

这是由于在 Ajax 的回调函数中调用 it.next(..) 时,将 Ajax 的响应数据传给了生成器,也就是说值被传给了生成器暂停的位置,也就是 result1 = yield .. 语句!

这的确很酷,很强大。本质上来说,result1 = yield request(..) 在 请求数据,但是过程是(几乎)完全隐藏的 —— 至少在这里不必关心 —— 而具体实现这一步骤的过程是异步的。通过 yield 的暂停实现了异步的过程,并且将使生成器继续的能力分配给了其他函数,从而使得代码实现了一个同步(看起来)数据请求

第二个 result2 = yield result(..) 语句也是完全相同的过程:它隐含地进行了暂停和继续,并且得到了需要的数据,但我们在编码时不必关心异步的细节。

当然,出现了 yield,这是一个有关某些魔法可能会发生的微妙的提示。但是相对于嵌套回调函数(或者说 promise 链带来 的 API 开销),yield 只是很小的语法信号或者说开销。

注意我提到“可能会发生”。这其实是生成器本身和内部非常强大的东西。上面的程序的确产生了异步的 Ajax 调用,但如果它没有这样做呢?如果我们之后修改程序,使其改为使用之前(或者预先获取的)Ajax 响应结果呢?或者应用程序的 URL 路由器使得在某些情况下可以立即响应 Ajax 请求,而不必从服务器获取数据呢?

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

var cache = {};

function request(url) {
    if (cache[url]) {
        // “延迟”缓存的响应直到当前线程执行完成
        setTimeout( function(){
            it.next( cache[url] );
        }, 0 );
    }
    else {
        makeAjaxCall( url, function(resp){
            cache[url] = resp;
            it.next( resp );
        } );
    }
}

注意:这里有一个微妙的小技巧,就是在缓存数据已存在时需要通过 setTimeout(..0) 来产生延迟。如果我们立即调用 it.next(..),这里会产生一个错误,因为(这就是小技巧的部分)从技术上来说,生成器还没有进入暂停状态。函数调用 request(..) 需要首先被执行,然后 yield 暂停。所以,我们不能立即在 request(..) 中调用 it.next(..),因为此时生成器正在运行中(yield 还没有执行)。不过我们可以“稍后”调用it.next(..),在当前执行线程完成后立即执行,这也就是 setTimeout(..0) 实现的“hack”。后文给出了此问题更好的答案。

现在,主生成器的代码仍旧是这样:

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

看到没有!?生成器逻辑(也就是流程控制)相比没有缓存控制的版本并没有任何改变。

*main() 中的代码仍然在请求数据,并且暂停直到数据返回然后继续执行。在当前场景中,“暂停”可能会很长(产生了一个真实的服务器请求,差不多300-800ms),也可能很快(setTimeout(..0) 带来的延迟)。但是流程控制并不关心。

这就是将抽象异步过程实现细节的强大之处。

更好的异步

上面的方式对于小规模的异步的生成器已经够用了。但是它会很快遇到瓶颈,所以我们需要使用更强大的异步机制,以便处理更复杂的情况。什么机制?Promise

如果你对 ES6 Promise 不了解,我写过几篇介绍文章,可以去读一读,等你回来我们再继续。

前面的 Ajax 代码示例和初始的嵌套回调函数示例,有相同的有关控制反转的问题。以下是目前的一些问题:

  1. 没有清晰的异常处理的模式。上一篇文章中提到,我们可以检测 Ajax 调用的错误,通过 it.throw(..) 将其传递给生成器,然后在生成器逻辑内部通过 try..catch 来处理它。但这只是更多的手工处理的工作,而且这些代码在我们的程序中可能无法重用。

  2. 如果 makeAjaxCall(..) 工具不在我们的控制之下,并且多次调用回调函数,或者同时调用了成功和失败的回调函数,等等,那么生成器就乱七八糟的了(未捕获的异常,非期待的值,等等)。处理和避免这样的问题意味着重复的工作,而且很可能无法移植。

  3. 我们经常需要“并行”处理不止一个任务(例如,两个同时发起的 Ajax 调用)。由于生成器的 yield 语句是单个暂停点,两个或多个无法同时执行 —— 它们只能按顺序每次执行一个。所以,并不清楚应该如何通过一个 yield 处理多个任务,而且能不增加更多的代码。

可以看到,这些问题都可以解决,但谁想每次都重复做这些事情呢。我们需要一个强大的模式,能够为基于生成器的异步编程提供可信赖、可重用的解决方案

什么模式呢?抛出 promise,让它们来继续生成器的执行。

上面我们用过 yield request(..),并且 request(..) 没有返回任何值,yield undefined 够高效吗?

让我们来把代码稍微修改下。将 request(..) 改为基于 promise 的,从而能够返回一个 promise,这样 yield 返回的实际是一个 promise(而不是 undefined)。

function request(url) {
    // 注意:现在返回一个 promise!
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } );
}

request(..) 现在构造了一个 promise(并且在 Ajax 调用完成后将其设为解决的(resolved)),将 promise 返回,从而可以被 yield 抛出。然后呢?

我们需要一个工具来控制生成器的迭代器,它会收集执行生成器得到的 promise(通过 next(..))。现在我会称呼这个工具为 runGenerator(..)

// (异步)运行一个生成器直到结束
// 注意:简化的方法:不进行异常处理
function runGenerator(g) {
    var it = g(), ret;

    // 异步地迭代生成器
    (function iterate(val){
        ret = it.next( val );

        if (!ret.done) {
            // 穷人版本的“这是一个 promise 吗?”检查
            if ("then" in ret.value) {
                // 等待 promise
                ret.value.then( iterate );
            }
            // 立即获得的值:返回即可
            else {
                // 避免同步执行
                setTimeout( function(){
                    iterate( ret.value );
                }, 0 );
            }
        }
    })();
}

主要需要注意的有:

  1. 自动初始化生成器(创建对应的 it 迭代器),然后异步执行 it 直到完成(done:true)。

  2. 检查是否返回了 promise(也就是每个 it.next(..) 调用的返回值),如果是,则通过向 promise 注册 then(..) 来等待 promise 完成。

  3. 如果有立即得到的(非 pormise)值返回,则将其传回生成器使得生成器可以继续执行。

现在,怎么使用它呢?

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 );
} );

等等...这不就是之前的完全相同的生成器代码吗?是的。再一次,我们看到了生成器的强大之处。现在我们创建 promsie,将其 yield 到外部,等待其完成后继续执行生成器 —— 所有这些都是“隐藏的”实现细节!这并非真的隐藏,而是与消费代码(生成器的流程控制)相分离。

通过等待 yield 返回的 promise,并在其完成后将数据返回给 it.next(..)result1 = yield request(..) 语句像之前一样获得了值。

不过现在我们采用 promise 来管理生成器代码的异步部分,从而相对于只是用回调函数的方式,解决了倒置和信赖的问题。以上的解决方案都是通过生成器 + promise “免费”获得的:

  1. 我们现在有了可以简单上手的内置的异常处理。尽管在上面的 runGenerator(..) 中没有展示,但从 promise 监听错误并将其通过 it.throw(..) 进行传递并不复杂 —— 然后可以通过 try..catch 在生成器代码内部捕获并处理这些错误。

  2. 我们获得了 promise 提供的控制与信任。不必担心,不必大惊小怪。

  3. promise 还提供了很多强大的抽象机制用于解决多个“并行”任务的复杂性,等等。

    例如,yield Promise.all([ .. ]) 可以用于“并行”处理一组 promise,作为单个 promise(交给生成器处理)返回,它会等到多有的子 promise 任务完成后才会继续。从 yield 表达式(当 promise 完成的时候)返回的是这一组 promsie 对应的响应数据,按照请求的顺序排列(不管实际完成的顺序如何)。

首先,我们来看看异常处理:

// 假设:`makeAjaxCall(..)`现在接收的是“异常优先风格”的回调函数(略)
// 假设:`runGenerator(..)`现在支持异常处理(略)

function request(url) {
    return new Promise( function(resolve,reject){
        // 传入“异常优先风格”的回调函数
        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 );
} );

如果一个 promise 拒绝(或者任意形式的错误/异常)在获取 URL 的过程中发生,promise 的拒绝会被映射为生成器的错误(通过 —— 没有展示 —— runGenerator(..) 中的it.throw(..)),然后被 try..catch 语句捕获。

接下来,我们看一个使用 promsie 来处理更复杂的异步的例子:

function request(url) {
    return new Promise( function(resolve,reject){
        makeAjaxCall( url, resolve );
    } )
    // 基于返回的文本作进一步的处理
    .then( function(text){
        // 是不是获得了一个(重定向)URL?
        if (/^https?:\/\/.+/.test( text )) {
            // 向新的 URL 发起另一次请求
            return request( text );
        }
        // 否则,假设文本就是期望返回的数据
        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([ .. ]) 构造了一个等待三个内部 promise 完成的 promise,它是 yield 给 runGenerator(..) 来监听从而继续执行生成器的 promise。内部的 promise 可以接收一个看起来像重定向 URL 的响应,然后将另一个内部 promise 链到新的地址上。更多有关 promise 链的内容,可以阅读这篇文章

所有 promise 可以用于异步模式的能力,都可以通过生成器中 yield promise(或者是 promise 的 promise 的 promise...)来以类似同步的代码方式获得。这是两个世界最好的地方了(译注:指生成器和 promise的结合)。

runGenerator(..):工具库

上面我们通过定义自己的 runGenerator(..) 来获得这种生成器 + promise 的结合。我们甚至略去(为了简短)这个工具的完整实现,以及异常处理相关的各种细节。

不过,你肯定不想自己来写 runGenerator(..) 不是吗?

我想也是。

各种 promise 或异步库已经提供了类似的工具。我不会在这里一一介绍,不过你可以看一下 Q.spawn(..)co(..) 库,等等。

接下来我要简要介绍的是我自己的工具库:asynquence 的 runner(..) 插件,因为我觉得它提供了一些独特的能力。如果想了解更多,可以看下这篇文章

首先,asynquence 为自动处理上面的“错误优先风格”提供了工具:

function request(url) {
    return ASQ( function(done){
        // 传入一个错误优先风格的回调函数
        makeAjaxCall( url, done.errfcb );
    } );
}

这样更好些,不是吗?

接下来,asynquence 的 runner(..) 插件在一个 asynquence 队列(异步的步骤序列)中消费了一个生成器,可以从上一步传入数据,生成器可以向下传递数据至再下一个步骤,所有的错误会被自动收集:

// 首先调用 `getSomeValues()` 来创建一个 sequence/promise,
// 然后为该序列链接更多的异步过程
getSomeValues()

// 现在通过一个生成器来处理获取到的值
.runner( function*(token){
    // token.messages 将会被上一步的数据填充
    var value1 = token.messages[0];
    var value2 = token.messages[1];
    var value3 = token.messages[2];

    // 并行产生 3 个 Ajax 请求,等待所有请求完成
    // 注意:`ASQ().all(..)` 类似 `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 )
    );

    // 将数据发送到下一步骤
    yield (msgs[0] + msgs[1] + msgs[2]);
} )

// 将上一个生成器的最后的结果数据用于产生新的 Ajax 请求
.seq( function(msg){
    return request( "http://some.url.4?msg=" + msg );
} )

// 现在所有的都完成了!
.val( function(result){
    console.log( result ); // 成功,所有的都已完成!
} )

// 或者,有错误!
.or( function(err) {
    console.log( "Error: " + err );
} );

asynquence 的 runner(..) 接收(可选的)数据来开始执行生成器,这些数据来自于队列的上一步,然后数据可以通过 token.message 数组在生成器内部使用。

然后,和上面演示过的 runGenerator(..) 那样,runner(..) 监听 yield 返回的 promise 或异步队列(ASQ().all(..)),然后等待其完成后继续执行生成器。

当生成器执行完成,最终的值通过 yield 提供给队列的下一个步骤使用。

此外,如果有错误产生,即使在生成器内部,也会冒泡给 or(..) 这里注册的错误处理器。

asynquence 尝试将 promise 和生成器进行混合,并且尽量让其简单。你可以自由地将生成器流程和基于 promise 的队列流程一起使用。

ES7 async

在 ES7 中有一个很可能被采纳的提议,是引入另一个新的函数类型:async function,它会像是一个生成器自动包装在一个类似 runGenerator(..)(或者 asyncquence 的 runner(..))中。这种方式下,你可以返回 promise,然后 async function 自动将其绑定,并在其完成后继续自身的执行(甚至不需要迭代器!)。

它很可能是这样的:

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()),不需要进行包装。在内部,不使用 yield,而是 await(另一个新的关键字)来告知 async function 等待 promise 完成后再继续。

基本上,会提供大多数由工具包装的生成器的功能,但是直接由原生语法提供支持

很酷,是不是!?

同时,像 asynquence 这样的工具库提供了运行工具函数,从而可以容易地使用异步的生成器!

总结

简单地将生成器 + yield promsie 结合起来,就可以获得两个世界的最好的部分,也就是强大的功能和优雅的同步的(看起来)异步流程控制能力。使用简单的包装工具(这个很多库已经提供),我们可以自动执行生成器,并且支持健壮的和同步的(看起来)异常处理!

而在 ES7+ 领域,我们很可能会看到 async function 在不需要额外工具库的情况下支持这些特性(至少是基础的情况)!

JavaScript 异步编程的未来是光明的,而且只会更加光明!我都得戴个墨镜了。

但这并没有结束。还有最后一个领域需要探索:

如果将 2 个或更多的生成器组合起来,使得它们可以“并行”地单独执行,同时能够在执行过程中彼此传递数据呢?这会是非常强大的能力,不是吗!?!这个模式叫作“CSP”(通信顺序进程,communicating sequential processes)。下一篇文章中我们将发掘并解锁 CSP 的功能。别眨眼哦!

译注

对于 ES6 原生 promise 以及作者文中提到的 asynquence 库并不熟悉,如有错漏,请指正。

另外,文章翻译过程中,逐渐只追求“达意”,与原文可能并没有完全做到词句对应,对此介意且英文阅读能力尚可的同学推荐阅读原文。

相关阅读:

【译】从 JS 学习 Lua

【译】ES6 生成器 - 1. ES6 生成器基础

【译】ES6 生成器 - 2. 深入理解 ES6 生成器

【译】ES6 生成器 - 4. ES6 生成器与并发

本文来自网易实践者社区,经作者汤康兴授权发布。