- 1. 最简单的异步
- 2. 更好的异步
- 3. 使用其他的 Promise 类库
- 4. ES7 async
- 5. 总结
译注1:此文带着自己的理解,不完全按原文翻译。原文地址
译注2:原文晦涩难懂的地方,尽力做了注释或修饰,方便大家理解。错误之处欢迎各位校验指正。
生成器提供了同步方式编写的代码风格,这就允许我们隐藏异步的实现细节。
我们就可以用一种非常自然的方式来表达程序的执行流程,避免了同时处理异步代码的语法和陷阱。
换句话说,我们利用生成器从内到外、从外到内双向传值的特点,将不同的值的处理交给了不同的生成器逻辑,只需要关心获取到特定的值进行某种操作,而无需关心特定的值如何产生(通过netx()
将值的产生逻辑委托出去)。
这么一来,异步处理的优点以及易读的代码结合到一起,就加强了我们程序的可维护性。
最简单的异步
举个栗子,假定我们已经有了以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13
| function makeAjaxCall(url,cb) { } 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( "我们请求到的数据是: " + resp.value ); }); } );
|
使用简单的生成器来表达的话,就像这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function request(url) { 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()
这个工具函数只是将我们的异步请求数据的代码进行了封装,需要注意的是在回调函数中调用了生成器的 next()
方法。
当我们使用 var it = main();
创建了一个迭代器后,紧接着使用 it.next();
开始迭代,这时候遇到第一个 yield
中断了生成器,转而执行 request( "http://some.url.1" )
当 request( "http://some.url.1" )
异步获取到数据后,在回调函数中调用 it.next(response)
将 response
传回给生成器刚刚中断的地方,生成器将继续迭代。
这里的亮点就是,我们在生成器中无需关心异步请求的数据如何获取,我们只知道调用了 request()
后,当需要的数据获取到了,就会通知生成器继续迭代。
这么一来在生成器中我们使用同步方式的编写风格,其实我们获取到了异步数据!
同理,当我们继续调用 it.next()
时,会遇到第二个 yield
中断迭代,发出第二个请求 yield request( "http://some.url.2?id=" + data.id )
异步获取到数据后再恢复迭代,我们依旧不用关心异步获取数据的细节了,多爽!
以上这段代码中,request()
请求的是异步 AJAX 请求,但如果我们后续改变程序给 AJAX 设置了缓存了,获取数据会先从缓存中获取,这时候没有执行真正的 AJAX 请求就不能在回调函数中调用 it.next(response)
来恢复生成器的中断了啊!
没关系,我们可以使用一个小技巧来解决这个问题,举个栗子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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 ); } ); } }
|
看,当我们给我们的程序添加了 AJAX 缓存机制甚至其他异步操作的优化时,我们只改变了 request()
这个工具函数的逻辑,而无需改动调用这个工具函数获取数据的生成器:
1 2 3
| var result1 = yield request( "http://some.url.1" ); var data = JSON.parse( result1 ); ..
|
在生成器中,我们还是像以前一样调用 request()
就能获取到需要的异步数据,无需关心获取数据的细节实现!
这就是将异步操作当做一个细节实现抽象出来后展现出的魔力了!
更好的异步
上面介绍的异步方案对于简单的异步生成器来说工作良好,但用途有限,我们需要一个更强大的异步方案:使用 Promises.
如果你对 ES6 Promises 有迷惑的话,我建议你先读 我写的介绍 Promises 的文章
我们的代码目前有个严重的问题:回调多了会产生多重嵌套(即回调地狱)。
此外,我们目前还缺乏的东西有:
清晰的错误处理逻辑。我们使用 AJAX 的回调可能会检测到一个错误,然后使用 it.throw()
将错误传回给生成器,在生成器中则使用 try..catch
来捕获错误。
一来我们需要猜测我们可能发生错误且手动添加对应的错误处理函数,二来我们的错误处理代码没法重复使用。
如果 makeAjaxCall()
函数不受我们控制,调用了多次回调的话,也会多次触发回调中的 it.next()
,生成器就会变得非常混乱。
处理和阻止这种问题需要大量的手动工作,也非常不方便。
有时候我们需要 『并行地』执行不只一个任务(比如同时触发两个 AJAX 请求)。而生成器中的 yield
并不支持两个或多个同时进行。
以上这些问题都可以用手动编写代码的方式来解决,但谁会想每次都重新编写类似的重复的代码呢?
我们需要一个更好的可信任、可重复使用的方案来支持我们基于生成器编写异步的代码。
怎么实现?使用 Promises !
我们将原来的代码加入 Promises 的特性:
1 2 3 4 5 6
| function request(url) { return new Promise( function(resolve,reject){ makeAjaxCall( url, resolve ); } ); }
|
request()
函数中创建了一个 promise 实例,一旦 AJAX 请求完成,这个实例将会被 resolved
。
我们接着将这个实例返回,这样它就能够被 yield
了。
接下来我们需要一个工具来控制我们生成器的迭代器,接收返回的 promise 实例,然后再通过 next()
来恢复生成器的中断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| function runGenerator(g) { var it = g(), ret; (function iterate(val){ ret = it.next( val ); if (!ret.done) { if ("then" in ret.value) { ret.value.then( iterate ); } else { setTimeout( function(){ iterate( ret.value ); }, 0 ); } } })(); }
|
注意:我们在 runGenerator()
中先生成了一个迭代器 var it = g()
,然后我们会执行这个迭代器直到它完成(done: true
)。
接着我们就可以使用这个 runGenerator()
了:
1 2 3 4 5 6 7 8
| 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( "你请求的数据是: " + resp.value ); } );
|
我们通过生成不同的 promise 实例,分别对这些实例进行 yield
,不同的实例等待自己的 promise 被 resolve
后再执行对应的操作。
这么一来,我们只需要同时生成不同的 promise 实例,就可以『并行地』执行不只一个任务(比如同时触发两个 AJAX 请求)了。
既然我们使用了 promises 来管理生成器中处理异步的代码,我们就解决了只有在回调中才能实现的功能,这就避免了回调嵌套了。
使用 Generotos + Promises 的优点是:
我们可以使用内建的错误处理机制。虽然这没有在上面的代码片段中展示出来,但其实很简单:
监听 promise 中的错误,使用 it.throw()
把错误抛出,然后在生成器中使用 try..catch
进行捕获和处理即可。
我们可以使用到 Promises 提供的 control/trustability 特性。
Promises 提供了大量处理多并行且复杂的任务的特性。
举个栗子:yield Promise.all([ .. ])
方法接收一组 promise 组成的数组作为参数,然后 yield
一个 promise 提供给生成器处理,这个 promise 会等待数组里所有 promise 完成。当我们得到 yield
后的 promise 时,说明传进去的数组中的所有 promise 都已经完成,且是按照他们被传入的顺序完成的。
首先,我们体验一下错误处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| 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( "你请求的数据是: " + resp.value ); } );
|
如果一个 promise 被 reject
或遇到其他错误的话,将使用 it.throw()
(代码片段中没有展示出来)抛出一个生成器的错误,这个错误能被 try..catch
捕获。
再举个使用 Promises 管理更复杂的异步操作的栗子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function request(url) { return new Promise( function(resolve,reject){ makeAjaxCall( url, resolve ); } ) .then( function(text){ if (/^https?:\/\/.+/.test( text )) { 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 的完成,这个 promise 会被 yield
给 runGenerator()
生成器,然后这个生成器就可以恢复迭代。
使用其他的 Promise 类库
在上面的代码片段中,我们自己编写了 runGenerator()
函数来提供 Generators + Promises 的功能,其实我们也可以使用社区里优秀的类库,举几个栗子: Q 、Co、 asynquence 等
接下来我会简要地介绍下 asynquence 中的 runner插件 。如果你感兴趣的话,可以阅读我写的两篇深入理解 asynquence 的博文。
首先,asynquence 提供了回调函数中错误为第一参数的编写风格(error-first style),举个栗子:
1 2 3 4 5 6
| function request(url) { return ASQ( function(done){ makeAjaxCall( url, done.errfcb ); } ); }
|
接着,asynquence 的 runner 插件会接收一个生成器作为参数,这个生成器可以处理传入的数据处理后再传出来,而所有的的错误会自动地传递:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| getSomeValues() .runner( function*(token){ var value1 = token.messages[0]; var value2 = token.messages[1]; var value3 = token.messages[2]; 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]); } ) .seq( function(msg){ return request( "http://some.url.4?msg=" + msg ); } ) .val( function(result){ console.log( result ); } ) .or( function(err) { console.log( "Error: " + err ); } );
|
ES7 async
在 ES7 草案中有一个提议,建议采用另一种新的 async
函数类型。
使用这种函数,我们可以向外部发出 promises,然后使用 async
函数自动地将这些 promises 连接起来,当 promises 完成的时候,就会恢复 async
函数自己的中断(不需要在繁杂的迭代器中手动恢复)。
这个提议如果被采纳的话,可能会像这样:
1 2 3 4 5 6 7 8 9 10
| 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
声明了这种异步函数类型,然后使用 main()
直接调用这个函数,而不用像使用 runGenerator()
或 ASQ().runner()
一样进行包装。
此外,我们没有使用 yield
关键字,而是使用了新的 await
关键字来声明等待 await
后面的 promise 的完成。
总结
一言以蔽之:Generators + Promises 的组合,强大且优雅地用同步编码风格实现了复杂的异步控制操作。
使用一些简单的工具类库,比如上面提到的 Q 、Co、 asynquence 等,我们可以更方便地实现这些操作。
可以预见在不久的将来,当 ES7+ 发布的时候,我们使用 async
函数甚至可以无需使用一些类库支撑就可以实现原生的异步生成器了!
(译注:本文是第三篇文章,其实还有最后一篇是讲述并发式生成器的实现思路,涉及到 CSP 的相关概念,原文中引用了比较多的东西,读起来比较晦涩难懂,怕翻译出来与原文作者想要表达的东西相差太远,就先放一边了,感兴趣的可以直接查看原文。
欢迎大牛接力)