接着上文: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。

本文地址 https://shaoshilei.com/2016-05/async-javascript-1.html