理解Promise的链式调用和async/await关键字

Promise 是2015年推出的 ECMAScript 6 中最重要的一个特征之一。Async/await 则是 AsyncFunction 特性中的关键字。异步在 JavaScript 中是很常用的,一是因为 JavaScript 是单线程运行的,二是因为JavaScript主要服务于Web,而Web中经常有需要异步处理的情景(如 AJAX)。Promise 和 async/await 关键字组合起来,可以极大地简化异步逻辑的代码,使代码更易阅读、维护。有关两者,网上已经有很多文章,也讲得十分清晰(比如关于 Promise 的这篇关于 async/await 的这篇),这里便不再赘述。本文主要记录我初学这两个概念时感到不易理解的几个点,以及后来我自行理解它们的方法。

问题

Promise 很优雅的一点就是它支持链式调用。这样一来,原先层层相套的回调可以写成更加规整的链式调用。比如以下就是一个简单的链式调用的例子:

new Promise(resolve => {
	setTimeout(() => {
		console.log('one');
		resolve();
	}, 1000);
}).then(() => {
	console.log('two');
});

// 输出:
// one
// two

首先,我们创建了一个 Promise 的实例,并且传入一个函数,这个函数接受一个参数 resolve,并且延时 1秒后调用了 resolveresolve 的功能其实就是将这个 Promise 的状态设置为 fulfilled。当这个 Promise 的状态转为 fulfilled 后,第一个 then() 方法中的函数就会被执行。因此我们会得到上面的输出。

但是,当我想串联多个 then() 方法、且其中含有耗时(异步调用)操作时,链式调用似乎就不再按照顺序执行了。比如,以下面的代码为例:

new Promise(resolve => {
	setTimeout(() => {
		console.log('one');
		resolve();
	}, 1000);
}).then(() => {
	setTimeout(() => {
		console.log('two');
	}, 1000);
}).then(() => {
	setTimeout(() => {
		console.log('three');
	}, 500);
});

// 输出:
// one
// three
// two

可以看到, three 先于 two 被打印了出来。这是为什么呢?如何才能让 two 先被打印出来呢?

解答

上面的问题困扰了我很久,直到我看到了 async/await 关键字。其实,这对关键字在 C# 中也有出现——

无论是在 C# 还是 JavaScript 中,async/await 都是非常棒的特性,它们也都是非常甜的语法糖。C# 的 async/await 实现离不开 Task 或 Task<Result> 类,而 JavaScript 的 async/await 实现,也离不开 Promise

——摘自https://segmentfault.com/a/1190000007535316

async关键字

Async 关键字是用来修饰函数的,它的功能是——如果函数返回了 Promise 对象,async 关键字什么也不会做;但如果函数返回了非 Promise 对象,它就起到了自动封装器的作用,它会用 Promise.resolve() 把返回值封装起来。因此,它好比一个约束,约束了函数返回类型一定是 Promise。以下用一些代码举例:

(() => 1)();
// 输出:1

(() => Promise.resolve(1))();
// 输出:Promise {<resolved>: 1}

(async () => 1)();
// 输出:Promise {<resolved>: 1}

(async () => Promise.resolve(1))();
// 输出:Promise {<resolved>: 1}

await关键字

Await关键字可以看作一个运算符,它后面连接的是一个表达式——如果表达式的计算结果是非 Promise 对象,那么 await 就不会起到实际作用;如果表达式的计算结果是 Promise 对象,那么 await 就会等待这个 Promise 被 resolve,然后得到 resolve 的值。以下也用一些代码举例:

let a = 1;
// a = 1

let b = await 1;
// b = 1

let c = Promise.resolve(1);
// c = Promise {<resolved>: 1}

let d = await Promise.resolve(1);
// d = 1

then()和async关键字的相似性

在了解了 async/await 关键字后,我就能更好地理解链式调用中的 then() 了。用易于理解的方法来说,then() 所做的事情和 async/await 关键字非常接近——首先,它等待前面的 Promise 被 resolve;然后,它接受一个函数作为参数,和 async 关键字一样的,如果该函数返回的不是 Promise 对象,将其封装成 Promise 对象;最后,它返回这个 Promise 对象,交由接下来的 then() 处理。当然这段解释是我由果索因的揣测,有关 then() 的语法可以阅读Promise.prototype.then(),里面有十分严谨而详细的解释,例如,我在写这篇文章时意外发现 then() 在接受非函数对象作为参数时会有很不同的行为……

再返回去看看之前的代码,发现能够说得通了。我传给第一个 then() 的,是一个匿名函数;第二个 then() 会像 await 关键字一样,等待这个 Promise 被 resolve。因为 setTimeout() 中的回调函数是在指定的延时之后异步执行的,这段代码本身会立即返回;由于它没有返回值,第一个 then() 会把它封装成 Promise.resolve(undefined),第二个 then() 也会立即等到这个值。而第一个 then() 中的延时要高于第二个 then() 中的,这就是为什么 three 要先于 two 被打印出来。

将回调封装成Promise

那么现在我需要做的就是将回调封装成 Promise 的格式。这并不复杂,还是以之前的代码为例,如果把所有的回调都用 Promise 来写:

new Promise(resolve => {
	setTimeout(() => {
		console.log('one');
		resolve();
	}, 1000);
}).then(r => new Promise(resolve => {
	setTimeout(() => {
		console.log('two');
		resolve();
	}, 1000);
})).then(r => new Promise(resolve => {
	setTimeout(() => {
		console.log('three');
		resolve();
	}, 500);
}));

// 输出:
// one
// two
// three

这样我就得到了期望的运行结果。想要在 Promise 之间传递值也很简单,前一个 Promise resolve 的值就会在下一个 then() 的回调函数中作为参数传入(这里是 r)。当然,根据 MDN web docs 的示例代码,then() 是用来获取 async 函数的运行结果的,链式执行多个异步任务的使命可以由 async/await 关键字更好地完成,还是以之前的代码,可以写成这样:

async function myFunc() {
	let r = await new Promise(resolve => {
		setTimeout(() => {
			console.log('one');
			resolve();
		}, 1000);
	});
	r = await new Promise(resolve => {
		setTimeout(() => {
			console.log('two');
			resolve();
		}, 1000);
	});
	r = await new Promise(resolve => {
		setTimeout(() => {
			console.log('three');
			resolve();
		}, 500);
	});
}

myFunc().then();

// 输出:
// one
// two
// three

相同的输出结果,但代码更加简洁了。

async/await的调用死循环

语法上,关于 async/await 关键字有这么一个限制——await 关键字只能出现在 async 函数中,而 async 关键字约束了函数返回值类型只会是 Promise 对象,因此我们需要用 await 关键字来等它的结果。那是不是就形成了一个死循环呢?不是的,就像之前说的那样,在最外层的函数中,async 函数的返回值需要用 then() 来获取;否则假使在最外层我们还是得阻塞式地等待异步函数的运行结果的话,那异步也就没有意义了,不是吗?