Javascript是单线程运行、支持异步机制的语言。进入正题以前,咱们有必要先理解这种运行方式。javascript
以「起床上班」的过程为例,假设有如下几个步骤:java
最简单粗暴的执行方式就是按顺序逐步执行,这样从起床到上班共需50分钟,效率较低。若是能在「洗刷」以前先「叫车」,就能够节省10分钟的等车时间。ajax
这样一来「叫车」就成了异步操做。但为什么只有「叫车」能够异步呢?由于车不须要本身开过来,因此本身处于空闲状态,能够先干点别的。npm
把上面的过程写成代码:编程
function 起床() { console.info('起床'); }
function 洗刷() { console.info('洗刷'); }
function 换衣() { console.info('换衣'); }
function 上班() { console.info('上班'); }
function 叫车(cb) {
console.info('叫车');
setTimeout(function() {
cb('车来了');
}, 1000);
}
起床();
叫车(function() {
上班();
});
洗刷();
换衣();
复制代码
由于「上班」要在「叫车」以后才能执行,因此要做为「叫车」的回调函数。然而,「叫车」须要10分钟,「洗刷」也须要10分钟,「洗刷」执行完后恰好车就到了,此时会不会先执行「上班」而不是「换衣」呢?Javascript是单线程的语言,它会先把当前的同步代码执行完再去执行异步的回调。而异步的回调则是另外一片同步代码,在这片代码执行完以前,其余的异步回调也不会被执行。因此「上班」不会先于「换衣」执行。数组
接下来考虑一种状况:手机没电了,想叫车得先充电。很明显,充电的过程也能够异步执行。整个过程应该是:promise
写成代码则是:
浏览器
function 充电(cb) {
console.info('充电');
setTimeout(function() {
cb(0.1); // 0.1表示充了10%
}, 1000);
}
起床();
充电(function() {
叫车(function() {
上班();
});
});
洗刷();
换衣()
复制代码
充电、叫车、上班是异步串行(按顺序执行)的,因此要把后者做为前者的回调函数。可见,串行的异步操做越多,回调函数的嵌套就会越深,最终造成了回调金字塔(也叫回调地狱):
缓存
充电(function() {
叫车(function() {
其余事情1(function() {
其余事情2(function() {
其余事情3(function() {
上班();
});
});
});
});
});
复制代码
这样的代码极难阅读,也极难维护。此外,还有更复杂的问题:bash
可喜的是,随着异步编程的发展,上面说起的这些问题愈来愈好解决了,下面就给你们介绍四种解决方案。
Async是一个异步操做的工具库,包含流程控制的功能。
「async.series」即为执行异步串行任务的方法。例如:
// 充电 -> 叫车
async.series([
function(next) {
充电(function(battery) {
next(null, battery);
});
},
function(next) {
叫车(function(msg) {
next(null, msg);
});
}
], function(err, results) {
if (err) {
console.error(err);
} else {
console.dir(results); // [0.1, '车来了']
上班();
}
});
复制代码
「async.series」的第一个参数是要执行的步骤(数组),每个步骤都是一个函数。这个函数有一个参数「next」,异步操做完成后必须调用「next」:
「async.series」的第二个参数则是这些步骤所有执行完成后的回调函数。其中:
「async.waterfall」是另外一个用得更多的异步串行方法,它与「async.series」的区别是:把上一步的结果传给下一步,而不是汇总到最后的回调函数。例如:
// 充电 -> 叫车
async.waterfall([
function(next) {
充电(function(battery) {
next(null, battery);
});
},
// battery为上一步的next所传的参数
function(battery, next) {
if (battery >= 0.1) {
叫车(function(msg) {
next(null, msg);
});
} else {
next(new Error('电量不足'));
}
}
], function(err, result) {
if (err) {
console.error(err);
} else {
console.log(result); // '车来了'
上班();
}
});
复制代码
而执行异步并行任务的方法则是「async.parallel」,用法与「async.series」相似,这里就再也不详细说明了。
那串行、并行相互穿插又是怎样的呢?
// 从起床到上班的整个过程
async.series([
function(next) {
起床();
next();
},
function(next) {
async.parallel([
function(next) {
async.waterfall([
function(next) {
充电(function(battery) {
next(null, battery);
});
},
function(battery, next) {
if (battery >= 0.1) {
叫车(function(msg) {
next(null, msg);
});
} else {
next(new Error('电量不足'));
}
}
], next);
},
function(next) {
洗刷();
换衣();
next();
}
], next);
}
], function(err, results) {
if (err) {
console.error(err);
} else {
上班();
}
});
复制代码
可见,若是串行和并行互相多穿插几回,仍是会出现必定程度的回调金字塔现象。
Asycn库的优势是符合Node.js的异步编程模式(回调函数的第一个参数是异常信息,Node.js原生的异步接口都这样)。然而它的缺点也正是如此,回调函数中有一个异常信息参数,还占据了第一位,实在是太不方便了。
Promise是ES6标准的一部分,它提供了一种新的异步编程模式。可是ES6定稿比较晚,且旧的浏览器没法支持新的标准,于是有一些第三方的实现(好比Bluebird,不只实现了Promise的标准,还进行了扩展)。顺带一提,Node.js 4.0+已经原生支持Promise。
那Promise到底是什么玩意呢?Promise表明异步操做的最终结果,跟Promise交互的主要方式是经过它的「then」或者「catch」方法注册回调函数去接收最终结果或者是不能完成的缘由(异常)。
使用Promise首先要把异步操做Promise化:
function 充电Promisify() {
return new Promise(function(resolve) {
充电(function(battery) {
resolve(battery);
});
// 也能够简写为 充电(resolve)
});
}
function 叫车Promisify(battery) {
return new Promise(function(resolve, reject) {
if (battery >= 0.1) {
叫车(function(msg) {
resolve(msg);
});
// 也能够简写为 叫车(resolve)
} else {
reject(new Error('电量不足'));
}
});
}
复制代码
具体来讲,就是建立一个Promise对象,建立时须要传入一个函数,这个函数有两个参数「resolve」和「reject」。操做成功时调用「resolve」,出现异常时调用「reject」。而想要得到异步操做的结果,正如前面所提到的,须要调用Promise对象的「then」方法:
叫车Promisify(0.1).then(function(result) {
console.log(result); // '车来了'
}, function(err) {
console.error(err);
});
叫车Promisify(0).then(function(result) {
console.log(result);
}, function(err) {
console.error(err.message); // '电量不足'
});
复制代码
「then」方法有两个参数:
要注意的是,建立Promise对象时传入的函数只会执行一次,即便屡次调用了「then」方法,该函数也不会重复执行。这样一来,一个Promise实际上还缓存了异步操做的结果。
下面看一下基于Promise的异步串行是怎样的:
// 充电 -> 叫车
充电Promisify().then(function(battery) {
return 叫车Promisify(battery);
}).then(function(result) {
console.log(result); // '车来了'
上班();
}).catch(function(err) {
console.error(err);
});
复制代码
若是「then」的回调函数返回的是一个Promise对象,那么下一个「then」的回调函数就会在这个Promise对象完成以后再执行。因此多个步骤只须要经过「then」链式调用便可。此外,这段代码的「then」只有一个参数,而异常则由「catch」方法统一处理。
接下来看一下异步并行,须要用到「Promise.all」这个方法:
// 充电、洗刷并行
Promise.all([
充电Promisify(),
new Promise(function(resolve) {
洗刷();
resolve();
})
]).then(function(results) {
console.dir(results); // [0.1, undefined]
}, function(err) {
console.error(err);
});
复制代码
最后是串行和并行穿插:
// 从起床到上班的过程
new Promise(function(resolve) {
起床();
resolve();
}).then(function() {
return Promise.all([
充电Promisify().then(function(battery) {
return 叫车Promisify(battery);
}),
new Promise(function(resolve) {
洗刷();
换衣();
resolve();
})
]);
}).then(function(results) {
console.dir(results); // ['车来了', undefined]
上班();
}).catch(function(err) {
console.error(err);
});
复制代码
可见,基于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* genFn() {
console.log('begin');
var value = yield 'a';
console.log(value); // 'B'
return 'end';
}
var gen = genFn();
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
复制代码
若是是普通的函数,执行「genFn()」后就会返回「end」,但生成器函数并非这样。执行「genFn()」后,其实是建立了一个生成器函数对象,此时函数内的代码不会执行。而调用这个对象(gen)的「next」方法时,函数开始执行,直到「yield」暂停。「next」方法的返回值是一个对象,它有两个属性:
第二次调用「gen.next」时,传入了一个参数值「B」。「next」方法的参数值即为当前暂停函数的「yield」的返回值,因此函数内部value的值为「B」。而后函数继续执行,返回「end」。因此「g2.value」为的值「end」,此时函数执行完毕,「g2.done」的值为「true」。
那到底这玩意对异步编程有何助益呢?且看这段代码:
function* 叫车Gen(battery) {
try {
var result = yield 叫车Promisify(battery);
console.log(result); // '车来了'
} catch (e) {
console.error(e);
}
}
var gen = 叫车Gen(0.1), promise = gen.next().value;
promise.then(function(result) {
gen.next(result);
}, function(err) {
gen.throw(err);
});
复制代码
其执行过程大概是:执行异步操做后就暂停了「叫车Gen」的执行,异步操做完成后经过「gen.next」把「result」回传到「叫车Gen」中;若是出现异常,就经过「gen.throw」抛出以便在「叫车Gen」里面捕获。
可是这样绕来绕去又有什么好处呢?仔细观察能够发现,「叫车Gen」内部虽然执行的是异步操做,但彻底就是同步的写法(没有回调函数,异常捕获也是用常规的「try...catch」)。进一步思考,若是能把后面的细节封装起来,那就真的能够用同步的方式写异步的代码了。然后面的细节部分也是有规律可循的,封装起来并非难事(只是有点绕):
function asyncByGen(genFn) {
var gen = genFn();
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 {
var battery = yield 充电Promisify();
console.log(
yield 叫车Promisify(battery)
); // '车来了'
} catch (e) {
console.error(e);
}
});
复制代码
// 异步并行:充电、洗刷并行
asyncByGen(function *() {
try {
console.dir(
yield Promise.all([
充电Promisify(),
new Promise(function(resolve) {
洗刷();
resolve()
})
])
); // [0.1, undefined]
} catch (e) {
console.error(e);
}
});
复制代码
// 串行、并行互相穿插:从起床到上班的过程
asyncByGen(function*() {
try {
起床();
console.dir(
yield Promise.all([
充电Promisify().then(function(battery) {
return 叫车Promisify(battery);
}),
new Promise(function(resolve) {
洗刷();
换衣();
resolve();
})
])
); // [0.1, undefined]
上班();
} catch (e) {
console.error(e);
}
});
复制代码
生成器函数是一种比较新的特性,虽然Node.js 4.0+已经原生支持,但在旧版本浏览器上确定没法运行。所以若是要在浏览器端使用还得经过编译器(如Babel)编译成ES5的代码,这也是这种解决方案的最大缺点。
讲到这里,顺便介绍一下「co」库。这个库的功能相似于「asyncByGen」,但它封装得更好,功能也更多,是用生成器函数写异步代码必不可少的利器。
若是你仍是看不懂生成器函数的执行过程,那也不要紧,由于它已经“过期”了!ES7提供了「async」、「await」两个关键字,能够达到跟「asyncByGen」同样的效果。
首先给你们介绍一个这两个关键字的用法。「async」是用来声明异步函数的,这种函数的返回值老是Promise对象(即便函数内部返回的不是Promise对象,也会返回一个结果为undefined的Promise对象)。
async function asyncFnA() {
return Promise.resolve('A');
}
asyncFnA().then(function(result) {
console.log(result); // 'A'
});
async function asyncFnB() {
}
asyncFnB().then(function(result) {
console.log(result); // undefined
});
复制代码
「await」只能用在由「async」声明的异步函数的内部,它会等待其后的Promise对象肯定状态后再执行后续的语句:
(async function() {
var battery = await 充电Promisify();
console.log(battery); // 0.1
})();
复制代码
顺带提一下,「await」后面不必定非要跟着Promise对象,也能够是一个普通的值,这样至关因而执行同步代码。
下面用「async/await」重写上面的例子:
// 异步串行:充电 -> 洗刷
(async function() {
try {
var battery = await 充电Promisify();
return await 叫车Promisify(battery);
} catch (e) {
console.error(e);
}
})().then(function(msg) {
console.log(msg); // 车来了
});
复制代码
// 异步并行:充电、洗刷并行
(async function() {
try {
return await Promise.all([
充电Promisify(),
(async function() {
洗刷();
})()
]);
} catch (e) {
console.error(e);
}
})().then((results) => {
console.dir(results); // [0.1, undefined]
});
复制代码
// 串行、并行互相穿插:从起床到上班的过程
(async function() {
try {
起床();
console.dir(
await Promise.all([
充电Promisify().then(function(battery) {
return 叫车Promisify(battery);
}),
(async function() {
洗刷();
换衣();
})()
])
); // [0.1, undefined]
上班();
} catch (e) {
console.error(e);
}
})();
复制代码
可见,与生成器函数相比,「async/await」又使异步编程变得更为简单了。Node.js 7.6+以及大部分主流浏览器的最新版本都已经支持这两个关键字了,但仍是那句话:若是要在浏览器端使用,编译器(如Babel)是少不了的。
本文的初版写于2015年年末,如今(2017年中)重读一遍,以为有很多能够改进的地方,并且技术也在不断发展,因而又修改了一遍。改动包括:
本文也发表在做者我的博客:异步流程控制 | Node.js开发 | Heero's Blog
文章同步发布在:[贝聊知乎]