原文链接 The Evolution of Async JavaScript: From Callbacks, to Promises, to Async/Await
注:本文为我的课程《高级 JavaScript》中的一部分,如果你喜欢本文,欢迎你来看看我的课程。
BerkshireHathaway.com 是我最喜欢的网站之一,因为它简单、高效,而且自从 1997 年创建以来一直运行良好。更🐂🍺的是在过去的二十年里这个网站很有可能从未出现过 bug。
为啥?因为这个网站是纯静态的,它自20多年前推出以来几乎从没变过样子。
也就是说如果你预先拥有所有数据,那么建站就会变得非常简单。不幸的是,现如今的大多数站点都不是这样的。为了弥补这方面的缺点,我们为我们的系统发明了各种「模式」来应对这种需要获取外部数据的情况。
与其他事物一样,这些模式随着时间的推移都会权衡各自不同的侧重点。本文将详细拆解Callbacks
, Promises
, 和Async/Await
这三种最常见模式的优缺点,同时在其历史背景下探讨一下这种演进产生的意义及进步之处。
Callbacks
这里会假设你完全不了解何为 callbacks。要是我假设有误,稍微向下滑一下即可。
在我刚开始学习编程的时候,我把函数看成一台机器。这些机器可以做任何你想让他们完成的工作。甚至是接收一个输入然后返回一个值。每台机器都有一个按钮,你可以在你想让他们运转的时候按下这个按钮,在函数里,也就是()
。
function add (x, y) {
return x + y;
}
add(2,3)
这个按钮是何时被谁按下的并不重要,机器只管去运行。
function add (x, y) {
return x + y
}
const me = add
const you = add
const someoneElse = add
me(2,3) // 5 - 我按下了按钮,启动了该机器
you(2,3) // 5 - 你按下了按钮,启动了该机器
someoneElse(2,3) // 5 - 路人甲按下了按钮,启动了该机器
在上面的代码中我们将函数add
赋值给了不同的变量me
、you
和someoneElse
,值得注意的是,原函数add
和我们定义的三个变量都指向的是同一块内存。实际上它们是同一个东西,只不过有不同的名字。因此当我们调用me
、you
和someoneElse
时,就好像我们在调用add
一样。
现在,要是我们把add
这台机器传入到其他机器中去会发生什么?记住,谁按下了()
按钮并不重要,重要的是只要按钮被按下,机器就会运行起来。
function add (x, y) {
return x + y
}
function addFive (x, addReference) {
return addReference(x, 5) // 15 - 按下按钮,启动机器
}
addFive(10, add) // 15
第一眼看到这个代码的时候你可能会感觉有点儿奇怪,实际上并没有啥新东西在里面。我们并没有直接按下函数add
的启动按钮,而是将函数add
作为参数传给了函数addFive
,把add
重命名为addReference
,然后我们「按下按钮」,或者说是调用了它。
这就引出了 JavaScript 中一个比较重要的概念。首先,正如你能将字符串或数字作为参数传给函数一样,你也可以把函数的引用当做参数传给函数,我们将这种操作方式中的「函数参数」称为 callback (回调函数),而接收「函数参数」的函数称之为 高阶函数。
为了体现语义化的重要性,我们将上面的代码重新命名来表示这个概念:
function add (x,y) {
return x + y
}
function higherOrderFunction (x, callback) {
return callback(x, 5)
}
higherOrderFunction(10, add)
这种模式是不是很熟悉?它随处可见呀。只要你用过 JavaScript 中 的数组方法、 loadsh 或者 jQuery ,那就说明你已经使用过 callback 了。
[1,2,3].map((i) => i + 5)
_.filter([1,2,3,4], (n) => n % 2 === 0 );
$('#btn').on('click', () =>
console.log('Callbacks are everywhere')
)
一般来说,callbacks 具有两种典型用法。第一种就是我们上面.map
和_.filter
的例子,这是一种将一个值计算为另一个值的较为优雅的抽象化方法。我们只需告诉它「嘿,我给你一个数组和一个函数,你用我提供给你的这个函数帮我返回一个新的值吧」。第二种用法就是上面给出的 jQuery 示例,即延迟执行一个函数直到某一特定时机。大概意思是说「嘿,给你个函数,只要 id 为 btn
的元素被点击了你就帮我执行它」。
现在,我们仅仅看到了同步执行的示例。但正如我们在文章开头时说到的:我们开发的大多数应用中都不具备其需要的所有数据。当用户与我们的应用进行交互时,应用需要获取外部数据。由此我们已经看到了 callback 的价值所在,延迟执行一个函数直到某一特定时机。
我们无需花费太多想象力就可以明白实践中是如何贯彻上面那句话来进行数据获取的。甚至是用来延迟执行一个函数,直到我们拿到了所需的数据。来看一个我们之前经常用到的例子,jQuery 的getJSON
方法:
// 假设函数 updateUI 和 showError 已经定义过,功能如其函数名所示
const id = 'tylermcginnis'
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: updateUI,
error: showError,
})
在我们获取到该用户的数据之前,我们并不能更新应用的 UI。那么我们是怎么做的呢?我们对它说「嘿,给你个对象(非女朋友,别想太多),如果这次请求成功了就去调用success
函数,同时把请求来的用户数据传给它;要是失败了,直接调用error
并把错误信息传给它就行了。你不用关心每一个函数的作用具体是啥,确保在你该调用他们的时候就去调用即可」。这就是利用回调函数来进行异步请求的一个很好的示例。
这一部分我们已经知道了 callbacks 是什么以及在同步/异步代码中使用他们带来的好处。但我们还不知道使用回调函数的缺点是啥。来看一看下面的代码,你知道发生了什么吗?
// 假设函数 updateUI、showError 和 getLocationURL 已经定义过,功能如其函数名所示
const id = 'tylermcginnis'
$("#btn").on("click", () => {
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: (user) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success (weather) {
updateUI({
user,
weather: weather.query.results
})
},
error: showError,
})
},
error: showError
})
})
如果对你有帮助的话,可以在 CodeSandbox 中看到完整的可运行代码。
你可能已经发现,这里已经添加了很多层回调函数,首先我们告诉程序在 id 为 btn
的按钮被点击之前不要发起 AJAX 请求,等到按钮被点击后,我们才发起第一个请求。若该请求成功,我们就调用updateUI
方法并传入前两个请求中获取的数据。无论你第一眼看时是否理解了上面的代码,客观的说,这样的代码比之前的难读多了。于是引出了「回调地狱」这一话题。
作为人类,我们的天性就是按顺序思考。当代码中的回调函数一层又一层嵌套时,就迫使你要跳出这种自然的思考方式。当代码的阅读方式与你自然的思考方式断开连接之后,bug 就产生了。
就像大多数软件问题的解决方案一样,一个常规化的解决方法就是将你的回调地狱进行模块化。
function getUser(id, onSuccess, onFailure) {
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: onSuccess,
error: onFailure
})
}
function getWeather(user, onSuccess, onFailure) {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success: onSuccess,
error: onFailure,
})
}
$("#btn").on("click", () => {
getUser("tylermcginnis", (user) => {
getWeather(user, (weather) => {
updateUI({
user,
weather: weather.query.results
})
}, showError)
}, showError)
})
CodeSandbox 中有完整代码。
OK,函数名帮助我们理解了到底发生了什么。但是讲真的,问题真的解决了吗?并没有。我们仅仅是解决了回调地狱中可读性的问题。即使是写成了单独的函数,我们顺序思考的天性依然被层层嵌套打断了。
回调函数的下一个问题就要提到「控制反转」了。你写下一个回调函数,假设你将回调函数传给的那个程序是可靠的,能在它该调用的时候进行调用。这实际上就是将程序的控制权转交给另一个程序。
当你使用类似 jQuery 或 loadsh 等第三方库,甚至是原生 JS 时,回调函数会在正确的时间使用正确的参数被调用的假设是合理的。然而,回调函数是你与大多数第三方库交互的接口,不管是有意还是无意,第三方库都有中断与回调函数交互的可能。
function criticalFunction () {
// It's critical that this function
// gets called and with the correct
// arguments.
}
thirdPartyLib(criticalFunction)
由于你并不是唯一调用criticalFunction
的那一个,你对参数的调用完全没有控制权。大多数时候这都不是个问题,如果是的话,那问题可就大了。
Promise
你有没有过未曾预约的情况下去一个非常火爆的餐厅吃饭?遇到这种情况时,餐厅会在有空位的时候通过某种方式联系你。一种古老的方式就是服务人员会记下你的名字,在有空位的情况下喊你。随着时代的进步,他们也开发出了新花样,记下你的电话号码以备有空位时短信通知你,这就允许你不用在餐厅门口死守了,还有最重要的一点就是他们可以在任何时候往你的手机推送广告。
听起来熟悉不?这就是 callbacks 的一种比喻啊。就像把一个回调函数传给第三方服务一样,我们把自己的号码传给了餐厅。你期望的是餐厅有空位时联系你,就像你期望第三方服务在某个时刻以某种方式调用你的回调函数一样。一旦你的号码或者说回调函数落在他们手中,你就对其失去了控制。
幸运的是,现在有了另一种解决方案。这种设计方案允许你保留所有控制权。你可能之前已经体验过了,餐厅可能会给你一个蜂鸣器,类似这种:

这个蜂鸣器会一直处于三个不同状态之一下 —— pending
,fulfilled
或rejected
。
pending
为初始的默认状态,蜂鸣器交到你手中时就是该状态。
fulfilled
就是蜂鸣器开始闪光,通知你已有空位时的状态。
rejected
是蜂鸣器通知你可能发生了什么不顺利的事,比如餐厅就要停止营业了或者是他们把你给忘了。
再次声明,你要知道你对这个蜂鸣接收器拥有完全的控制权。蜂鸣器变为fulfilled
状态时,去不去吃饭是由你来决定的。如果变成rejected
状态,虽然体验很差但是你还可以选择其他餐厅吃饭,如果一直处于pending
状态的话,虽然你没有吃上饭但你没错过其他事情。
既然你成为了蜂鸣器的主人,那我们就来举一反三一下吧。
如果说把把你的号码给了餐厅像是传给他们一个回调函数,那么接收这个蜂鸣器就相当于接受到了所谓的「Promise(承诺)」。
按照惯例,我们依然先问问这是为啥?为什么会出现 Promise 呢?它的出现就是为了使复杂的异步请求变的可控。就像前面提到的蜂鸣器,一个Promise
拥有三种状态,pending
,fulfilled
和rejected
。和蜂鸣器不同之处在于,这里的三种状态代表的是异步请求的状态。
如果异步请求一直在执行,Promise
就会保持在pending
状态下;异步请求执行成功,Promise
状态会变为fulfilled
;异步请求执行失败,Promise
状态会变为rejected
。
你已经知道了为什么会出现 Promises 及其可能出现的三种状态,现在还有三个问题需要我们去解答:
- 如何创建一个 Promise?
- 如何改变一个 promise 的状态?
- 如何监听 promise 在何时改变了状态?
1)如何创建一个 Promise
很直接,new
出一个Promise
实例即可。
const promise = new Promise();
2)如何改变一个 promise 的状态?
Promise
构造函数接收一个(回调)函数作为参数,该函数接收两个参数,resolve
和reject
。
resolve
- 允许你将 promise 的状态改为fulfilled
的函数;
reject
- 允许你将 promise 的状态改为rejected
的函数。
下面的代码示例中,我们使用setTimeout
延时 2s 后调用resolve
。即可将 promise 状态变成fulfilled
.
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve() // 改变状态为 'fulfilled'
}, 2000)
})
具体改变过程看下面的过程:

<pending>
变为 <resolved>
。
3)如何监听 promise 在何时改变了状态?
我认为这才是最关键的问题。知道如何创建 promise 或者是改变它的状态当然有用,但要是不知道在 promise 状态发生改变后如何去执行一些操作的话,那还是没啥用。
实际上到现在我们还没有提到 promise 到底是个什么东西。你new Promise
的时候,仅仅是创建了一个普通的 JavaScript 对象。该对象可以调用then
和catch
两个方法,关键就在这里。当 promise 的状态变为fulfilled
,传入到.then
方法中的函数就会被调用。要是 promise 的状态变为rejected
,那么传入到.catch
方法中的函数就会被调用。这意思就是一旦你创建了一个 promise,一旦异步请求成功就执行你传入到.then
中的函数,失败就执行传入到.catch
中的函数。
看一个例子,这里再次使用setTimeout
来延迟 2s 改变 promise 的状态为 fullfilled
:
function onSuccess () {
console.log('Success!')
}
function onError () {
console.log('💩')
}
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve()
}, 2000)
})
promise.then(onSuccess)
promise.catch(onError)
运行上面的代码你会发现,2s 后控制台会输出Success!
,再次梳理这个过程:首先我们创建了一个 promise,并在 2s 后调用了 resolve
函数,这一操作将 promise 的状态改变为 fulfilled
。然后,我们把onSuccess
函数传给了 promise 的 .then
方法,通过这步操作我们告诉 promise 在 2s 后状态变为fulfilled
时执行onSuccess
函数。
现在我们假装程序发生了点儿意外,promise 的状态变为 rejected
,从而我们可以调用reject
方法。
function onSuccess () {
console.log('Success!')
}
function onError () {
console.log('💩')
}
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
reject()
}, 2000)
})
promise.then(onSuccess)
promise.catch(onError)
自己执行看看发生了什么吧。
到这儿你已经了解了 Promise 的 API,让我们来看一点实在的代码吧。
还记得前面的异步回调的例子吗?
function getUser(id, onSuccess, onFailure) {
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: onSuccess,
error: onFailure
})
}
function getWeather(user, onSuccess, onFailure) {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success: onSuccess,
error: onFailure,
})
}
$("#btn").on("click", () => {
getUser("tylermcginnis", (user) => {
getWeather(user, (weather) => {
updateUI({
user,
weather: weather.query.results
})
}, showError)
}, showError)
})
要是我们能把上面回调嵌套的 AJAX 请求用 promise 包裹起来会怎样?这样就可以根据请求的状态来进行resolve
或reject
了。让我们从getUser
函数开始改造吧。
function getUser(id) {
return new Promise((resolve, reject) => {
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: resolve,
error: reject
})
})
}
奈斯。可以看到getUser
的参数现在只需接收 id 即可了,不再需要另外两个回调函数了,因为不需要「控制反转」了。这里使用 Primise 的resolve
和reject
函数进行替代。请求成功则执行resolve
,失败就执行reject
。
接下来我们重构getWeather
:
function getWeather(user) {
return new Promise((resolve, reject) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success: resolve,
error: reject,
})
})
}
恩,看起来还不错。接下来就要更新我们的句柄,下面是我们想要执行的工作流:
- 从 Github API 获取用户的信息;
- 从雅虎天气 API 获取由上一步所得用户信息中的地理位置信息获取其天气信息;
- 使用用户信息和天气信息更新 UI。
1)从 Github API 获取用户的信息
$("#btn").on("click", () => {
const userPromise = getUser('tylermcginnis')
userPromise.then((user) => {
})
userPromise.catch(showError)
})
getUser
不再接收两个回调函数了,取而代之的是返回给我们一个可以调用.then
和.catch
方法的 promise,这两个方法在拿到用户信息后会被调用,如果被调用的是.catch
,那就说明出错了。
2)从雅虎天气 API 获取由上一步所得用户信息中的地理位置信息获取其天气信息
$("#btn").on("click", () => {
const userPromise = getUser('tylermcginnis')
userPromise.then((user) => {
const weatherPromise = getWeather(user)
weatherPromise.then((weather) => {
})
weatherPromise.catch(showError)
})
userPromise.catch(showError)
})
可以看到用法与第一步相同,只不过我们调用的是getWeather
,传入的是我们从userPromise
中获得的user
对象。
3)使用用户信息和天气信息更新 UI
$("#btn").on("click", () => {
const userPromise = getUser('tylermcginnis')
userPromise.then((user) => {
const weatherPromise = getWeather(user)
weatherPromise.then((weather) => {
updateUI({
user,
weather: weather.query.results
})
})
weatherPromise.catch(showError)
})
userPromise.catch(showError)
})
我们的新代码看起来还不错,但是仍有需要改进的地方。在我们动手改进代码前,你需要注意 promises 的两个特性,链式调用以及resolve
和then
的传参。
链式调用
.then
和.catch
都会返回一个新的 promise。这看起来像是一个小细节但其实很重要,因为这意味着 promises 可以进行链式调用。
在下面的例子中,我们调用getPromise
会返回一个至少 2s 后resolve
的 promise。从这里开始,.then
返回的 promise 可以继续使用 .then
,直到程序抛出 new error
被.catch
方法捕获到。
function getPromise () {
return new Promise((resolve) => {
setTimeout(resolve, 2000)
})
}
function logA () {
console.log('A')
}
function logB () {
console.log('B')
}
function logCAndThrow () {
console.log('C')
throw new Error()
}
function catchError () {
console.log('Error!')
}
getPromise()
.then(logA) // A
.then(logB) // B
.then(logCAndThrow) // C
.catch(catchError) // Error!
可是为啥链式调用很重要?还记得上面讲 callbacks 的部分提到的缺点吗,回调函数强迫我们进行反自然顺序的思考,而 promises 的链式调用解决了这个问题。getPromise 运行,然后执行 logA,然后执行logB,然后...
。
这样的例子还有很多,再举一个常见的fetch
API 为例。fetch
会返回给你一个resolve
了 HTTP 响应的 promise。为了获取到实际的 JSON 数据,你需要调用.json
方法。有了链式调用的存在,我们就可以顺序思考问题了。
fetch('/api/user.json')
.then((response) => response.json())
.then((user) => {
// user is now ready to go.
})
有了链式调用后,我们来继续重构上面举过的一个例子:
function getUser(id) {
return new Promise((resolve, reject) => {
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: resolve,
error: reject
})
})
}
function getWeather(user) {
return new Promise((resolve, reject) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success: resolve,
error: reject,
})
})
}
$("#btn").on("click", () => {
getUser("tylermcginnis")
.then(getWeather)
.then((weather) => {
// 这里我们同时需要 user 和 weather
// 现在我们仅有 weather
updateUI() // ????
})
.catch(showError)
})
现在我们又遇到问题了,我们想在第二个.then
中调用updateUI
方法。问题是我们需要传给updateUI
的参数要包含 user
和 weather
两个数据。我们到这里只有 weather
,如何构造出所需的数据呢。我们需要找出一种方法来实现它。
那么关键点来了。resolve
只是个函数,你传给它的任何参数也会被传给.then
。意思就是说,在getWeather
内部,如果我们手动调用了resolve
,我们可以传给它user
和weather
。然后调用链中第二个.then
方法就会同时接收到那两个参数。
function getWeather(user) {
return new Promise((resolve, reject) => {
$.getJSON({
url: getLocationURL(user.location.split(',')),
success(weather) {
resolve({ user, weather: weather.query.results })
},
error: reject,
})
})
}
$("#btn").on("click", () => {
getUser("tylermcginnis")
.then(getWeather)
.then((data) => {
// 现在,data 就是一个对象,weather 和 user是该对象的属性
updateUI(data)
})
.catch(showError)
})
现在对比一下 callbacks,来看看 promises 的强大之处吧:
// Callbacks 🚫
getUser("tylermcginnis", (user) => {
getWeather(user, (weather) => {
updateUI({
user,
weather: weather.query.results
})
}, showError)
}, showError)
// Promises ✅
getUser("tylermcginnis")
.then(getWeather)
.then((data) => updateUI(data))
.catch(showError);
Async/Await
现在,promise 大幅增加了我们异步代码的可读性,但是我们可不可以让这种优势发挥的更好?假设你在 TC39 委员会工作,你有权给 JS 添加新特性,你会采取什么方式来继续优化下面的代码:
$("#btn").on("click", () => {
getUser("tylermcginnis")
.then(getWeather)
.then((data) => updateUI(data))
.catch(showError)
})
上面这样的代码可读性已经很强了,且符合我们大脑的顺序思维方式。另一个我们之前没涉及的问题就是,我们需要把users
信息从第一个异步请求开始一直传递到最后一个.then
中去。这倒不是个大问题,但是这让我们对getWeather
函数还进行了改造,传了users
进去。要是我们能像写同步代码一样去书写异步代码就好了。如果可行的话,上述问题就不复存在了。思路如下:
$("#btn").on("click", () => {
const user = getUser('tylermcginnis')
const weather = getWeather(user)
updateUI({
user,
weather,
})
})
看看,这样得有多舒服。异步代码看起来完全就像是同步的。我们的大脑也非常熟悉这样的思维方式,无需额外成本。显然这样是行不通的,如果我们运行上面的代码,user
和weather
仅仅是getUser
和getWeather
返回的 promises。但我们可是在 TC39 呀。我们需要告诉 JavaScript 引擎如何分辨异步函数调用与常规的同步函数。那我们就在代码中新增一些关键字来让 JavaScript 引擎更容易识别吧。
首先,我们可以新增一个关键字到主函数上,这样可以告诉 JavaScript 引擎我们要在函数内部写异步函数调用的代码了。就用async
来做这件事吧:
$("#btn").on("click", async () => {
const user = getUser('tylermcginnis')
const weather = getWeather(user)
updateUI({
user,
weather,
})
})
这可🐂🍺了,看起来非常合理。接下来就要新增另一个关键字来保证引擎确切的知道即将要调用的函数是否是异步的,并且要返回一个 promise。那么我们就用 await 来表示吧。这样就告诉引擎说:「这个函数是异步的,且会返回一个 promise。不要再像你平时那样做了,继续执行下面的代码的同时等待 promise 的最终结果,最后返回给我这个结果」。加入async
和await
两个关键字后,我们的代码看起来就是这样的了:
$("#btn").on("click", async () => {
const user = await getUser('tylermcginnis')
const weather = await getWeather(user.location)
updateUI({
user,
weather,
})
})
很赞吧。TC39 已经实现了这样的特性,即Async/Await
。
async 函数返回一个 promise
既然你已经看到了Async/Await
的优势所在,现在我们就来讨论几个比较重要的细节。首先,任何适合你给一个函数添加了async
关键字,该函数都会隐式返回一个 promise。
async function getPromise(){}
const promise = getPromise()
即使getPromise
函数为空,它都返回了一个 promise,因为它是个async
函数。
如果async
函数返回了一个值,该值也会被 promise 包裹。这意味着你必须使用.then
方法来获取它。
async function add (x, y) {
return x + y
}
add(2,3).then((result) => {
console.log(result) // 5
})
await 必须与 async 同时使用
如果你想在函数中单独使用 await,程序会报错。
$("#btn").on("click", () => {
const user = await getUser('tylermcginnis') // SyntaxError: await is a reserved word
const weather = await getWeather(user.location) // SyntaxError: await is a reserved word
updateUI({
user,
weather,
})
})
关于此我是这么想的,当你给一个函数添加了async
关键字时它做了两件事:1)使函数本身返回一个 promise;2)从而使你可以在函数内部使用await
。
错误处理
前面的代码为了讲解方便省去了.catch
对错误进行捕获。在Async/Await
中,最常用的错误捕获方法就是用try...catch
块将代码包裹起来进行错误处理。
$("#btn").on("click", async () => {
try {
const user = await getUser('tylermcginnis')
const weather = await getWeather(user.location)
updateUI({
user,
weather,
})
} catch (e) {
showError(e)
}
})
结语
文章太长了,翻译到吐血。错误之处多多包涵。另外,你能看到这里真的是太🐂了,给你点个赞👍。