JavaScript Event Loop, Callback, Promise, Generator, async/await
Table of Contents
1. 异步:现在和将来的代码块
1.1. 分块的程序
可以把 JavaScript 程序看作由多个块(Chunks)构成的。 这些块中只有一个是现在执行,其余的则会在将来执行。 最常见的块单位是函数。
'use strict';
console.log("begin");
function foo() {
console.log("This is foo");
}
function bar() {
console.log("This is bar");
}
setTimeout(foo, 1000);
setTimeout(bar, 1000);
上面代码由 3 块组成:
块 1:主程序
console.log("begin");
setTimeout(foo, 1000);
setTimeout(bar, 1000);
块 2:函数 foo
console.log("This is foo");
块 3:函数 bar
console.log("This is bar");
其中,块 1 是现在执行,块 2 和块 3 会在将来执行。
1.2. JavaScript 运行时概念模型
JavaScript 运行时概念模型如图 1 所示。

Figure 1: JavaScript 运行时概念模型
栈(Stack):函数调用形成了一个栈帧。
堆(Heap):对象被分配在一个堆中,一个用以表示一个内存中大的未被组织的区域。
队列(Queue):一个 JavaScript 运行时包含了一个待处理的消息队列(又称“事件队列”)。每一个消息都与一个函数(称为“回调函数”)相关联。 当栈为空时,从队列中取出一个消息进行处理。这个处理过程包含了调用与这个消息相关联的函数(以及因而创建了一个初始堆栈帧)。当栈再次为空的时候,也就意味着这个消息处理结束,接着可以处理下一个消息了。这就是“事件循环”的过程。
参考:https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
1.3. 事件循环
上一节已经介绍了事件循环的过程。下面通过一段伪代码(摘自“You Don't Know JS: Async & Performance”)来说明这个概念:
// 事件循环极度简化的伪代码描述
var eventLoop = [ ]; // eventLoop是一个用作队列的数组(先进,先出)
var event;
// “永远”执行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到队列中的下一个事件
event = eventLoop.shift();
// 现在,执行下一个事件(事件关联的函数)
try {
event();
}
catch (err) {
reportError(err);
}
}
}
这当然是一段极度简化的伪代码,只用来说明概念。不过它应该足以用来帮助大家有更好的理解。
在上面“永远”执行的 while 循环中每一轮循环称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中摘下一个事件并执行其关联的回调函数。
可以使用 setTimeout()等方法把回调函数挂在事件循环队列中。不过,需要说明的是, setTimeout()并没有把你的回调函数直接挂在事件循环队列中。它所做的是设定一个定时器。当定时器到时后,环境会把你的回调函数放在事件循环中,这样,在未来某个时刻的 tick 会摘下并执行这个回调函数。
除 setTimeout()等方法外,很多事件上可以注册回调函数。当事件发生时,环境会把你的回调函数放在事件循环中。如:
var link=document.getElementById("mylink");
link.onclick=function(){ // 注册onclick事件的回调函数。onclick事件发生时会把回调函数挂在事件循环队列中。
console.log("I was clicked !");
};
1.4. JavaScript 是单线程的
JavaScript 是单线程的(注:是指用户代码是单线程的,JavaScript 引擎往往不是单线程的)。
JavaScript 之所以被设计为单线程,与它的主要用途有关。发明 Javascript 时,它的主要用途是与用户交互、操作 DOM 等。假设 JavaScript 不是单线程的,比如 JavaScript 有两个执行线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?如果把这个同步的责任交给用户,则用户代码将变得复杂。 为了避免这种复杂性,JavaScript 被设计为单线程。
注:为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 创建多个 worker 线程,不过 Web Worker 有很多限制,如:Web Worker 无法访问 DOM 节点;Web Worker 无法访问全局变量或全局函数等等。
1.4.1. 完整运行(run-to-completion)
考虑下面例子:
var a = 1;
var b = 2;
function foo() {
a++;
b = b * a;
a = b + 3;
}
function bar() {
b--;
a = 8 + b;
b = a * 2;
}
// ajax(...)是某个库中提供的某个Ajax函数。由于网络响应时间不确定,所以foo和bar的执行顺序是不确定的
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);
由于 JavaScript 的单线程特性,foo()/bar()中的代码具有原子性。也就是说,假设 “http://some.url.1” 响应更快,foo()会先执行 一旦 foo()开始运行,它的所有代码都会在 bar()中的任意代码运行之前完成。比如,假设 foo()要执行 1 分钟,在这 1 分钟内 “http://some.url.2” 响应完成了,那么 bar()并不会打断 foo()的执行。这称为完整运行(run-to-completion)特性。
由于 foo()不会被 bar()中断,bar()也不会被 foo()中断,所以这个程序只有两个可能的结果,这取决于这两个函数哪个先运行,如果 foo()先运行则结果为 a=11,b=22,如果 bar()先运行则结果为 a=183,b=180(可以自行验证)。如果存在多线程,且 foo()和 bar()中的语句可以交替运行的话,可能的结果将会增加很多(请考虑一下其它语言中的多线程同步问题)。
这种不确定性是在函数(事件)顺序级别上,而不是多线程情况下的语句顺序级别(或者说,表达式运算顺序级别)。换句话说,JavaScript 单线程事件模型的确定性要远远高于多线程情况。
1.4.2. “完整运行”实例
看下面例子:
'use strict';
console.log("begin");
function A() {
console.log("This is A");
}
setTimeout(A, 0); // 0毫秒后执行函数A
console.log("end");
由于 JavaScript 是单线程的,具有“完整运行”特性。上面代码中,一定会先输出字符串“begin”和“end”(它们都在主程序中),然后输出字符串“This is A”。
下面例子更能说明问题。
'use strict';
var execSync = require('child_process').execSync;
console.log("begin");
function A() {
console.log("This is A");
}
function longTimeTask(second) { // js中没有sleep函数,这里用execSync来模拟一个长时间的任务
let cmd = `bash -c "sleep ${second}"`;
console.log(`This is long task, exec ${cmd}`);
execSync(cmd);
}
setTimeout(A, 0); // 0秒后执行函数A
longTimeTask(2); // 这是一个2秒的任务(注:JavaScrip中应该避免这样使用!)
console.log("end");
上面代码中的注释“0 秒后执行函数 A”是不够准确的,准确的说法是 0 秒后,把回调函数 A 放在事件循环中,下一个 tick(前面介绍过 tick 的概念)时会从事件循环队列中取出回调函数 A,并执行。
执行上面代码,会输出:
begin This is long task, exec bash -c "sleep 2" end This is A
从输出中可知,尽管 longTimeTask 会执行 2 秒(长于给函数 A 设置的超时器时间 0 秒),函数 A 也不会在这 2 秒内执行,这是因为 Java 是单线程的,具有“完整运行”特性。
注:如果 longTimeTask 的执行需要 100 秒(这在 JavaScript 中是应该避免的!),则“This is A”至少要等 100 秒才有可能被输出(这显然和 setTimeout 的初衷不符)。
从这个例子可以看出: JavaScrpt 不适合 CPU 密集型的任务,适合 I/O 密集型的任务。
1.5. 并发中的交互:gate
// 我们期望两个ajax请求都完成后(即foo,bar都被调用后),再通过baz输出a+b的值
// 下面演示代码是错误的,baz会调用两次,只有最后一次的结果是正确的
var a, b;
function foo(x) {
a = x * 2;
baz();
}
function bar(y) {
b = y * 2;
baz();
}
function baz() {
console.log(a + b);
}
// ajax(..)是某个库中的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);
如何实现两个 ajax 请求都完成后,再调用 baz 呢?请看下面代码:
// 我们期望两个ajax请求都完成后(即foo,bar都被调用后),再通过baz输出a+b的值
// 使用gate后,可以保证两个ajax请求都完成后再调用一次baz
var a, b;
function foo(x) {
a = x * 2;
if (a && b) { // if (a && b) 被称为gate,这里写为 if (b) 也行
baz();
}
}
function bar(y) {
b = y * 2;
if (a && b) { // if (a && b) 被称为gate,这里写为 if (a) 也行
baz();
}
}
function baz() {
console.log(a + b);
}
// ajax(..)是某个库中的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);
包裹 baz 调用的条件判断 if (a && b) 称为门(gate),我们虽然不能确定 a 和 b 到达的顺序,但是会等到它们两个都准备好再进一步打开门(调用 baz)。
1.6. 并发中的交互:latch
// 我们期望两个ajax请求中的其中一个完成后,就通过baz输出a的值
// 下面演示代码是错误的,baz会调用两次,但我们只想要第一次的输出结果
var a;
function foo(x) {
a = x * 2;
baz();
}
function bar(x) {
a = x / 2;
baz();
}
function baz() {
console.log( a );
}
// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
如何实现两个 ajax 请求中的其中一个完成后,就通过 baz 输出 a 的值呢?请看下面代码:
// 我们期望两个ajax请求中的其中一个完成后,就通过baz输出a的值
// 使用latch后,可以保证仅第一个ajax请求完成后,输出对应a值
var a;
function foo(x) {
if (!a) { // if (!a) 被称为latch
a = x * 2;
baz();
}
}
function bar(x) {
if (!a) { // if (!a) 被称为latch
a = x / 2;
baz();
}
}
function baz() {
console.log( a );
}
// ajax(..)是某个库中的某个Ajax函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );
条件判断 if (!a) 称为门(latch),它使得只有 foo 和 bar 中的第一个可以通过,第二个(实际上是任何后续的)调用会被忽略。也就是说,第二名没有任何意义!
2. 回调函数
回调函数是 JavaScript 中最基本的异步模式。
考虑下面这段代码:
// A
setTimeout(function(){
// C
}, 1000 );
// B
其中,代码“// C”部分就是回调函数的内容。
2.1. 问题一:回调地狱(缺乏顺序性)
考虑下面代码:
// 回调地狱,代码难以理解、追踪、调试和维护!
listen( "click", function handler(evt) {
setTimeout( function request() {
ajax( "http://some.url.1", function response(text){
if (text == "hello") {
handler();
}
else if (text == "world") {
request();
}
} );
}, 500) ;
} );
这种代码常常被称为回调地狱(callback hell),有时也被称为毁灭金字塔(pyramid of doom,得名于嵌套缩进产生的横向三角形状)。
这样的代码和人大脑的顺序思考问题的方式相违背,这使得代码变得更加难以理解、追踪、调试和维护。
不过,“横向三角形状”只是一个表相,我们可以把上面代码改写为:
// 尽管不是“横向三角形状”代码,但也难以理解、追踪、调试和维护!
listen( "click", handler );
function handler() {
setTimeout( request, 500 );
}
function request(){
ajax( "http://some.url.1", response );
}
function response(text){
if (text == "hello") {
handler();
}
else if (text == "world") {
request();
}
}
上面代码没有“横向三角形状”式的缩进,但它也是“回调地狱”,代码难以理解、追踪、调试和维护!
2.2. 问题二:信任问题(控制反转问题)
// A
ajax( "...", function(...){
// C
});
// B
代码“// A”和“// B”发生于现在,在 JavaScript 主程序的直接控制之下。而代码“// C”会延迟到将来发生,并且是在第三方的控制下——在本例中就是函数 ajax(...)。我们把这称为“控制反转(inversion of control)”,也就是把自己程序一部分的执行控制交给某个第三方,但第三方不一定可靠!
下面将通过一个具体的例子来说明回调的信任问题。
假设你是一名开发人员,为某个销售昂贵电视的网站建立商务结账系统。你已经做好了结账系统的各个界面。在最后一页,当用户点击“确定”就可以购买电视时,你需要调用(假设由某个分析追踪公司提供的)第三方函数以便跟踪这个交易。
可能是为了提高性能,他们提供了一个看似用于异步追踪的工具,这意味着你需要传入一个回调函数。在这个函数中你需要提供向客户收费和展示感谢页面的最终代码。
代码可能是这样:
analytics.trackPurchase( purchaseData, function(){
chargeCreditCard();
displayThankyouPage();
});
很简单,是不是?你写好代码,通过测试,一切正常,然后就进行产品部署。皆大欢喜!
突然,某一天你的老板惊慌失措地打电话让你赶紧到办公室,说你们的一位高级客户购买了一台电视,信用卡却被刷了五次!老板生气地说:“这种情况你没有测试过吗?!”
通过分析日志,你得出一个结论:唯一的解释就是那个第三方分析工具出于某种原因把你的回调函数调用了五次而不是一次。沮丧的你联系他们的客服,而客服显然和你一样吃惊。他们保证,一定会向开发者提交此事,之后再给你回复。第二天,你收到一封很长的信,信中他们确定了确实是他们的问题,且充满了歉意,并保证绝不会再发生同样的事故等等。
你和老板讨论此事,老板说我们不能再信任他们了。你需要找到某种方法来保护结账代码,保证不再出问题。经过思考后,你实现了像下面这样的代码:
var tracked = false;
analytics.trackPurchase( purchaseData, function(){
if (!tracked) {
tracked = true;
chargeCreditCard();
displayThankyouPage();
}
});
不过,后来有一个 QA 工程师问道:“如果他们根本不调用这个回调函数怎么办?”
然后,你开始沿着这个兔子洞深挖下去,考虑着他们调用你的回调时所有可能的出错情况。这里粗略列出了你能想到的分析工具可能出错的情况:
• 调用回调过早(在追踪之前);
• 调用回调过晚(或没有调用);
• 调用回调的次数太少或太多(就像你遇到过的问题!);
• 没有把所需的环境/参数成功传给你的回调函数;
• 吞掉可能出现的错误或异常;
• ......
现在你应该更加明白回调地狱是多像地狱了吧。
3. Promise
通过前面内容可知, 通过回调函数来表达程序异步模式和管理并发存在两个主要缺陷:缺乏顺序性和可信任性。 使用 Promise 可以解决这两个缺陷。
Promise 中链式的 then 调用比回调函数看起来更具有顺序性,代码更易维护。Promise 更深刻的好处在于它解决了回调函数存在的信任问题。Promise 没有摈弃回调,只是把回调的安排转交给了一个位于我们和其他工具之间的可信任的中介机制。
3.1. Promise 可解决信任问题
在节 2.2 中介绍过,回调函数存在严重的信任问题。如把一个回调函数传入工具 foo(...)时可能出现如下问题:
• 调用回调过早;
• 调用回调过晚(或没有调用);
• 调用回调的次数太少或太多;
• 没有把所需的环境/参数成功传给你的回调函数;
• 吞掉可能出现的错误或异常;
• ......
下面解释为什么 Promise 可以防止回调被“过多”地调用。根据 Promise 的定义, Promise 对象的状态一旦改变,就不会再变化。 Promise 创建代码试图调用 resolve(...)或 reject(...) 多次,或者试图两者都调用,那么这个 Promise 将只会接受第一次决议(决议就是状态改变过程),并默默地忽略任何后续调用。
关于 Promise 解决其它信任问题的说明可参考:You Don't Know JS: Async & Performance, 3.3 Promise Trust
3.2. Promise 模式
3.2.1. Promise.all(可取代 gate)
Promise.all()方法将多个 Promise 实例包装为新的 Promise 实例。
下面是 Promise.all()的应用实例:
// add接收两个Promise,返回Promise
function add(xPromise, yPromise) {
// Promise.all([ .. ])接受一个promise数组并返回一个新的promise,
// 这个新promise等待数组中的所有promise完成
return Promise.all( [xPromise, yPromise] )
.then( function(values){ // 这个promise决议后,我们把收到的X和Y值加一起
return values[0] + values[1]; // values是来自于之前决议的promise的消息数组
} );
}
add(fetchX(), fetchY()) // fetchX(), fetchY()是返回Promise的函数
.then( function(sum) {
console.log(sum);
});
如果不使用 Promise,使用传统的回调函数机制,我们需要使用 gate 的技巧。其代码类似于(节 1.5 有类似的代码):
// 用传统的回调
function add(getX,getY,cb) {
var x, y;
getX( function(xVal){
x = xVal;
// 两个都准备好了?
if (y != undefined) { // 这里是gate,确保两个数据都准备好了才调用cb
cb( x + y );
}
} );
getY( function(yVal){
y = yVal;
// 两个都准备好了?
if (x != undefined) { // 这里是gate,确保两个数据都准备好了才调用cb
cb( x + y );
}
} );
}
add(fetchX, fetchY, function(sum){
console.log(sum);
});
在经典的编程术语中,门(gate)是这样一种机制要等待两个或更多并行/并发的任务都完成才能继续。它们的完成顺序并不重要,但是必须都要完成,门才能打开并让流程控制继续。
3.2.2. Promise.race(可取代 latch)
尽管 Promise.all()协调多个并发 Promise 的运行,并假定所有 Promise 都需要完成,但有时候你会想只响应“第一个跨过终点线的 Promise”,而抛弃其他 Promise。这种模式传统上称为门闩(latch),但在 Promise 中称为竞态(race)。
Promise.race()也接受单个数组参数。这个数组由一个或多个 Promise,一旦有任何一个 Promise 决议为完成,Promise.race()就会完成;一旦有任何一个 Promise 决议为拒绝,它就会拒绝。
下面是 Promise.race 的例子:
// request(..)是一个支持Promise的Ajax工具,即它返回Promise
var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );
Promise.race( [p1,p2] )
.then( function(msg){
// p1或者p2将赢得这场竞赛
console.log( msg );
} );
如果不使用 Promise,使用传统的回调函数机制,我们需要使用 latch 的技巧,可以参考节 1.6 ,这里不详细写代码。
3.2.3. Promise.all 和 Promise.race 的变体
Promise.all()和 Promise.race()还有其他几个常用的变体模式:
- none()
- 这个模式类似于 all(),不过完成和拒绝的情况互换了。所有的 Promise 都要被拒绝,即拒绝转化为完成值,反之亦然。
- any()
- 这个模式与 all() 类似,但是会忽略拒绝,所以只需要完成一个而不是全部。
- first()
- 这个模式类似于与 any()的竞争,即只要第一个 Promise 完成,它就会忽略后续的任何拒绝和完成。
- last()
- 这个模式类似于 first(),但却是只有最后一个完成胜出。
3.3. Job Queue
考虑下面代码:
console.log("A");
setTimeout(() => {
console.log("A - setTimeout 1");
}, 0);
new Promise((resolve) => {
console.log("Promise 1, A");
resolve();
})
.then(() => {
return console.log("Promise 1, B");
});
console.log("AAA");
它的输出为:
A Promise 1, A AAA Promise 1, B A - setTimeout 1
由于 Promise 的参数会同步执行, 所以下面三行会先输出:
A Promise 1, A AAA
由于 Promise.then 中的任务放在 Job Queue 中,Job Queue 中的任务比 Event Loop Queue 中的任务的优先级高,所以“Promise 1, B”比“A - setTimeout 1”先输出。
注:ES6 中新增了 Job Queue 的概念,它与 Promise 的执行有关,可以理解为等待执行的任务。 Job Queue 中的任务比 Event Loop Queue 中的任务的优先级高。
Job Queue 与 Event Loop Queue 的相同点:都是先进先出队列。
Job Queue 与 Event Loop Queue 的不同点:
(1). 每个 JavaScript Runtime 可以有多个 Job Queue,但只有一个 Event Loop Queue。
(2). 当 JavaScript Engine 处理完当前块后,优先执行所有的 Job Queue,然后再处理 Event Loop Queue。
考虑下面代码:
console.log("A");
setTimeout(() => {
console.log("A - setTimeout 1");
}, 0);
setTimeout(() => {
console.log("A - setTimeout 2");
}, 0);
new Promise((resolve) => {
console.log("Promise 1, A");
resolve();
})
.then(() => {
return console.log("Promise 1, B");
})
.then(() => {
return console.log("Promise 1, C");
});
new Promise((resolve) => {
console.log("Promise 2, A");
resolve();
})
.then(() => {
return console.log("Promise 2, B");
})
.then(() => {
return console.log("Promise 2, C");
})
.then(() => {
return console.log("Promise 2, D");
});
console.log("AAA");
它的输出为:
A Promise 1, A Promise 2, A AAA Promise 1, B Promise 2, B Promise 1, C Promise 2, C Promise 2, D A - setTimeout 1 A - setTimeout 2
理解这个输出:
前面 4 行最先输出,因为它们不是异步任务,属于第一个 chunk。
Promise 1 与 Promise 2 先于 setTimeout 执行,因为 Job Queue 的执行优先于 Event Loop Queue。
Promise 1 与 Promise 2 各自的输出都是顺序的,因为 Job Queue 是先进先出队列,同一 Job Queue 中的任务顺序执行。
Promise 1 与 Promise 2 的后续任务是交错的,因为 Promise 1 与 Promise 2 都是独立的 PromiseJob(Job 的其中一种),属于不同的 Job Queue,它们之间的顺序规范中没有规定。
4. Generator
Javascript 中的生成器(Generator)和 Python 中的生成器很相似。
Javascript 中 Generator 的语法是在关键字 function 和生成器名字之间增加一个星号(*)。 如:
// Javascript中Generator简单例子
function* idMaker() { // 星号靠近函数名也可以,即function *idMaker() {
console.log("begin idMaker");
var index = 0;
while(true) {
yield index++; // yield只能用于 Generator 中
}
}
var gen = idMaker(); // 返回迭代器(不会马上执行),在迭代器上调用next时,Generator会执行到遇到yield或者return。
console.log("call next 1st");
var res=gen.next(); // next()调用的结果是一个对象,它有一个value属性,保存着idMaker返回的值(yield后的表达式)
console.log(res.value); // 输出 0
console.log("call next 2nd");
res=gen.next();
console.log(res.value); // 输出 1
console.log("call next 3rd, and more");
console.log(gen.next().value); // 输出 2
console.log(gen.next().value); // 输出 3
console.log(gen.next().value); // 输出 4
上面代码会输出:
call next 1st begin idMaker 0 call next 2nd 1 call next 3rd, and more 2 3 4
在迭代器对象上的 next() 方法返回两个属性, value 和 done ,下面再看一个 Generator 的例子:
function* gen() {
yield 1;
yield 2;
yield 3;
}
var g = gen();
console.log(g.next());
console.log(g.next());
console.log(g.next());
console.log(g.next());
上面代码会输出:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: 3, done: false }
{ value: undefined, done: true }
4.1. Generator 作为对象属性
下面是 Generator 作为对象属性的例子:
const someObj = {
*generator () {
yield 'a';
yield 'b';
}
}
const gen = someObj.generator()
console.log(gen.next()); // { value: 'a', done: false }
console.log(gen.next()); // { value: 'b', done: false }
console.log(gen.next()); // { value: undefined, done: true }
4.2. Generator 作为对象方法
下面是 Generator 作为对象方法的例子:
class Foo {
*generator () {
yield 1;
yield 2;
yield 3;
}
}
const f = new Foo ();
const gen = f.generator();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }
4.3. 传递参数
yield 表达式本身没有返回值,或者说总是返回 undefined。 next() 方法可以带一个参数,该参数就会被当作上一个 yield 表达式的返回值。如:
function* gen() {
let a = yield 1;
let b = yield (2 + a);
yield (3 + b);
}
var g = gen();
console.log(g.next().value); // 第一次调用next时,传递参数是无效的(会被忽略)
console.log(g.next(100).value); // 这使得上一个yield表达式的返回值为100,即a为100
console.log(g.next(200).value); // 这使得上一个yield表达式的返回值为200,即b为200
上面代码会输出:
1 102 203
由于 next() 方法的参数表示上一个 yield 表达式的返回值,所以在第一次使用 next() 方法时,传递参数是无效的(会被忽略)。从语义上讲,第一个 next() 方法用来启动遍历器对象,所以不用带有参数。
4.4. Generator 的应用
5. async/await
从字面意思看成,async 是“异步”的简写,而 await 可以认为是“async wait”的简写。除 Javascript 外,async/await 在其他语言(如 C#和 Python 3.5)中已经被支持。
async/await 是 ES2017 中提出的,本节代码的测试在 nodejs 8+中完成。
5.1. async
关键字 async 用于声明一个函数是异步的(这个异步函数执行时会返回一个 Promise)。
async function name([param[, param[, ... param]]]) {
statements
}
下面是 async 的例子:
async function testAsync() {
return 1;
}
const result = testAsync(); // result是一个Promise
result.then(v => {
console.log(v); // 输出1
});
5.2. await
操作符 await 用于等待一个异步函数执行完成, await 操作符只能用在 async 声明的函数内。
[rv] = await expression;
其中,“expression”是 A Promise or any value to wait for the resolution. 而返回值“rv”是 resolved value of the promise, or the value itself if it's not a Promise.
下面是 async/await 的例子:
function resolveAfter2Seconds(x) {
return new Promise(resolve => {
setTimeout(() => {
resolve(x);
}, 2000);
});
}
async function add(x) {
var a = await resolveAfter2Seconds(20); // 在这个Promise决议前,后面代码不会被执行
var b = await resolveAfter2Seconds(30); // 在这个Promise决议前,后面代码不会被执行
return x + a + b;
}
add(10).then(v => {
console.log(v); // prints 60 after 4 seconds.
});
async function add1(x) {
var a = resolveAfter2Seconds(20);
var b = resolveAfter2Seconds(30);
return x + await a + await b; // 在两个await等待的两个Promise决议前,后面代码(return语句)不会被执行
}
add1(10).then(v => {
console.log(v); // prints 60 after 2 seconds.
});
5.3. 用 async/await 代替 Promise 代码
假设有下面代码:
function getProcessedData(url) {
return downloadData(url) // returns a promise
.catch(e => {
return downloadFallbackData(url); // returns a promise
})
.then(v => {
return processDataInWorker(v); // returns a promise
});
}
上面代码可以改写为 async/await 的形式:
async function getProcessedData(url) {
let v;
try {
v = await downloadData(url);
} catch(e) {
v = await downloadFallbackData(url);
}
return processDataInWorker(v);
}
改写完了以后可以发现, async/await 可以使代码流程更加清晰,使“异步”代码看起来更像“顺序执行”的代码。
5.3.1. 实例:回调、Promise 和 async/await 三种写法的比较
下面是模拟一个 Mongo 数据库操作的三种写法:回调、Promise 和 async/await。
// 方式一:回调写法
mongoDb.open(function(err, db) {
if(!err) {
db.collection("users", function(err, collection) {
if(!err) {
let person = {name: "yika", age: 20};
collection.insert(person, function(err, result) {
if (!err) {
console.log(result);
}
});
}
})
}
});
// 方式二:Promise写法
let person = {name: "yika"};
mongoDb
.open()
.then(function(database) {
return database.collection("users");
})
.then(function(collection) {
return collection.insert(person);
})
.then(function(result) {
console.log(result);
})
.catch(function(e) {
throw new Error(e);
})
// 方式三:async/await写法
async function insertData(person){
let db, collection, result;
try {
db = await mongoDb.open();
collection = await db.collection("users");
result = await collection.insert(person);
} catch(e) {
console.error(e.message);
}
console.log(result);
}
insertData({name: "yika"});
从例子中可以看出,async/await 写法使 Javascript 更加接近于同步编程的风格。
6. 参考
You Don't Know JS: Async & Performance