JS 异步编程的进化过程
Posted 车前端
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS 异步编程的进化过程相关的知识,希望对你有一定的参考价值。
JS 异步编程的进化过程:从回调函数(callbacks)到 Promises 再到 Async/Await.
概述
BerkshireHathaway.com 是我最喜欢的网站之一,它简单,高效,并且自1997年推出以来,一直不错。更值得注意的是,在过去的20年里,这个网站很有可能从未出现过 bug。为什么?因为该网站是全静态的,20多年来几乎一样。如果预先拥有网站的所有数据,那么构建起来会非常简单。但是,大部分网站都做不到这一点。因此,才有了从外部提取数据的"模式"。像大多数事物的发展一样,这些模式随着时间的推移发生了演变。在这篇文章中,我们将分析三种最常见模式的优缺点: Callbacks
, Promises
,和 Async/Await
,并从历史背景的角度讨论各自的意义和发展。
让我们先从最早的数据获取模式开始分析:回调函数(Callbacks)。
回调函数(Callbacks)
假设读者不清楚什么是回调函数。否则,可以直接跳过下面这部分。
我刚开始学习编程的时候,将函数看作一个机器,这些机器可以做很多事,甚至可以接收一个输入值并返回一个值。每个机器上都有一个按钮,想要机器运行,你就可以按下该按钮。
function add (x, y) {
return x + y
}
add(2, 3) // 5 - Press the button, run the machine.
无论我按下按钮,你按下按钮,还是别人按下按钮,结果都一样,无论何时,只要按下按钮,机器都一样运行。
function add (x, y) {
return x + y
}
const me = add
const you = add
const someoneElse = add
me(2, 3) // 5 - Press the button, run the machine.
you(2, 3) // 5 - Press the button, run the machine.
someoneElse(2, 3) // 5 - Press the button, run the machine.
上面的代码,我们定义了三个变量: me
, you
和 someoneElse
,均赋值为 add
函数。注意, 原 add
函数和我们后面创建的3个变量都指向内存中同一位置,只是变量名不同而已。所以,当我们调用 me
、 you
,或者 someoneElse
时,效果就和我们调用 add
一样。
现在,如果我们要把 add
机器添加到另一台机器会怎样?请记住,谁按下这个()按钮都不重要,只要按下,就会运行。
function add (x, y) {
return x + y
}
function addFive (x, addReference) {
return addReference(x, 5) // 15 - Press the button, run the machine.
}
addFive(10, add) // 15
乍一看可能会有点奇怪,但上面没有新的东西。我们不是直接在 add
上“按下按钮” ,而是将 add
作为参数传递给 addFive
,并重命名它为 addReference
,然后我们“按下按钮”间接地调用了 add
。
这突出了 javascript 语言的一些重要概念。首先,正如你可以将字符串或数字作为参数传递给函数一样,你也可以将函数的引用作为参数传递。当你执行此操作时,作为参数传递的函数被称为 回调函数(callback),接收回调函数的函数称为 高阶函数(higher order)。
由于恰当的命名对理解有一定帮助,所以下面将之前的函数重新命名,以匹配他们表示的概念。
function add (x,y) {
return x + y
}
function higherOrderFunction (x, callback) {
return callback(x, 5)
}
higherOrderFunction(10, add)
上面这种模式应该很熟悉吧,其实它无处不在。如果你使用过任何 JS Array 的方法,那么其实你已经使用过回调。如果你使用过 lodash、jQuery,那么你也已经使用了回调。
[1,2,3].map((i) => i + 5)
_.filter([1,2,3,4], (n) => n % 2 === 0 );
$('#btn').on('click', () =>
console.log('Callbacks are everywhere')
)
通常,回调有两种常见的用法。第一种,例如 .map
和 _.filter
例子中所看到的那样,将一个值转化为另一个值。好比在说:“嘿,这是一个数组和一个函数。来吧,根据我给你的函数,返回给我一个新的值”。第二种,也就是我们在 jQuery 示例中看到的,是将函数延迟到特定时间再执行。类似:“嘿,这一个函数。每当点击 id 为 btn
的元素时,就调用它”。上述第二种“将函数延迟到特定时间再执行”的方法,则是我们(接下来)要关注的。
现在只看了同步的例子。正如我们在本文开头所讨论的那样,我们构建的大多数应用程序都不可能预先获得所有的数据。所以,需要在用户与应用程序交互时获取外部数据。前面说到的回调函数可以解决这个问题,原因是,它们允许“将函数延迟到特定时间再执行”。可以将这一点运用到获取数据上:延迟函数的执行,直到我们获得所需的数据,jQuery 的 getJSON
方法可能是最流行的例子。
// updateUI and showError are irrelevant.
// Pretend they do what they sound like.
const id = 'tylermcginnis'
$.getJSON({
url: `https://api.github.com/users/${id}`,
success: updateUI,
error: showError,
})
那么,想要等获取到用户数据后,再更新 UI,该怎么做呢?上面的写法仿佛在说:“嘿,这是一个对象。如果请求成功,请继续调用 success
并将获取到的用户数据传递给它。否则,调用 error
方法并将错误对象传递给它。你不用关心每个方法是做什么的,只要能确保在它们应该被调用的时候去调用就可以了”。上面这个例子就完美地演示了如何使用回调进行异步请求。
现在,我们已经了解了回调是什么以及它在同步和异步代码中的用处。我们还没有谈到的是回调的黑暗面。请看下面的代码。你能说出发生了什么吗?
// updateUI, showError, and getLocationURL are irrelevant.
// Pretend they do what they sound like.
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
})
})
请注意,我们添加了一些回调层。首先,只有点击了 id 为 btn
的元素才会初始化 AJAX 请求。一旦按钮被点击,我们会发出第一个请求。如果该请求成功,再发出第二个请求。如果第二个请求成功,将把前面两个请求中获取的数据传递给 updateUI
方法并调用。不知道你是否第一眼就理解了上面的代码,客观地讲,它比之前的代码更难阅读。这将我们带向了回调的第一个问题:“回调地狱(Callback Hell)”。
通常,我们一般都是顺序思维。当你在嵌套的回调中再嵌套回调时,它会迫使你跳出这种自然的思维方式。当代码的阅读方式与自然思考方式脱节时,就容易发生错误。
和大多数软件问题的解决方案一样,解决“回调地狱”的一个常见方式就是将代码模块化。
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)
})
可以看到,恰当的函数命名能在一定程度上帮助我们更好地理解代码。不过,这样的写法也只是在回调地狱的可读性问题上加了一个“创可贴”而已,问题仍然存在,即便有了额外的函数,嵌套回调也会打断我们的顺序思维。
另一个关于回调的问题与控制反转(inversion of control)有关。当你写回调函数时,你会假设自己将回调交给了一个负责任的程序,这个程序会在(并且仅会在)应该调用的时候调用回调函数。你实际上是将控制权交给了另一个程序。当你在使用 jQuery、lodash 这样的库,甚至普通 JavaScript 时,可以安全地假设回调函数会在正确的时间以正确的参数进行调用。但是,对于很多第三方库来说,回调函数是你与它们进行交互的接口,一旦有问题就会打破这种交互。
function criticalFunction () {
// It's critical that this function
// gets called and with the correct
// arguments.
}
thirdPartyLib(criticalFunction)
因为你不是 criticalFunction
的调用者,所以你不能够控制调用它的时间和参数。大多数时候这不是问题,但是当它出现问题时,这将会是一个很大的问题。
Promises
你有在未预订的情况下直接去一个火爆餐馆的经历吗?这时,餐厅需要一种当有餐桌空出来时与你联系的方法。过去,他们会在有位子的时候喊你的名字。后来,出现了一个新的解决方案,一旦有位子空出,他们就会取你的号码并给你发信息。这样一来,扩大了可以被叫到的范围,但更重要的是,这允许餐厅能够随时随地将广告发送到你的手机上。听起来有点熟?应该是这样的!或许也可能不是。这是回调的隐喻! 将你的号码提供给餐馆就像给第三方服务提供回调功能一样。你希望餐厅在有空桌子时给你发短信,就像期望第三方服务在合适的时候以它们承诺的方式来调用你的函数。 一旦你的号码或回调函数掌握在他们手中,你就失去了控制的所有权。
幸好,有另一种解决方案。有一个设计,允许你保留所有的控制。你可能遇到过 —— 他们会给你一个叫号器,就是下面这个东西。
如果你之前从未使用过,没关系,用法很简单。这次工作人员不是记录你的姓名或者号码,而是给你一个像上面那样的设备。当这个设备开始闪烁并且嗡嗡作响时,说明到你了。在你等待空桌的时候,依然可以做其他事情。是他们需要给你东西,这里没有所谓的控制反转。
叫号器一定会处于如下三种状态之一: pending
, fulfilled
或 rejected
。
pending
是默认状态,初始状态。他们刚给你叫号器的时候,它就处于这种状态。
fulfilled
是叫号器闪烁并且可以就餐时的状态。
rejected
当出现问题时,叫号器所处的状态。也许餐厅即将关闭,或者他们忘了晚上有人要包场。
还有一点重要的需记住,你作为叫号器的接收者,拥有完全的控制权。如果叫号器处于 fulfilled
状态,你可以去准备好的位置落座,当然也可以忽视不去。如果它进入 rejected
状态,那很糟糕,当前排队失败了,但你可以选择去其他地方就餐。如果它停留在 pending
状态,你虽然没有吃上东西,但是实际上也没有失去什么。
如果说,将你的号码给餐厅像是给了他们一个回调函数的话,那么接收这个小小的叫号器就像收到所谓的 “Promise”。
就像其他话题一样,我们先从 why 说起。为什么有 Promises ?它的出现让异步请求所带来的复杂性更易于管理。这很像叫号器,一个 Promise
会处于 pending
, fulfilled
或者 rejected
三种状态中的一种。与叫号器不同的是,这三种状态代表了异步请求的状态。
如果异步请求仍在进行中,则 Promise
的状态为 pending
。如果异步请求成功完成,则 Promise
状态将变为 fulfilled
。如果异步请求失败, Promise
状态将变为 rejected
。这样看,叫号器的比喻相当贴切,对吧。
既然你已经理解了 Promise
存在的原因以及它的3种状态,那么我们还需要回答三个问题。
1)如何创建 Promise? 2)如何改变 Promise 的状态(status)? 3)如何监听 Promise 状态(status)的变化?
1)如何创建 Promise
可以直接 new
一个 Promise
实例,如下:
const promise = new Promise()
2)如何改变 Promise 的状态(status)
Promise
的构造函数接收一个参数:一个(回调)函数。这个函数将接收两个参数, resolve
和 reject
。
resolve
- 一个允许将 promise 状态变为 fulfilled
的函数
reject
- 一个允许将 promise 状态变为 rejected
的函数
在下面的代码中,我们使用 setTimeout
等待2秒然后再调用 resolve
。这将改变 promise 的状态为 fulfilled
。
const promise = new Promise((resolve, reject) => {
setTimeout(() => {
resolve() // Change status to 'fulfilled'
}, 2000)
})
可以通过分别在 promise 创建之时和大约2秒后 resolve
已被调用时打印日志,查看 promise 各时期状态的变化。
注意观察 promise 的状态:从 <pending>
变为 <resolve>
3)如何监听 promise 状态(status)的变化?
在我看来,这是最重要的问题。现在,我们已经知道了如何创建 promise 并改变其状态,但是如果我们不知道如何在状态改变后做一些事情的话,前面说的就没有多大意义了。
到这里,我们还没有说 promise 到底是什么。当你创建一个 newPromise
时,实际上只是创建了一个普通的 JavaScript 对象。该对象可以调用两个方法 then
和 catch
。这是关键所在。当 promise 的状态变为 fulfilled
时,传递给 .then
的函数将会被调用。当 promise 的状态变为 rejected
时,传递给 .catch
的函数将被调用。意思就是:在你创建 Promise 的时候,要通过 .then
将你希望异步请求成功时调用的函数传递进来,通过 .catch
将你希望异步请求失败时调用的函数传递进来。
来看一个例子吧。我们再次使用 setTimeout
,在两秒钟(2000毫秒)之后将 promise 的状态改为 fulfilled
。
function onSuccess () {
console.log('Success!')
}
function onError () {
console.log('
以上是关于JS 异步编程的进化过程的主要内容,如果未能解决你的问题,请参考以下文章