Javascript异步编程之三Promise: 像堆积木一样组织你的异步流程

时间:2023-03-08 16:52:08
Javascript异步编程之三Promise: 像堆积木一样组织你的异步流程

这篇有点长,不过干货挺多,既分析promise的原理,也包含一些最佳实践,亮点在最后:)

还记得上一节讲回调函数的时候,第一件事就提到了异步函数不能用return返回值,其原因就是在return语句执行的时候异步代码还没有执行完毕,所以return的值不是期望的运算结果。

Promise却恰恰要回过头来重新利用这个return语句,只不过不是返回最终运算值,而是返回一个对象,promise对象,用它来帮你进行异步流程管理。

先举个例子帮助理解。Promise对象可以想象成是工厂生产线上的一个工人,一条生产线由若干个工人组成,每个工人分工明确,自己做完了把产品传递给下一个工人继续他的工作,以此类推到最后就完成一个成品。这条生产线的组织机制就相当于Promise的机制,每个工人的工作相当于一个异步函数。后面会继续拿promise和这个例子进行类比。

Promise风格异步函数的基本写法:

如果用setTimeout来模拟你要进行的异步操作,以下是让异步函数返回promise的基本写法。调用Promise构造函数,生成一个promise对象,然后return它。把你的代码包裹在匿名函数function(resolve, reject){ … } 里面,作为参数传给Promise构造函数。resolve和reject是promise机制内部已经定义好的函数,传给你用来改变promise对象的状态。在你的异步代码结束的时候调用resolve来表示异步操作成功,并且把结果传给resolve作为参数,这样它可以传给下一个异步操作。

function asyncFn1() {
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('asyncFn1 is done');
resolve('asyncFn1 value');
}, 1000);
}); return promise;
}

在promise机制当中,resolve被调用后会把promise的状态变成’resolved’。 如果reject被调用,则会把promise的状态变成’rejected’,表示异步操作失败。所以在上面的例子中如果你有一些逻辑判断,可以在失败的时候调用reject:

//伪代码
function asyncFn1() {
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('asyncFn1 is done');
if(success) {
resolve('asyncFn1 value');
} else {
reject('error info');
}
}, 1000);
}); return promise;
}

then()方法:

既然promise的用来做流程管理的,那肯定是多个异步函数要按某种顺序执行,而每个都要return promise对象。怎样把它们串起来呢?答案是调用promise对象最重要的方法promsie.then(),从它的字面意思就可以看出它的作用。而且then()方法也返回一个新的promise对象,注意是新的promise对象,而不是返回之前那个。

假如有三个异步函数:

function asyncFn1() {
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('asyncFn1 is done');
resolve('asyncFn1 value');
}, 1000);
});
return promise;
} function asyncFn2(arg) {
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('asyncFn2 is done');
resolve(arg + ' asyncFn2 value');
}, 1000);
});
return promise;
} function asyncFn3(arg) {
var promise = new Promise(function(resolve, reject) {
setTimeout(function() {
console.log('asyncFn3 is done');
resolve(arg + ' asyncFn3 value');
}, 1000);
});
return promise;
}

可以用then方法这样顺序来组织它们:

var p1 = asyncFn1(),
p2 = p1.then(asyncFn2),
p3 = p2.then(asyncFn3); p3.then(function(arg) {
console.log(arg);
});

这样组织起来后,就会按照顺序一个一个执行:asyncFn1执行完成后p1变成resolved状态并调用asyncFn2,asyncFn2运行完后p2变成resolved状态并且调用asyncFn3,asyncFn3执行完成后p3编程resolved状态并调用匿名函数打印输出结果。这个过程中,如果任何一个promise被变成’rejected’,后续所有promise马上跟着变成rejected,而不会继续执行他们所登记的异步函数。

上面代码可以更加简化成这样,看起来更清爽,用飘柔的感觉有没有:

asyncFn1()
.then(asyncFn2)
.then(asyncFn3)
.then(function(arg) {
console.log(arg);
});

怎么样,比上一节讲的回调嵌套代码漂亮太多啦,多苗条。

现在跟工厂生产线的例子进行类比一下加深理解。你猜上面这段飘柔代码在工厂生产线例子中相当于什么?你一定会说,你不是上面说了嘛,相当于一条顺序执行的生产线。错!!! 它相当于---------生产计划,或者生产图纸。怕了没?没错就是相当于生产计划,里面登记了每个工人的任务和他们的工作顺序。如果把它当成生产线,就会误以为asyncFn1()运行完了再调用then,当asyncFn2运行完了再调用下一个then,当asyncFn3运行完了再调用第三个then,这样会造成是由then来调用这些异步函数的错觉。实际上then的作用仅仅是登记当每个promise变成resolved状态时要调用的下一个函数,仅仅是登记,而不是实际上调用它们,实际调用是发生在promise变成resolved的时候。(then可以用来登记生产计划的原因是它其实是个同步方法,所以这段飘柔代码噌得一下就执行完了,计划就出来了,而不是跟着那些asyncFn函数们一个等一个的执行)。搞清楚这个对于新手来说非常重要,它可以让你更好的来组织你的异步流程。后面会详细说。另外,工作计划产生后,生产也同时开始了,即asyncFn函数们也开始执行了,按登记的顺序。

catch()方法:

上面例子中then方法都是只接受一个异步函数作为参数,实际上then方法可以接受两个函数作为参数。第一个函数是Promise对象的状态变为Resolved时调用,第二个回调函数是Promise对象 的状态变为Rejected时调用。其中,第二个函数是可选的,大部分情况下不需要提供。但是一种情况除外就是当你的异步流程结束的时候需要用第二个函数来捕获异常。即:

asyncFn1()
.then(asyncFn2)
.then(asyncFn3)
.then(null, function(error) {
console.log(error);
});

最后一步的异常捕获通常会换一种写法:

asyncFn1()
.then(asyncFn2)
.then(asyncFn3)
.catch(function(error) {
console.log(error);
});

catch()是then()用来捕获异常时的别名或语法糖。它可以捕获前面任何promise对象变成rejected的状态后,所传递下来的错误信息。如果不使用catch()方法,Promise对象抛出的错误就会石沉大海,让你无法调试。

嵌套promise

Promise机制本身是为了解决回调嵌套的,但有意思的是promise本身也可以嵌套,示例如下:

//伪代码
fn1()
.then(fn2)
.then(function(result) {
return fn3(result)
.then(fn31)
.then(fn32)
.then(fn33);
})
.then(fn4)
.catch(function(err) {
console.log(err);
});

你怎么看?我个人观点,任何事情都没有绝对的对和错,好和不好,就是个度的问题。

Promise.all()方法:

上一节在回调风格的异步中,最后留了一个思考题,怎样在循环里面调用异步函数?现在揭晓答案。

var fs = require('fs');

function foo(dir, callback) {
fs.readdir(dir, function(err, files) {
var text = '',
counter = files.length;
for(var i=0, j=files.length; i<j; ++i) {
void function(ii) {
fs.readFile(files[ii], 'utf8', function(err, data) {
text += data;
--counter;
if(counter===0) {
callback(text);
}
});
} (i);
}
});
} foo('./', function(data) {
console.log(data);
});

上面代码foo函数读取当前目录下所有文件然后合并到一起,由callback把内容传出来。调用callback的时机也很清楚了,关键就是设个计数器(counter),必须当所有readFile回调都完成后再调用callback。顺便提一下循环调用异步的时候循环本身必须使用一个匿名函数包裹,为什么?呵呵新手绕不过的坑,答案自行寻找。后面有时间再写文探讨一些javascript的坑坑吧。

怎样循环回调风格的异步函数现在清楚了,那么问题来了,怎样循环promise风格的函数呢?

var fs = require('fs');

//把fs.readdir()改造为promise风格
function readdirP(dir) {
return newPromise(function(resolve, reject) {
fs.readdir(dir, function(err, files) {
if(err) {
reject(err);
} else {
resolve(files);
}
});
});
} //把fs.readFile()改造为promise风格
function readFileP(file) {
return new Promise(function(resolve, reject) {
fs.readFile(file, 'utf8', function(err, data) {
if(err) {
reject(err);
} else {
resolve(data);
}
});
});
} function foo(dir) {
return new Promise(function(resolve, reject) {
var text = '';
readdirP(dir).then(function(files) {
return new Promise(function(resolve, reject) {
var counter = files.length;
console.log(counter);
for(var i=0, j=files.length; i<j; ++i) {
void function(ii) {
readFileP(files[ii]).then(function(data) {
text += data;
--counter;
if(counter===0) {
resolve(text);
}
});
}(i);
}
});
}).then(function(result) {
resolve(result);
});
});
} foo('./').then(function(data) {
console.log(data);
});

我了个去,怎么看起来比回调风格的还复杂?没错的确是这样,因为你还是在用回调思维写promise风格的代码,是个四不像。正宗的写法应该是这样的:

function foo(dir) {
var promise = readdirP(dir) .then(function(files) {
var arr=[];
for(var i=0, j=files.length; i<j; ++i) {
arr.push(readFileP(files[i]));
}
return Promise.all(arr);
}) .then(function(datas) {
return datas.join('');
}); return promise;
} foo('./').then(function(data) {
console.log(data);
});

这里关键就在于Promise.all()的使用。Promise.all(arr)接受一组promise为参数,即promise数组。当所有promise都变成resolved的时候就完成了,输出也是一个数组,即每个promise所resolve的值。如果任何一个promise变成rejected,则整个失败,可以在后面用catch捕获。标准写法:

//伪代码
var arr = [promise1, promise2, promise3];
Promise.all(arr)
.then(function(resultArr) {
使用resultArr;
})
.catch(function(error) {
console.log(error);
});

Promise.race()方法:

稍提一下Promise.race(arr)方法,用法跟Promise.all(arr)类似,只不过arr中任何一个promise变resolved/rejected的时候就结束,输出这个resolve/reject的值。这个方法的功能从它的名字就可以看出来。

最佳实践:

Promise流程最后一定要加个catch()捕获可能发生的错误。

then(fn)方法只接受函数作为的参数,fn如果是异步的,则必须要return一个promise对象;如果是同步的,则可以直接return一个value

function foo(arg) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(arg + 1);
}, 1000);
});
} foo(0)
.then(foo)
.then(foo)
.then(function(arg) {
return arg +1;
})
.then(foo)
.then(function(arg) {
console.log(arg);
});

猜猜上述代码最后输出多少?foo被调用了4次,并且中间有一次同步arg+1的代码,所以最后输出5。这里的同步代码arg+1太简单只是为了演示,如果你的同步代码比较复杂而且中间可能抛出exception,那最好让同步代码也返回一个promise,这样就可以在最后catch里面捕获到,真是太爽了:

foo(0)
.then(foo)
.then(foo)
.then(function(arg) {
return Promise.resolve().then(function() {
return arg +1;
});
})
.then(foo)
.catch(function(err) {
console.log(err);
});

即把同步代码用Promise.resolve().then(function() { … } 进行包裹。Promise.resolve()是生成promise对象的快捷方法,不过它生成的promise对象初始状态就是resolved的。Promise.resolve()方法还可以带参数,这里不进行详述,大家可以自行去了解一下。

用上述方法写出来的流程,出错几率会大大减少。

说了这么久,该说重点了:)

堆积木:

返本溯源,promise是为了解决什么问题来着?对了,解决回调地狱,本质上是为了更加清晰的组织异步代码。Promise的精髓用法就是把一个个异步函数像积木一样按照它们的顺序堆积自来,可以串行可以并行,这种堆积木方式的组织流程相当灵活,可以组织出任意你的业务中需要的流程。这样说比较抽象,还是用例子吧:

(这是我实际项目中的一个真实例子)我有5个promise风格的异步函数fn1, fn2, fn3, fn4 和 fn5。fn3需要用到fn2的结果,fn4需要用到fn3的结果, fn5需要用到fn1, fn2, fn3和fn4的结果。是不是挺绕,应该怎么写?时间关系就不卖关子了。

var p1 = fn1(),
p2 = fn2();
p3 = p2.then(fn3);
p4 = p3.then(fn4); var arr = [p1, p2, p3, p4]; Promise.all(arr).then(fn5);

怎么样,是不是很神奇?发挥你的想象力,这些异步函数你可以随意组合,串行并行。

切记:组合的过程中每个异步函数通常只出现一次,除非你业务需要它使用不同的数据运行多次,否则如果出现多次,极有可能你已经掉坑里了:

//错误代码
var p1 = fn1(),
p2 = fn2();
p3 = fn2().then(fn3);
p4 = fn2().then(fn3).then(fn4); var arr = [p1, p2, p3, p4]; Promise.all(arr).then(fn5);

看起来两组代码似乎等价哦,呵呵,只不过错误代码中fn2会跑3次,fn3会跑2次。好好对比清楚:)

我在还没有领悟这种用法的时候是用这样直肠子的做法:

fn1()
.then(fn2)
.then(fn3)
.then(fn4)
.then(fn5);

哟?这不是更简单吗?错!因为fn1的输出在fn2, fn3和fn4中根本没用,但是还是必须捎带在他们每一个的输出结果里面; fn4根本不需要fn2的输出,但又要捎带在fn3里面以传给fn4最后给fn5。这样就造成这些函数深度耦合在一起,功能混乱。 所以记得promise不只能串行,也可以并行,就像堆积木一样非常灵活的进行组合。不知谁这么聪明发明了这种方法:)

转载请注明出处: http://www.cnblogs.com/chrischjh/p/4692743.html

『本集完』