随着ES6标准逐渐成熟,利用Promise和Generator解决回调地狱问题的话题一直很热门。但是对解决流程控制/回调地狱问题的各种工具认识仍然比较麻烦。最近两天看了很多文章,想出几个场景把各种异步流程方式类比一下,希望能有助于理解他们的实现。

需要说明的是类比只能反映被类比的事物的一个方面,必然有其反映不到的部分,不能完全以类比来理解各种异步控制的本质。所以仅用于简化理解,快速入门,依然需要阅读有深入研究的文章来加深对各种异步流程控制的方法的掌握。

文章中没有严格使用Node.js核心模块的函数名,而是伪造的函数名来方便在各种方式下保持一致性和简化书写。

#同步
从最简单的同步开始。以一段经典的代码为例吧,传入文件名,从文件中读取内容并按JSON格式解析,把其中的一部分内容发还给用户。

1
2
3
4
5
6
7
8
function foo(filename){
var file = readFile(filename);
var json = parseJSON(file);
return json.someContent;
}

var resultA=foo('first.json');
var resultB=foo('second.json');

其中readFileparseJSON显而易见是同步、阻塞的函数。

我们构造这样一个场景,**M(ain)**代表着解释引擎的主线程,坐在柜台前等待用户提交请求。

来了两个用户A和用户B,用户A排在前面。

用户A: 你好,我要读文件first.json
M:好的你等一下我去拿。
【M离开了柜台去拿文件】
M:好的我回来了,这是你要的first.json的内容。然后呢?
用户A:然后把内容按JSON解析一下。
M:你等等我去解析一下。
【M离开了柜台去解析文件内容】
M:好了解析完了,然后呢?
用户A:我要里面的someContent部分。
M:给你。再见。

用户B:你好,我要读文件second.json
…………

可以看到同步阻塞顾名思义,操作一步一步进行,遇到IO操作每一步都要等待,用户A不处理完,用户B也进不来。

#经典回调方式

仍然是这个需求,这次的代码就比较像Node.js里的常见形式了,此处为了简单忽略了err。由于回调套回调,缩进如同金字塔,被称作回调地狱。当然回调地狱远不是一个缩进金字塔那么简单。

1
2
3
4
5
6
7
8
9
10
function foo(filename, cb){
readFile(filename,function(file){
parseJSON(file,function(json){
cb(json.someContent);
});
});
}

foo('first.json',cbA);
foo('second.json',cbB);

依然是来了两个用户A和用户B

用户A:你好,我要读文件first.json,按JSON解析后把里面的someContent寄往cbA发回给我。
M:好的。
【M开始写信,信封上写上文件读取处readFile,拿出一张信纸写到:“读取first.json,_然后内容放进后附信封中寄出_”。】
【M又拿出一个信封,写上JSON解析处parseJSON,信纸上写到“把给你的file按JSON解析,_然后把里面的someContent放到所附信封里寄出_”。】
【M又又拿出一个信封,写上cbA,然后把这个信封放进了刚才的信封里,又把刚才的信封塞进了第一个信封里。】
【M把鼓鼓囊囊的信封扔到邮箱里就不管了】
M:下一位!
用户B:你好……

写几封信的时间比自己跑出去取文件要快的多。异步操作带来的处理速度提升是显而易见的。

但是为了保证业务流程的衔接,信里面就包含了后续一切需要进行的操作,层层包裹。第一封信寄出,M就既无从得知信走到了何处,也无法控制readFileparseJSON是不是如自己所想寄出了给他的信封,有没有私自复印了多寄了一两封。

这才是回调地狱真正危险的地方,缺乏控制。

#Promise

引入Promise之后,很多人就以为能解决回调地狱了,其实不然。在某些场景下只是让缩进好看了一点而已。有些场景下缩进也没法好看,需要书写的回调不仅不会减少还会增多。

1
2
3
4
5
6
7
8
9
function foo(filename,cb){
readFile(filename)
.then(parseJSON(file))
.then(function(json){
cb(json.someContent)});
}

foo('first.json',cbA);
foo('second.json',cbB);

当然这里面的readFileparseJSON已经是Promise化了的。

依然是来了两个用户A和用户B

用户A:你好,我要读文件first.json,按JSON解析后把里面的someContent寄往cbA发回给我。
M:好的。
【M叫来了一个办事员小P】
M:小P你听好,先去找readFile读取first.json,然后把内容给parseJSON让他解析一下,最后把解析的内容里的someContent寄给cb,懂了吗?
P:我办事你放心!
【小P离开了柜台】
M:下一位!
用户B:你好……

小P是一位M信得过的办事员,M相信他能够挨个去找该找的部门,不偷工减料也不毛手毛脚。让小P去办事比寄一封信靠谱的多,M依然能很快回过头来继续应付下一个用户请求。

当然实际场景中虽然写的时候.then一下子连起来写完,并不是真的一下子把内容都交给同一位小P/同一个Promise。更像是一个Promise公司,每个操作进行完后都由一位Promise公司的办事员进行下一步操作。

Promise物如其名,使用Promise重要的就是Promise的可信性,比如Promise的状态不可逆,比如fulfill回调只会被调用一次。Promise并不是回避书写回调,而是用一种更可靠的方式来书写回调。

#Co(Generator)

这里只谈co不谈Generator,是因为Generator并不是为解决异步流程控制而生的,而TJ大神用co把Generator和Thunk/Promise结合在一起提供了新的异步流程控制的方法。

1
2
3
4
5
6
7
8
var foo=co(function*(filename){
var file = yield readFile(filename);
var json = yield parseJSON(file);
return json.someContent;
});

foo('first.json').then(cbA);
foo('second.json').then(cbB);

咦?这代码看起来跟同步的怎么差不多。

这次我们换个方法描述,一样是来了用户A和用户B,但是先从用户A的视角来看这件事情。

用户A: 你好,我要读文件first.json
M:好的你等一下我去拿。
【M离开了柜台】
M:我回来了,这是你要的first.json的内容。然后呢?
用户A:然后把内容按JSON解析一下。
M:你等等我去解析一下。
【M离开了柜台】
M:好了解析完了,然后呢?
用户A:我要里面的someContent部分。
M:给你。再见。

是不是看起来跟同步一模一样?实际上从M的角度看这件事情呢?

用户A: 你好,我要读文件first.json
M:好的你等一下我去拿。
【M离开了柜台】
M:小P来一下!去readFilefirst.json,回来叫我。
P:好的我这就去。
【M转向了另一个柜台窗口】
M:你好。
用户B:你好,我要读文件second.json
…………
…………
P:M,first.json拿回来了。
【M转向第一个柜台】
M:我回来了,这是你要的first.json的内容。然后呢?
用户A:然后把内容按JSON解析一下。
M:你等等我去解析一下。
【M离开了柜台】
M:小P来一下!去parseJSON把这堆东西解析一下,回来叫我。
P:好的我这就去。
【M转向了另一个柜台窗口】
…………
…………

真相大白了,M并没有亲自去拿文件解析JSON,而是叫来了任劳任怨的小P干活,自己在用户A面前伪装成被占用了所有的时间的样子,其实偷偷去接待别的用户了。

利用Generator可以用yield中断执行,再在外部通过next唤醒继续执行的特性,co把Generator的next写到Promise的then里面从而实现循环调用。使用了co之后,代码看起来跟同步非常相像,写起来符合人正常的同步思维,甚至可以使用同步的流程控制语句比如for。但是执行起来却能充分利用异步带来的性能优势。

顺便提一句,co看起来已经非常像async/await方式了。Node.js中同样近似于async/await方式的还有asyncawait库,它不依赖generator而是依赖于node-fiber,看名字大概就是Node里的一个纤程的实现吧。由于不需要generator,对于诸如Coffescript和Typescript类的语言支持非常好。

以上就是对Javascript中最近常讨论的几种异步流程控制的简单类比说明。这种理解方式非常粗浅,而且有很多问题并不像上面写的那样那么简单。要想使用好异步,还是要多读一些更为深入的文章。

#参考资料