原文连接 The Evolution of Async JavaScript: From Callbacks, to Promises, to Async/Awaitjavascript
注:本文为个人课程《高级 JavaScript》中的一部分,若是你喜欢本文,欢迎你来看看个人课程。java
BerkshireHathaway.com 是我最喜欢的网站之一,由于它简单、高效,并且自从 1997 年建立以来一直运行良好。更🐂🍺的是在过去的二十年里这个网站颇有可能从未出现过 bug。git
为啥?由于这个网站是纯静态的,它自20多年前推出以来几乎从没变过样子。github
也就是说若是你预先拥有全部数据,那么建站就会变得很是简单。不幸的是,现现在的大多数站点都不是这样的。为了弥补这方面的缺点,咱们为咱们的系统发明了各类「模式」来应对这种须要获取外部数据的状况。编程
与其余事物同样,这些模式随着时间的推移都会权衡各自不一样的侧重点。本文将详细拆解Callbacks
, Promises
, 和Async/Await
这三种最多见模式的优缺点,同时在其历史背景下探讨一下这种演进产生的意义及进步之处。json
这里会假设你彻底不了解何为 callbacks。要是我假设有误,稍微向下滑一下便可。api
在我刚开始学习编程的时候,我把函数当作一台机器。这些机器能够作任何你想让他们完成的工做。甚至是接收一个输入而后返回一个值。每台机器都有一个按钮,你能够在你想让他们运转的时候按下这个按钮,在函数里,也就是()
。数组
function add (x, y) {
return x + y;
}
add(2,3)
复制代码
这个按钮是什么时候被谁按下的并不重要,机器只管去运行。promise
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
的那一个,你对参数的调用彻底没有控制权。大多数时候这都不是个问题,若是是的话,那问题可就大了。
你有没有过不曾预定的状况下去一个很是火爆的餐厅吃饭?遇到这种状况时,餐厅会在有空位的时候经过某种方式联系你。一种古老的方式就是服务人员会记下你的名字,在有空位的状况下喊你。随着时代的进步,他们也开发出了新花样,记下你的电话号码以备有空位时短信通知你,这就容许你不用在餐厅门口死守了,还有最重要的一点就是他们能够在任什么时候候往你的手机推送广告。
听起来熟悉不?这就是 callbacks 的一种比喻啊。就像把一个回调函数传给第三方服务同样,咱们把本身的号码传给了餐厅。你指望的是餐厅有空位时联系你,就像你指望第三方服务在某个时刻以某种方式调用你的回调函数同样。一旦你的号码或者说回调函数落在他们手中,你就对其失去了控制。
幸运的是,如今有了另外一种解决方案。这种设计方案容许你保留全部控制权。你可能以前已经体验过了,餐厅可能会给你一个蜂鸣器,相似这种:
这个蜂鸣器会一直处于三个不一样状态之一下 —— pending
,fulfilled
或rejected
。
pending
为初始的默认状态,蜂鸣器交到你手中时就是该状态。
fulfilled
就是蜂鸣器开始闪光,通知你已有空位时的状态。
rejected
是蜂鸣器通知你可能发生了什么不顺利的事,好比餐厅就要中止营业了或者是他们把你给忘了。
再次声明,你要知道你对这个蜂鸣接收器拥有彻底的控制权。蜂鸣器变为fulfilled
状态时,去不去吃饭是由你来决定的。若是变成rejected
状态,虽然体验不好可是你还能够选择其余餐厅吃饭,若是一直处于pending
状态的话,虽然你没有吃上饭但你没错过其余事情。
既然你成为了蜂鸣器的主人,那咱们就来触类旁通一下吧。
若是说把把你的号码给了餐厅像是传给他们一个回调函数,那么接收这个蜂鸣器就至关于接受到了所谓的「Promise(承诺)」。
按照惯例,咱们依然先问问这是为啥?为何会出现 Promise 呢?它的出现就是为了使复杂的异步请求变的可控。就像前面提到的蜂鸣器,一个Promise
拥有三种状态,pending
,fulfilled
和rejected
。和蜂鸣器不一样之处在于,这里的三种状态表明的是异步请求的状态。
若是异步请求一直在执行,Promise
就会保持在pending
状态下;异步请求执行成功,Promise
状态会变为fulfilled
;异步请求执行失败,Promise
状态会变为rejected
。
你已经知道了为何会出现 Promises 及其可能出现的三种状态,如今还有三个问题须要咱们去解答:
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,
})
})
}
复制代码
恩,看起来还不错。接下来就要更新咱们的句柄,下面是咱们想要执行的工做流:
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);
复制代码
如今,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/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,程序会报错。
$("#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)
}
})
复制代码
文章太长了,翻译到吐血。错误之处多多包涵。另外,你能看到这里真的是太🐂了,给你点个赞👍。