ES6 Generators的异步应用
ES6 Generators系列:
通过前面两篇文章,我们已经对ES6 generators有了一些初步的了解,是时候来看看如何在实际应用中发挥它的作用了。
Generators最主要的特点就是单线程执行,同步风格的代码编写,同时又允许你将代码的异步特性隐藏在程序的实现细节中。这使得我们可以用非常自然的方式来表达程序或代码的流程,而不用同时还要兼顾如何编写异步代码。
也就是说,通过generator函数,我们将程序具体的实现细节从异步代码中抽离出来(通过next(..)来遍历generator函数),从而很好地实现了功能和关注点的分离。
其结果就是代码易于阅读和维护,在编写上具有同步风格,但却支持异步特性。那如何才能做到这一点呢?
最简单的异步
一个最简单的例子,generator函数内部不需要任何异步执行代码即可完成整个异步过程的调用。
假设你有下面这段代码:
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 ); }); } );
如果使用generator函数来实现上面代码的逻辑:
function request(url) { // 这里的异步调用被隐藏起来了, // 通过it.next(..)方法对generator函数进行迭代, // 从而实现了异步调用与main方法之间的分离 makeAjaxCall( url, function(response){ it.next( response ); } ); // 注意:这里没有return语句! } 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(..)的封装,确保回调能够调用generator函数的next(..)方法。请注意request(..)方法中没有return语句(或者说返回了一个undefined值),后面我们会讲到为什么要这么做。
Main函数的第一行,由于request(..)方法没有任何返回值,所以这里的yield request(..)表达式不会接收任何值进行计算,仅仅暂停了main函数的运行,直到makeAjaxCall(..)在ajax的回调中执行it.next(..)方法,然后恢复main函数的运行。那这里yield表达式的结果到底是什么呢?我们将什么赋值给了变量result1?在Ajax的回调中,it.next(..)方法将Ajax请求的返回值传入,这个值会被yield表达式返回给变量result1!
是不是很酷!这里,result1 = yield request(..)事实上就是为了得到ajax的返回结果,只不过这种写法将回调隐藏起来了,我们完全不用担心,因为其中具体的执行步骤就是异步调用。通过yield表达式的暂停功能,我们将程序的异步调用隐藏起来,然后在另一个函数(ajax的回调)中恢复对generator函数的运行,整个过程使得我们的main函数的代码看起来就像是在同步执行一样。
语句result2 = yield result(..)的执行过程与上面一样。代码执行过程中,有关generator函数的暂停和恢复完全是透明的,程序最终将我们想要的结果返回回来,而所有的这些都不需要我们将注意力放在异步代码的编写上。
当然,代码中少不了yield关键字,这里暗示着可能会有一个异步调用。不过这和地狱般的嵌套回调(或者promise链)比起来,代码看起来要清晰很多。
注意上面我说的yield关键字的地方是“可能”会出现一个异步调用,而不是一定会出现。在上面的例子中,程序每次都会去调用一个Ajax的异步请求,但如果我们修改了程序,将之前Ajax响应的结果缓存起来,情况会怎样呢?又或者我们在程序的URL请求路由中加入某些逻辑判断,使其立即就返回Ajax请求的结果,而不是真正地去请求服务器,情况又会怎样呢?
我们将上面的代码改成下面这个版本:
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(..)语句,它会延迟返回缓存中的数据。如果我们直接调用it.next(..)程序会报错,这是因为generator函数目前还不是处于暂停状态。主函数在调用完request(..)之后,generator函数才会处于暂停状态。所以,我们不能在request(..)函数内部立即执行it.next(..),因为此时的generator函数仍然处于运行中(即yield表达式还没有被处理)。不过我们可以稍后再调用it.next(..),setTimeout(..)语句将会在当前执行线程完成后立即执行,也就是在request(..)方法执行完后再执行,这正是我们想要的。下面我们会有更好的解决方案。
现在,我们的main函数的代码依然是这样:
var result1 = yield request( "http://some.url.1" ); var data = JSON.parse( result1 ); ..
瞧!我们的程序从不带缓存的版本改成了带缓存的版本,但是main函数却不用做任何修改。*main()函数依然只是请求一个值,然后暂停运行,直到请求返回一个结果,然后再继续运行。当前程序中,暂停的时间可能会比较长(实际Ajax请求大概会在300-800ms之间),但也可能是0(使用setTimeout(..0)延迟的情况)。无论是哪种情况,我们的主流程是不变的。
这就是将异步过程抽象为实现细节的真正力量!
改进的异步
以上方法仅适用于一些简单异步处理的generator函数,很快你就会发现在大多数实际应用中根本不够用,所以我们需要一个更强大的异步处理机制来匹配generator函数,使其能够发挥更大的作用。这个处理机制是什么呢?答案就是promises. 如果你对ES6 Promises还不了解,可以看看这里的一篇文章: http://blog.getify.com/promises-part-1/
在前面的Ajax示例代码中,无一例外都会遇到嵌套回调的问题(我们称之为回调地狱)。到目前为止我们还有一些东西没有考虑到:
- 有关错误处理。在前一篇文章中我们已经介绍过如何在generator函数中处理错误,我们可以在Ajax的回调中判断是否出错,并通过it.throw(..)方法将错误传递给generator函数,然后在generator函数中使用try..catch语句来处理它。但这无疑会带来许多工作量,而且如果程序中有很多generator函数的话,代码也不容易重用。
- 如果makeAjaxCall(..)函数不在我们的控制范围内,并且它会多次调用回调,或者同时返回success和error等等,那么我们的generator函数将会陷于混乱(未处理的异常,返回意外的值等)。要解决这些问题,你可能需要做很多额外的工作,这显然很不方便。
- 通常我们需要“并行”来处理多个任务(例如同时发起两个Ajax请求),由于generator函数的yield只允许单个暂停,因此两个或多个yield不能同时运行,它们必须按顺序一个一个地运行。所以,在不编写大量额外代码的前提下,很难在generator函数的单个yield中同时处理多个任务。
上面的这些问题都是可以解决的,但是谁都不想每次都面对这些问题然后从头到尾地解决一遍。我们需要一个功能强大的设计模式,能够作为一个可靠的并且可以重用的解决方案,应用到我们的generator函数的异步编程中。这种模式要能够返回一个promises,并且在完成之后恢复generator函数的运行。
回想一下上面代码中的yield request(..)表达式,函数request(..)没有任何返回值,但实际上这里我们是不是可以理解为yield返回了一个undefined呢?
我们将request(..)函数改成基于promises的,这样它会返回一个promise,所以yield表达式的计算结果也是一个promise而不是undefined。
function request(url) { // 注意:现在返回的是一个promise! return new Promise( function(resolve,reject){ makeAjaxCall( url, resolve ); } ); }
现在,request(..)函数会构造一个Promise对象,并在Ajax调用完成之后进行解析,然后返回一个promise给yield表达式。然后呢?我们需要一个函数来控制generator函数的迭代,这个函数会接收所有的这些yield promises然后恢复generator函数的运行(通过next(..)方法)。我们假设这个函数叫runGenerator(..):
// 异步调用一个generator函数直到完成 // 注意:这是最简单的情况,不包含任何错误处理 function runGenerator(g) { var it = g(), ret; // 异步迭代给定的generator函数 (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 ); } } })(); }
几个关键的点:
- 程序会自动初始化generator函数(创建迭代器it),然后异步运行直到完成(done:true)。
- 查看yield是否返回一个promise(通过it.next(..)返回值中的value属性来查看),如果是,则等待promise中的then(..)方法执行完。
- 任何立即执行的代码(非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 ); } );
等等!这不是和本文一开始的那个generator函数一样吗?是的。不过在这个版本中,我们创建了promises并返回给yield,等promise完成之后恢复generator函数继续运行。所有这些操作都“隐藏”在实现细节中!不过不是真正的隐藏,我们只是将它从消费代码(这里指的是我们的generator函数中的流程控制)中分离出去而已。
Yield接受一个promise,然后等待它完成之后返回最终的结果给it.next(..)。通过这种方式,语句result1 = yield request(..)能够得到和之前一样的结果。
现在我们使用promises来管理generator函数中异步调用部分的代码,从而解决了在回调中所遇到的各种问题:
- 拥有内置的错误处理机制。虽然我们并没有在runGenerator(..)函数中显示它,但是从promise监听错误并非难事,一旦监听到错误,我们可以通过it.throw(..)将错误抛出,然后通过try..catch语句捕获和处理这些错误。
- 我们通过promises来控制所有的流程。这一点毋庸置疑。
- 在自动处理各种复杂的“并行”任务方面,promises拥有十分强大的抽象能力。例如,yield Promise.all([..])接收一个“并行”任务的promises数组,然后yield一个单个的promise(返回给generator函数处理),这个单个的promise会等待数组中所有的promises全部处理完之后才会开始,但这些promises的执行顺序无法保证。当所有的promises执行完后,yield表达式会接收到另外一个数组,数组中的值是每个promise返回的结果,按照promise被请求的顺序依次排列。
首先我们来看一下错误处理:
// 假设:`makeAjaxCall(..)` 是“error-first”风格的回调(为了简洁,省略了部分代码) // 假设:`runGenerator(..)` 也具备错误处理的功能(为了简洁,省略了部分代码) function request(url) { return new Promise( function(resolve,reject){ // 传入一个error-first风格的回调函数 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 ); } );
在request(..)函数中,makeAjaxCall(..)如果出错,会返回一个promise的rejection,并最终映射到generator函数的error(在runGenerator(..)函数中通过it.throw(..)方法抛出错误,这部分细节对于消费端来说是透明的),然后在消费端我们通过try..catch语句最终捕获错误。
下面我们来看一下复杂点的使用promises异步调用的情况:
function request(url) { return new Promise( function(resolve,reject){ makeAjaxCall( url, resolve ); } ) // 在ajax调用完之后获取返回值,然后进行下一步操作 .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对象,它接收三个子promises,当所有的子promises都完成之后,将返回的结果通过yield表达式传递给runGenerator(..)函数并恢复运行。在request(..)函数中,每个子promise通过链式操作对response的值进行解析,如果其中包含另一个URL则继续请求这个URL,如果没有则直接返回response的值。有关promise的链式操作可以查看这篇文章: http://blog.getify.com/promises-part-5/#the-chains-that-bind-us
任何复杂的异步处理,你都可以通过在generator函数中使用yield promise来完成(或者promise的promise链式操作),这样代码具有同步风格,看起来更加简洁。这是目前最佳的处理方式。
runGenerator(..)工具库
我们需要定义我们自己的runGenerator(..)工具来实现上面介绍的generator+promises模式。为了简单,我们甚至可以不用实现所有的功能,因为这其中有很多的细节需要处理,例如错误处理的部分。
但是你肯定不想亲自来写runGenerator(..)函数吧?反正我是不想。
其实有很多的开源库提供了promise/async工具,你可以免费使用。这里我就不去一一介绍了,推荐看看Q.spawn(..),co(..)等。
这里我想介绍一下我自己写的一个工具库:asynquence的插件runner。因为我认为和其它工具库比起来,这个插件提供了一些独特的功能。我写过一个系列文章,是有关asynquence的,如果你有兴趣的话可以去读一读。
首先,asynquence提供了一系列的工具来自动处理“error-first”风格的回调函数。看下面的代码:
function request(url) { return ASQ( function(done){ // 这里传入了一个error-first风格的回调函数 - done.errfcb makeAjaxCall( url, done.errfcb ); } ); }
看起来是不是会好很多?
接下来,asynquence的runner(..)插件消费了asynquence序列(异步调用序列)中的generator函数,因此你可以从序列的从上一步中传入消息,然后generator函数可以将这个消息返回,继续传到下一步,并且这其中的任何错误都将自动向上抛出,你不用自己去管理。来看看具体的代码:
// 首先调用`getSomeValues()`创建一个sequence/promise, // 然后将sequence中的async链起来 getSomeValues() // 使用generator函数来处理获取到的values .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 ) ); // 将message发送到下一步 yield (msgs[0] + msgs[1] + msgs[2]); } ) // 现在,将前一个generator函数的最终结果发送给下一个请求 .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(..)从sequence的上一步中接收一个messages(可选)来启动generator,这样在generator中可以访问token.messages数组中的元素。然后,与我们上面演示的runGenerator(..)函数一样,runner(..)负责监听yield promise或者yield asynquence(一个ASQ().all(..)包含了所有并行的步骤),等待完成之后再恢复generator函数的运行。当generator函数运行完之后,最终的结果将会传递给sequence中的下一步。此外,如果这其中有错误发生,包括在generator函数体内产生的错误,都将会向上抛出或者被错误处理程序捕捉到。
Asynquence试图将promises和generator融合到一起,使代码编写变得非常简单。只要你愿意,你可以随意地将任何generator函数与基于promise的sequence联系到一起。
ES7 async
在ES7的计划中,有一个提案非常不错,它创建了另外一种function:async function。有点像generator函数,它会自动包装到一个类似于我们的runGenerator(..)函数(或者asynquence的runner(..)函数)的utility中。这样,就可以自动地发送promises和async function并在它们执行完后恢复运行(甚至都不需要generator函数遍历器了!)。
代码看起来就像这样:
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(另一个新加入的关键字),它会告诉async function等待promise完成之后才会继续运行。将来我们会有更多的generator函数库都支持本地语法。
是不是很酷?
同时,像asynquence runner这样的库一样,它们会给我们在异步generator函数编程方面带来极大的便利。
总结
一句话,generator + yield promise(s)模式功能是如此强大,它们一起使得对同步和异步的流程控制变得行运自如。伴随着使用一些包装库(很多现有的库都已经免费提供了),我们可以自动执行我们的generator函数直到所有的任务全部完成,并且包含了错误处理!
在ES7中,我们很可能将会看到async function这种类型的函数,它使得我们在没有第三方库支持的情况下也可以做到上面说的这些(至少对于一些简单情况来说是可以的)。
JavaScript的异步在未来是光明的,而且只会越来越好!我坚信这一点。
不过还没完,我们还有最后一个东西需要探索:
如果有两个或多个generators函数,如何让它们独立地并行运行,并且各自发送自己的消息呢?这或许需要一些更强大的功能,没错!我们管这种模式叫“CSP”(communicating sequential processes)。我们将在下一篇文章中探讨和揭秘CSP的强大功能。敬请关注!