接着上文:async javaScript 上

JavaScript异步编程解决方案

现在主要的异步编程的方案有3种:

  1. PubSub模式(分布式事件)
  2. Promise对象
  3. 工作流控制库

下面我们将逐个进行分析,在这些异步方案之前,我们经常看到所谓的金字塔厄运

1
2
3
4
5
6
7
8
9
asyncFunc1(function(result1){
//some codes
asyncFunc2(function(result2){
//some codes
asyncFunc3(function(result3){
//other codes
});
});
});

那么,我们的解决方案就是使我们能更加方便的组织异步代码,规避像上面那样的问题。

PubSub模式(分布式事件)

所谓的PubSub模式其实很简单, 比如我们平时使用的dom.addEventListener就是一个PubSub模式鲜活的例子。在2000年DOM Level 2发布之前, 我们可能 需要使用类似于dom.onclick的方式去绑定事件。这样很容易产生问题,如果没有分布式事件的话,我们不能:

1
2
dom.onclick = eventHandler1;
dom.onclick = eventHandler2;

很明显,onclick只是dom的一个属性,同一个key不能对应多个value,第一个会被第二个覆盖掉,所以我们只能:

1
2
3
4
5
dom.onclick = function(){
eventHandler1.apply(this,arguments);
eventHandler1.apply(this,arguments);
};
`

这样的坏处很多,比如不够灵活,代码冗长,不利于维护等等。

现在开始学习前端,可能已经没有老师或者书籍讲解这样的用法了。dom.addEventListener标准化之后,我们可以:

1
2
dom.addEventListener('click',eventHandler1);
dom.addEventListener('click',eventHandler2);

而像jquery这样的类库,也自然磨平了不同浏览器的差异,提供了类似于$dom.on()的方法。如今,几乎所有的前端dom相关的类库都会提供类似的API。当然,在javascript世界的另一端,nodejs也有核心模块Events提供的EventEmitter对象,从而很容易实现分布式事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
var Emitter = require("events").EevntEmitter;

var emitter = new Emitter();

emitter.on('someEvent',function(stream){
console.log(stream + 'from eventHandler1');
});

emitter.on('someEvent',function(stream){
console.log(stream + 'from eventHandler2');
});

emitter.emit('someEvent','I am a stream!');

我们用DOM举例并不说明PubSub模式就是事件监听,而是因为事件监听是一个典型的分布式事件的示例,只是我们的订阅和发布依托的对象不是一个常规的对象,而且是一个浏览器的DOM对象,而在jQuery中这个对象就是jQuery对象了,下面,我们用简单的代码实现一个PubSub模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var PubSub = {handler: {}};

PubSub.sub = function(evnt, handler) {
var handlers = this.handlers;

!(event in handlers) && handlers(event) = [];

handers[event].push(handler);
}

PubSub.pub = function(event) {
var handlers = (handlers[event] || []);

var handlerArgs = [].slice.call(arguments, 1);

for(var i = 0, item; item = handlers[i]; i++) {
item.apply(this, handlerArgs);
}

return this;
}

如同我们看到的,上面的代码只是一个最简单甚至不安全的实现。在生产环境中,有很多成熟的框架,比如PubSubJS这样纯粹的PubSub模式的实现。同时,从上面的实现中,我们能发现,所有的event-handler都是同步执行的,这与我们浏览器中真实点击事件的事件处理时机还是有差异的,真实的点击事件的handler会在后续的event-loop中触发,同样,我们手动的dom.click()或者jQuery的$dom.click()都是同步执行的(大家可以测试一下)。

PubSub模式是大家最常用的一种方式,相对容易理解。基于这种事件化对象,实现了代码的分层次化,像大名如雷贯耳的Backbone.js也是使用了这样的技术。这是PubSub模式的好处。但是,事件不是万金油,有一些情况不适合用事件来处理,比如一些一次性转化且只有成功或者失败结果的流程,使用PubSub模式就有一些不合适。而这种情景下,Promise就显得更加适合我们。

Promise对象

Promise在很多语言中都有各自的实现,而其与javascript的结缘要归功于javascript发展历史上有里程碑意义的Dojo框架。2007年Dojo的开发者Twisted的启发,为Dojo添加了一个dojo.Deferred对象。2009年,Kris Zyp在CommmonJS社区提出了Promiser/A规范。之后,风云变幻,nodejs异军突起(2010年初,nodejs 放弃了对Promise的原生支持),2011年jQuery1.5携带着叛逆的Promise实现以及崭新的ajax风火出世,从此Promise真正被javascript开发者所熟知。

如今,更多的实现早已关注羽翼更加丰满的Promise/A+规范,jQuery对Promise的实现也对标准有所妥协,同时像Q.js的出现,也使得javascript世界有了通吃客户端和服务端的直观且纯粹的实现。

就在不远的(2014年12月)将来,javascript发展史上有了一个重大的时刻将会到来,ES6将成为正式标准,在众多夺人眼球的特性中,对Promise的原生支持仍然不乏瞩目,如果再配以Generator将是如虎添翼。

稍远的将来,ES7会提供一个async关键字引导声明的函数,支持 await, 而此番花样将会如何让我们拭目以待。

CommonJS社区的Promise/A规范相对简洁,而Promise/A+规范规范对其作了一些补充,我们后面将以Promise/A+规范配以实例学习Promise。

什么是Promise?Promise是一个对象,它代表异步函数的返回结果。用代码表示也就是:

1
var promise = asyncFunction();

如果具象一点,我们常见的一个jQuery的ajax调用就是这样:

1
2
3
4
var ajaxPromise = $.ajax('mydata');
ajaxPromise.done(successFunction);
ajaxPromise.fail(errorFunction);
ajaxPromise.always(completeFunction);

从上面的代码中,我们看到jQuery返回的Promise对象拥有若干方法,比如done、fail和always分别对应了ajax成功、失败以及无论成功失败都应该执行的回调,这些方法可以看做是规范之上的具体实现带给我们的语法糖。那么,真实的Promise规范是什么样?(其实,规范相对简短,大家可以稍花时间阅读,在此我们做一下主干介绍)

Promise的状态能且只能是下面三种的某一种:pending, fulfilled, rejected。这三种状态之间的关系:

  • pending:可以转变到fulfilled状态或者rejected状态
  • fulfilled:不可以转变到其他任何状态,而且必须有一个不可改变的value
  • rejected:不可以转变到其他任何状态,而且必须有一个不可改变的reason

关于value和reason,我们可以分别理解为fulfilled的结果和rejected的原因。

Promise必须要拥有一个then方法,用以访问当前或者最终的value或reason。then方法拥有两个参数,而且这两个参数都是可选的,用promise.then(onFulfilled, onRejected)。分析如下:

  • onFulfilled: :如果不是函数,将被忽略。
    如果是函数,只有且必须在promise状态转换为fulfilled之后被触发一次,并且只传递promise的value作为第一个参数。

  • onRejected:如果不是函数,将被忽略。
    如果是函数,只有且必须在promise状态转换为rejected之后被触发一次,并且只传递promise的reason作为第一个参数。


另外:多次调用then绑定的回调函数,在fulfilledrejected的时候,执行顺序与绑定顺序相对应。规范要求,调用需要在then之后的event loop中执行。

Promise的then方法必须返回一个promise对象,以供链式调用,如果onFulfilled或者onRejected有throw,那么后生成的Promise对象应该以抛出内容为reason转化为rejected状态。

在浅析Promise规范之后,我们可以完善一下本章节的第一段代码:

1
2
3
4
5
var promise = asyncFunction();
promise = promise.then(onFulfilled1, onRejected1)
.then(onFulfilled2, onRejected2);

promise.then(onFulfilled3, onRejected3);

Promise/A规范的实现众多,在我们的实际生产中,我们应该选择哪个实现呢?这个只能说因地制宜。

当然,现在应该有很多人和我一样,期待着ES6的原生Promise实现。ES标准化的Promise看上去是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var promise = new Promise(function(resolve, reject) {
// do a thing, possibly async, then…

if (/* everything turned out fine */) {
resolve("Stuff worked!");
}
else {
reject(Error("It broke"));
}
});

promise.then(function(result) {
console.log(result); // "Stuff worked!"
}, function(err) {
console.log(err); // Error: "It broke"
});

接下来,我们顺带提及一下Generator吧,如果你还不知道Generator是什么?看这里。简洁一点描述就是Generator函数可以通过特定的yield关键字中断函数执行,并与外界共享执行上下文。Generator函数基于这一特性,可以跟异步函数配合,等待异步函数的执行(结果),然后通过特定的接口(next)将异步结果注入到Generator自己的上下文中,然后继续执行后面的代码。

这样结合后,我们便能用同步的方式书写异步代码。能与Generator配合的实现有很多,其中就有Promise对象,而express的主人TJ大神给我们提供了一个非常成熟的方案–co。个人感觉,基于Generator优化异步代码的方式会是未来的最受欢迎的方式。在此,推荐几篇比较优秀的文章,我也就不班门弄斧了。

朴灵大大的还热乎的Generator与异步编程,不知道是哪位老师的Harmony Generator, yield, ES6, co框架学习,屈屈大大的ES6中的生成器函数介绍。如果想学习这一“不远未来”的技术,请点击进入上述链接吧。

另外,Google和Mozilla分别给了一些自己的解决方案:traceurtaskjs

基于Promise,我们可以实现各种串行并行的异步操作,但是,这个串行并行的控制,需要我们手动去维护,而flow-control类的方案,恰恰满足了我们这方面的需求,下面我们就从这里说起吧。

工作流控制库

所谓的工作流控制库(flow-control),我用自己的语言描述便是通过固有的模式(库提供相关的api)组织任务(代码/函数)的执行,从而轻松实现并行串行等需求。那么,比如我们有一个需求,需要读取三个文件,而三个文件是有顺序依赖关系的,那么我们需要做的就是顺序读取,可能代码原始是这样的:

1
2
3
4
5
6
7
fs.readFile('originalFile',function(err,data1){
fs.readFile(data1,function(err,data2){
fs.readFile(data2,function(err,data3){
//operate with data3
});
});
});

我们看到了一个“美丽的”金字塔。那么,如果用久负盛名的async后,会是怎么样呢?

1
2
3
4
5
6
7
8
9
10
11
async.waterfall([
function(cb){
fs.readFile('originalFile',cb);
},function(data1,cb){
fs.readFile(data1,cb);
},function(data2,cb){
fs.readFile(data2,cb);
}
],function(err,result){
//result now equals data3 & operate with data3
});

而同样的需求,用极简主义的step实现,代码又是如何呢?

1
2
3
4
5
6
7
8
9
step(function(){
fs.readFile('originalFile',this);
},function(err,data1){
fs.readFile(data1,this);
},function(err,data2){
fs.readFile(data2,this);
},function(err,data3){
//operate with data3
});

关于原始方案异步函数嵌套异步函数,我们可以一目了然就不做解释了。下面,我们对比一下async和step两者:

最明显的区别便是,async对外暴露一个对象,对象之下有实现若干特定流程的api。比如,我们需求中,由上而下有顺序依赖关系,async会给我们提供一个很文艺的api叫waterfall,而没有依赖关系只有顺序要求,我们就可以使用async.series,并行推进任务可以用async.parallel等等。

相比async,step就显得简洁很多,step给我们只提供了一个函数,它接受一个系列函数作为参数,并根据函数中对this的调用区分实现不同类型的流程控制。上面的示例中,异步函数在完成之后将结果传入step的回调函数执行时的this(如你所想,这时候this是一个函数),而正是通过this实现了将异步操作的结果传入到下一个step的回调函数,从而实现流程控制。通过this,我们实现其它的流程控制,比如要求多个任务并行:

1
2
3
4
5
6
7
8
Step(function loadStuff() {
fs.readFile('file-1', this.parallel());
fs.readFile('file-2', this.parallel());
fs.readFile('file-3', this.parallel());
},function showStuff(err, f1, f2, f3) {
//operate with f1, f2, f3
}
);

另外,async还为我们提供了一些流程控制之外的非常易用集合操作的方法以及一些工具函数。比如,类似于数组的map操作,我们看下面的函数:

1
2
3
async.map(['file1','file2','file3'], fs.stat, function(err, results){
// results is now an array of stats for each file
});

而当我们需要用step实现类似需求的时候怎么办呢?因为step是极简主义,源码也总共寥寥百余行,但是,我们完全可以借助既有的函数和方法,模拟出一个stepMap:

1
2
3
4
5
6
7
8
function stepMap(arr, iterator, callback){
step(function(){
var group = this.group();
for(var i = 0, l = arr.length; i < l; i++){
iterator(arr[i], group());
}
},callback);
}

总之,关于这一类型的解决方案,async和step是两个比较大众的实现,哪个更优,我觉得各有利弊,就像我们权衡express和connect一样。如果你喜欢便捷易用,又对api天生敏感,async是不错的选择;如果你像我一样,喜欢简洁,而且喜欢自己折腾,又不想死记api,那不妨尝试一下step。

本文地址 http://laoono.com/2016-05/async-javascript-1.html