异步JavaScript的演化史:从回调到Promise再到Async/Await

Posted 前端之巅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了异步JavaScript的演化史:从回调到Promise再到Async/Await相关的知识,希望对你有一定的参考价值。

作者|Tyler McGinnis
译者|张卫滨

本文以实际样例阐述了异步 javascript 的发展过程,介绍了每种实现方式的优势和不足,能够帮助读者掌握相关技术的使用方式并把握技术发展的脉络。

我最喜欢的一个站点叫做 BerkshireHathaway.com,它非常简单、高效,从 1997 年创建以来它一直都能很好地完成自己的任务。尤其值得注意的是,在过去的 20 年间,这个站点从来没有出现过缺陷。这是为什么呢?因为它是静态的,从建立到现在的 20 年间,它几乎没有发生过什么变化。如果你将所有的数据都放在前面的话,搭建站点是非常简单的。但是,如今大多数的站点都不会这么做。为了弥补这一点,我们发明了所谓的“模式”,帮助我们的应用从外部抓取数据。同其他大多数事情一样,这些模式都有一定的权衡,并随着时间的推移在发生着变化。在本文中,我们将会分析三种常用模式的优劣,即回调(Callback)、Promise 和 Async/Await,并从历史发展的维度讨论一下它们的意义和发展。

我们首先从数据获取模式的最原始方式开始介绍,那就是回调。

回调

在这里我假设你对回调一无所知,如果事实并非如此的话,那么你可以将内容稍微往后拖动一下。

当我第一次学习编程的时候,它就帮助我形成了一种思考方式,那就是将功能视为一种机器。这些机器能够完成任何你希望它能做到的事情,它们甚至能够接受输入并返回值。每个机器都有一个按钮,如果你希望这个机器运行的话,就按下按钮,这个按钮也就是 ()。

function add (x, y) {
  return x + y
}

add(2,3) // 5 - 按下按钮,运行机器。

事实上,不仅我能按下按钮,你 也可以,任何人 按下按钮的效果都是一样的。只要按下按钮,不管你是否愿意,这个机器就会开始运行。

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函数赋值给了三个不同的变量:meyousomeoneElse。有很重要的一点需要注意,原始的add和我们创建的每个变量都指向的相同的内存点。在不同的名字之下,它们实际上是完全相同的内容。所以,当我们调用meyousomeoneElse的时候,就像调用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,将其重命名为addReference,然后我们“按下按钮”或者说调用它。

这里涉及到了 JavaScript 的一些重要概念。首先,就像可以将字符串或数字以参数的形式传递给函数一样,我们还可以将函数的引用作为参数进行传递。但我们这样做的时候,作为参数传递的函数被称为回调函数(callback function),而接收回调函数传入的那个函数则被称为高阶函数(higher order function)。

因为术语非常重要,所以对相同功能的代码,我们进行变量的重命名,使其匹配它们所要阐述的概念:

function add (x,y) {
  return x + y
}

function higherOrderFunction (x, callback) {
  return callback(x, 5)
}

higherOrderFunction(10, add)

这种模式看上去应该是非常熟悉的,它到处可见。如果你曾经用过 JavaScript 的 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 和 showError 的内容无关紧要。
// 假定它们所做的工作与它们的名字相同。

const id = 'tylermcginnis'

$.getJSON({
  url: `https://api.github.com/users/${id}`,
  success: updateUI,
  error: showError,
})

在获取到用户的数据之前,我们是不能更新应用的 UI 的。那么我们是怎么做的呢?我们可以说,“这是一个对象。如果请求成功的话,那么调用success,并将用户的数据传递给它。如果请求没有成功的话,那么调用error并将错误对象传递给它。你不用关心每个方法是做什么的,只需要确保在应该调用它们的时候,去进行调用就可以了。这个样例完美地阐述了如何使用回调进行异步请求。

到此为止,我们已经学习了回调是什么以及它如何为同步代码和异步代码带来收益。我们还没有讨论回调的阴暗面。看一下下面的代码,你能告诉我它都做了些什么吗?

// 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
  })
})

如果你需要帮助的话,可以参考一下这些代码的在线版本 (https://codesandbox.io/s/v06mmo3j7l)。

注意一下,我们只是多添加了几层回调。首先,我们还是声明,如果不点击 id 为btn的元素,那么原始的 AJAX 请求就不会发送。一旦点击了按钮,我们会发起第一个请求。如果请求成功的话,我们会发起第二个请求。如果第二个请求也成功的话,那么我们将会调用updateUI方法,并将两个请求得到的数据传递给它。不管你一眼是否能够明白这些代码,客观地说,它要比之前的代码更加难以阅读。这也就涉及到所谓的“回调地狱”。

作为人类,我们习惯于序列化的思考方式。如果在嵌套回调中依然还有嵌套回调的话,它会强迫我们背离自然的思考方式。当代码的阅读方式与你的思考方式脱节时,缺陷也就难以避免地出现了。

与大多数软件问题的解决方案类似,简化“回调地狱”问题的一个常见方式就是对你的代码进行模块化。

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)
})

如果你需要帮助的话,可以参考一下这些代码的在线版本 (https://codesandbox.io/s/m587rq0lox)。

好了,函数的名称能够帮助我们理解到底会发生什么,但客观地说,它真的“更好”了吗?其实也没好到哪里去。我们只是给回调地狱这个问题上添加了一块创可贴。问题依然存在,也就是我们会自然地按照顺序进行思考,即便有了额外的函数,嵌套回调也会打断我们顺序思考的方式。

回调方式的另外一个问题与 控制反转(inversion of control) 有关。当你编写回调的时候,你会假设自己将回调交给了一个负责任的程序,这个程序会在(并且仅会在)应该调用的时候调用你的回调。你实际上将控制权交给了另外一个程序。当你在处理 jQuery、lodash 这样的库,甚至普通 JavaScript 时,可以安全地假设回调函数会在正确的时间以正确的参数进行调用。但是,对于很多第三方库来说,回调函数是你与它们进行交互的接口。第三方库很可能有意或无意地破坏与你的回调进行交互的方式。

function criticalFunction () {
  // 这个函数一定要进行调用,并且要传入正确的参数
}

thirdPartyLib(criticalFunction)

因为你不是调用criticalFunction的人,因此你完全无法控制它何时被调用以及使用什么参数进行调用。大多数情况下,这都不是什么问题,但是一旦出现问题的话,就不是什么小问题。

Promise

幸好,还有另外一种解决方案。这种方案的设计允许你保留所有的控制权。你可能之前见过这种方式,那就是他们会给你一个蜂鸣器,如下所示。

异步JavaScript的演化史:从回调到Promise再到Async/Await

蜂鸣器一定会处于如下三种状态之一:pendingfulfilledrejected

pending:默认状态,也是初始态。当他们给你蜂鸣器的时候,它就是这种状态。

fulfilled:代表蜂鸣器开始闪烁,你的桌子已经准备就绪。

rejected:如果蜂鸣器处于这种状态,则代表出现了问题。可能餐厅要打烊,或者他们忘记了晚上有人要包场。

再次强调,你作为蜂鸣器的接收者拥有完全的控制权。如果蜂鸣器处于fulfilled状态,你就可以就坐了。如果它进入fulfilled状态,但是你想忽略它,同样也可以。如果它进入了rejected状态,这非常糟糕,但是你可以选择去其他地方就餐。如果什么事情都没有发生,它会依然处于pending状态,你可能吃不上饭了,但是同时也没有失去什么。

现在,你已经掌握了餐厅蜂鸣器的事情,接下来,我们将这个知识用到其他重要的地方。

像以往一样,我们首先从 为什么 开始。Promise 为什么会存在呢?它的出现是为了让异步请求所带来的复杂性更容易管理。与蜂鸣器非常类似,Promise会处于如下三种状态中的某一种: pendingfulfilledrejected。但是与蜂鸣器不同,这些状态代表的不是饭桌的状态,它们所代表的是异步请求的状态。

如果异步请求依然还在进行,那么Promise的状态会是pending。如果异步请求成功完成的话,那么Promise会将状态转换为fulfilled。如果异步请求失败的话,Promise会将状态转换为rejected。蜂鸣器的比喻非常贴切,对吧?

理解了 Promise 为什么会存在以及它们的三种不同状态之后,我们还要回答三个问题:

  1. 如何创建 Promise?

  2. 如何改变 Promise 的状态?

  3. 如何监听 Promise 状态变化的时间?

 1)如何创建 Promise?

这个问题非常简单,你可以使用new创建Promise的一个实例:

const promise = new Promise()
 2)如何改变 Promise 的状态?

Promise的构造函数会接收一个参数,这个参数是一个(回调)函数。该函数会被传入两个参数resolvereject

resolve:一个能将 Promise 状态变为fulfilled的函数;

reject:一个能将 Promise 状态变为rejected的函数;

在下面的代码中,我们使用setTimeout等待两秒钟然后调用resolve,这样会将 Promise 的状态变为fulfilled

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve() // 将状态变为“fulfilled”
  }, 2000)
})

分别用日志记录刚刚创建之时和大约两秒钟之后resolve已被调用时的 Promise,我们可以看到状态的变化了:

<iframe height=500 width=800 ></iframe>

请注意,Promise 从<pending>变成了<resolved>

3)如何监听 Promise 状态变化的时间?

我认为,这是最重要的一个问题。我们已经知道了如何创建 Promise 和改变它的状态,这非常棒,但是如果我们不知道如何在状态变化之后做一些事情的话,这其实是没有太大意义的。

我们还没有讨论的一件事就是 Promise 到底是什么。当我们通过new Promise创建 Promise 的时候,你实际创建的只是一个简单的 JavaScript 对象,这个对象可以调用两个方法thencatch。这是关键所在,当 Promise 的状态变为fulfilled的时候,传递给.then的函数将会被调用。如果 Promise 的状态变为rejected,传递给.catch的函数将会被调用。这就意味着,在你创建 Promise 的时候,要通过.then将你希望异步请求成功时调用的函数传递进来,通过.catch将你希望异步请求失败时调用的函数传递进来。

看一下下面的样例。我们依然使用setTimeout在两秒钟(2000 毫秒)之后将 Promise 的状态变为fulfilled

function onSuccess () {
  console.log('Success!')
}

function onError () {
  console.log('

以上是关于异步JavaScript的演化史:从回调到Promise再到Async/Await的主要内容,如果未能解决你的问题,请参考以下文章

javascript异步编年史,从“纯回调”到Promise

39.JavaScript中Promise的基本概念使用方法,回调地狱规避链式编程

译异步JavaScript的演变史:从回调到Promises再到Async/Await

JavaScript 异步操作之回调函数

Javascript的那些硬骨头:作用域回调闭包异步……

ES6 - Promise 对象