Javascript是 单线程 运行、支持 异步 机制的语言。进入正题之前,我们有必要先理解这种运行方式。
以「起床上班」的过程为例,假设有以下几个步骤:
最简单最不容易出错的执行方式就是按顺序逐步执行,这样从起床到上班共需45分钟,效率较低。而高效的方式是,在「洗刷」之前先「叫车」,这样就可以节省10分钟的等车时间。
这样一来「叫车」就成了异步操作。但为何只有「叫车」可以异步呢?因为车不需要自己开过来,所以自己处于空闲状态,可以先干点别的。
把上面的过程写成伪代码:
起床(); 叫车(function() { 上班(); }); 洗刷(); 换衣();
因为「上班」要在「叫车」之后才能执行,所以要作为「叫车」的回调函数。然而,「叫车」需要10分钟,「洗刷」也需要10分钟,「洗刷」执行完后刚好车就到了,此时会不会先执行「上班」而不是「换衣」呢?Javascript是单线程的语言,它会 先把当前的同步代码执行完再去执行异步的回调 。而异步的回调则是另一片同步代码,在这片代码执行完之前,其他的异步回调也不会被执行。所以「上班」不会先于「换衣」执行。
接下来考虑一种情况:手机没电了,要想叫车得先充电。很明显,充电的过程也可以异步执行。整个过程应该是:
写成伪代码则是:
起床(); 充电(function() { 叫车(function() { 上班(); }); }); 洗刷(); 换衣();
充电、叫车、上班是异步串行(按顺序执行)的,所以要把后者作为前者的回调函数。可见,串行的异步操作越多,回调函数的嵌套就会越深,最终形成了回调金字塔(也叫回调地狱):
充电(function() { 叫车(function() { 其他事情1(function() { 其他事情2(function() { 其他事情3(function() { 上班(); }); }); }); }); });
这样的代码 极难阅读 ,也 极难维护 。此外,还有更复杂的问题:
可喜的是,随着异步编程的发展,上面提及的这些问题越来越好解决了,下面就给大家介绍三种解决方案。
Async 是一个异步操作的工具库,其中就包括流程控制。
「 async.series 」即为执行异步串行任务的方法。例如:
// 先加载a.txt再加载b.txt async.series([ function(next) { $.ajax('a.txt', { success: function(resultA) { next(null, resultA); }, error: function(err) { next( new Error(err.statusText) ); } }); }, function(next) { $.ajax('b.txt', { success: function(resultB) { next(null, resultB); }, error: function(err) { next( new Error(err.statusText) ); } }); } ], function(err, results) { if (err) { console.error(err); } else { console.dir(results); // 数组 } });
「async.series」的第一个参数是要执行的步骤(数组),每一个步骤都是一个函数。这个函数有一个参数「next」,异步操作完成后必须调用「next」:
「async.series」的第二个参数则是这些步骤全部执行完成后的回调函数。其中:
「 async.waterfall 」是另一个用得更多的异步串行方法,它与「async.series」的区别是,直接把上一步的结果传给下一步,而不是汇总到最后的回调函数。例如:
// 简化代码,把ajax的调用封装为函数 function loadFile(path, next) { return $.ajax(path, { success: function(resultA) { next(null, resultA); }, error: function(err) { next( new Error(err.statusText) ); } }); } // 假设a.txt的内容为下一个要加载的文件的路径 async.waterfall([ function(next) { loadFile('a.txt', next); }, function(resultA, next) { console.log(resultA); // 'b.txt' loadFile(resultA, next); } ], function(err, result) { if (err) { console.error(error); } else { console.log(result); // b.txt的内容 } });
而执行异步并行任务的方法则是「 async.parallel 」,用法与「async.series」类似,这里就不再详细说明了。
那先串行后并行又会是怎样的呢?
// 假设a.txt的内容是要下一步要并行加载的两个文件的路径 async.waterfall([ function(next) { loadFile('a.txt', next); }, function(resultA, next) { console.log(resultA); // 'b.txt|c.txt' resultA = resultA.split('|'); async.parallel([ function(next) { loadFile(resultA[0], next) }, function(next) { loadFile(resultA[1], next) } ], next); } ], function(err, results) { if (err) { console.error( new Error(err) ); } else { console.dir(results); } });
在先串行后并行的代码中,「async.parallel」嵌套在了「async.waterfall」的代码中。这样一来,如果串行和并行多穿插几次,会不会又出现回调金字塔的情况呢?答案是不会的,因为无论是增加一个串行步骤还是并行步骤,都可以直接加到「async.waterfall」的步骤数组中,也就是说最多只会嵌套一次。
Asycn库的优点是 符合Node.js的异步编程模式 (回调函数的第一个参数是异常信息,Node.js自带的异步接口都这样)。然而它的缺点也正是如此,回调函数中有一个异常信息参数,还占据了第一位,实在是太不方便了。
Promise是ES6标准的一部分,它提供了一种新的异步编程模式。但是ES6定稿比较晚,且旧的浏览器无法支持新的标准,因而有一些第三方的实现(比如 Bluebird )。顺带一提,Node.js 4.0+已经原生支持Promise。
那Promise究竟是什么玩意呢?Promise代表异步操作的 最终结果 ,跟Promise交互的主要方式是通过它的「 then 」方法注册回调函数去接收最终结果或者是不能完成的原因(异常)。
使用Promise首先要把异步操作Promise化:
function ajaxPromisify(url) { return new Promise(function(resolve, reject) { $.ajax({ url: url, success: function(result) { resolve(result); }, error: function(err) { reject( new Error(err.statusText) ); } }); }); }
具体来说,就是创建一个Promise对象,创建时需要传入一个函数,这个函数有两个参数「 resolve 」和「 reject 」。操作成功时调用「resolve」,出现异常时调用「reject」。而想要获得异步操作的结果,正如前面提到的,需要调用Promise对象的「then」方法:
var promiseA = ajaxPromisify('a.txt'); promiseA.then(function(result) { console.log(result); }, function(err) { console.error(err); });
「then」方法有两个参数:
要注意的是, 创建Promise对象时传入的函数只会执行一次 ,即使多次调用了then方法,该函数也不会重复执行。这样一来,一个Promise实际上还缓存了异步操作的结果。
下面看一下基于Promise的异步串行是怎样的:
ajaxPromisify('a.txt').then(function(resultA) { console.log(resultA); return ajaxPromisify('b.txt'); }).then(function(resultB) { console.log(resultB); }).catch(function(e) { console.error(e); });
如果then的回调函数返回的是一个Promise对象,那么下一个「then」的回调函数就会在这个Promise对象完成之后再执行。所以多个步骤只需要通过「then」链式调用即可。此外,这段代码的「then」只有一个参数,而异常则由「 catch 」方法统一处理。
接下来看一下异步并行,需要用到Promise.all这个方法:
Promise.all([ ajaxPromisify('a.txt'), ajaxPromisify('b.txt') ]).then(function(results) { console.dir(results); }, function(err) { console.error(err); });
最后是先串行后并行:
ajaxPromisify('a.txt').then(function(resultA) { console.log(resultA); // 'b.txt|c.txt' resultA = resultA.split('|'); return Promise.all([ ajaxPromisify(resultA[0]), ajaxPromisify(resultA[1]) ]); }).then(function(results) { console.dir(results); }).catch(function(e) { console.error(e); });
可见,基于Promise的异步代码比Async库的要简洁,而且通过「then」的链式调用可以很好地控制执行顺序。但是由于现有的异步接口都不是基于Promise写的,所以要进行 二次封装 。
顺带一提,其实jQuery的「$.ajax」方法返回的就是一个不完全的Promise(没有实现Promise的所有接口):
$.ajax('a.txt').then(function(resultA) { console.log(resultA); return $.ajax('b.txt'); }).then(function(resultB) { console.log(resultB); });
Generator Function,中文译名为 生成器函数 ,是ES6中的新特性。这种函数通过「 function * 」进行声明,函数内部可以通过「 yield 」关键字 暂停函数执行 。
这是一个生成器函数的例子:
function* Gen() { console.log('begin'); var value = yield 'a'; console.log(value); // 'B' return 'end'; } var gen = Gen(); console.log(typeof gen); // 'object' var g1 = gen.next(); g1.value; // 'a' g1.done; // false var g2 = gen.next('B'); g2.value; // 'end' g2.done; // true
如果是普通的函数,执行「Gen()」后就会返回「end」,但生成器函数并不是这样。执行「Gen()」后,实际上是创建了一个生成器函数对象,此时函数内的代码不会执行。而调用这个对象(gen)的「next」方法时,函数开始执行,直到「yield」暂停。「next」方法的返回值则是一个对象,它有两个属性:
第二次执行gen.next时,传入了一个参数值「B」。 「next」方法的参数值即为当前暂停函数的「yield」的返回值 ,所以函数内部value的值为「B」。然后函数继续执行,返回「end」。所以「g2.value」为的值「end」,此时函数执行完毕,「g2.done」的值为「true」。
那到底这玩意对异步编程有何助益呢?且看这段代码:
function* AJAXGen(url) { try { var result = yield ajaxPromisify(url); console.log(result); } catch (e) { console.log('error'); } } var gen = AJAXGen('a.txt'), promise = gen.next().value; promise.then(function(result) { gen.next(result); }, function(err) { gen.throw(err); });
其执行过程大概是:执行异步操作后就暂停了「AJAXGen」的执行,异步操作完成后通过「gen.next」把「result」回传到「AJAXGen」中;如果出现异常,就通过「gen.throw」抛出以便在「AJAXGen」里面捕获。
但是这样绕来绕去又有什么好处呢?仔细观察可以发现,「AJAXGen」函数内部的虽然执行的是异步操作,但完全就是同步的写法(连异常捕获都是),没有任何的回调函数。进一步思考,如果能把后面的细节封装起来,那真的就可以用同步的方式写异步的代码了。而后面的细节部分也是有规律可循的,封装起来并不是难事(只是有点绕):
function asyncByGen(Gen) { var gen = Gen(); function nextStep(g) { if (g.done) { return; } if (g.value instanceof Promise) { g.value.then(function(result) { nextStep( gen.next(result) ); }, function(err) { gen.throw(err); }); } else { nextStep( gen.next(g.value) ); } } nextStep( gen.next() ); }
借助这个函数,异步编程可以前所未有地简单:
// 异步串行 asyncByGen(function *() { try { console.log( yield ajaxPromisify('a.txt') ); console.log( yield ajaxPromisify('b.txt') ); } catch (e) { console.error(e); } }); // 异步并行 asyncByGen(function *() { try { console.dir( yield Promise.all([ ajaxPromisify('a.txt'), ajaxPromisify('b.txt') ]) ); } catch (e) { console.error(e); } }); // 先串行后并行 asyncByGen(function*() { try { var resultA = yield ajaxPromisify('a.txt'); console.log(resultA); // 'b.txt|c.txt' resultA = resultA.split('|'); var results = yield Promise.all([ ajaxPromisify(resultA[0]), ajaxPromisify(resultA[1]) ]); console.dir(results); } catch (e) { console.error(e); } });
生成器函数是一种比较新的特性,虽然Node.js 4.0+已经原生支持,但在旧版本浏览器上肯定无法运行。因此如果要在浏览器端使用还得通过编译器(如babel)编译成ES5的代码,这也是这种解决方案的最大缺点。
最后提一下 「co」库 。这个库的功能类似于「asyncByGen」,但它封装得更好,功能也更多,是用生成器函数写异步代码必不可少的利器。