写在文章前

这篇文章翻译自 ASYNC/AWAIT WILL MAKE YOUR CODE SIMPLER,写于2017年八月的文章,并由某专栏提名为17年十大必读文章。在掘金上没找到这篇文章的翻译(其实没仔细找),就想试着自己翻译一下。

背景

AJAX (Asynchronous JavaScript And XML)

首先来进行一点科普。 在90年代末期, Ajax是异步JavaScript的第一个重大突破。 这个技术可以让网站在html加载之后获取和展示新的数据。对于当时大部分网站的那种需要重新下载整个个页面来展示一个部分内容的更新来说,它是革命性的创新。这项技术(在jQuery中通过捆绑成为辅助函数而闻名)在整个21世界主导了web开发,同时ajax在今天也是网站用来检索数据的主要技术,但xml却被json大规模的取代

NodeJS

当NodeJS在2009年第一次发布的时候,服务端的一个主要的关注点就是允许程序优雅的处理并发。当时大部分的服务端语言使用阻塞代码完成的这种方式来处理I/O操作,直到它结束处理I/O操作之后再继续进行之前的代码运行。取而代之,NodeJS利用事件循环体系,使用了一种类似ajax语法的工作方式:一旦非阻塞的异步操作完成之后,就可以让开发者分配的回调函数被触发。

Promises

几年之后,一个新的叫做“promises”的标准出现在nodejs和浏览器环境中,他提供了一套更强大也更标准化的方式去构建异步操作。promises 仍旧使用基于回调的格式,但是为异步操作的链式调用和构建提供了统一的语法。promises,这种由流行的开源库所创造的标准,最终在2015年被加入了原生JavaScript。

promises虽然是一个重大的改进,但仍旧会在某些情况下产生冗长难读的代码。

现在,我们有了一个新的解决方案。

async/await 是一种允许我们像构建没有回调函数的普通函数一样构建promises的新语法(从 .net和c#借鉴而来)。 这个是一个极好的JavaScript的增加功能,在去年被加进了JavaScript ES7,它甚至可以用来简化几乎所有现存的js应用。

例子

我们将会举几个例子。

这些代码例子不需要加载任何的三方库。最新版本的Chrome,Firefox,Safari和Edge完全支持异步/等待,因此您可以在浏览器控制台中试用这些示例。此外,async/await 语法可以在Node的7.6版本及其以上运行, Babel 以及TypeScript 也同样支持async/await 语法。Async和await 如今完全可以在任何JavaScript项目中使用

Setup

如果你想在你的电脑上跟随我们的脚步探寻async,我们就将会使用这个虚拟的API Class。这个类通过返回promise对象来模拟网络的调用的过程,并且这些promise对象将会在被调用的200ms之后使用resolve函数将简单的数据作为参数传递出去。

class Api {
  constructor () {
    this.user = { id: 1, name: 'test' }
    this.friends = [ this.user, this.user, this.user ]
    this.photo = 'not a real photo'
  }

  getUser () {
    return new Promise((resolve, reject) => {
      setTimeout(() => resolve(this.user), 200)
    })
  }

  getFriends (userId) {
    return new Promise((resolve, reject) => {
      setTimeout(() => resolve(this.friends.slice()), 200)
    })
  }

  getPhoto (userId) {
    return new Promise((resolve, reject) => {
      setTimeout(() => resolve(this.photo), 200)
    })
  }

  throwError () {
    return new Promise((resolve, reject) => {
      setTimeout(() => reject(new Error('Intentional Error')), 200)
    })
  }
}

每个例子将会按顺序执行相同的三个操作:检索一个用户,检索他们的朋友,以及检索他们的照片。最后,我们将在控制台输出上述的三个结果。

尝试1 – 嵌套的promise回调函数

下面是使用嵌套的promise回调函数的实现方法

function callbackHell () {
  const api = new Api()
  let user, friends
  api.getUser().then(function (returnedUser) {
    user = returnedUser
    api.getFriends(user.id).then(function (returnedFriends) {
      friends = returnedFriends
      api.getPhoto(user.id).then(function (photo) {
        console.log('callbackHell', { user, friends, photo })
      })
    })
  })
}

这可能对于任何JavaScript使用者来说再熟悉不过了。这个代码块有着非常简单的目的,并且很长而且高层级嵌套,还以一大群的括号结尾

})
    })
  })
}

在真实的代码库中,每个回调函数都可能会相当长,这可能会导致产生一些非常冗长而且高层级嵌套的函数。我们一般管这种在回调的回调中使用回调的代码叫“回调地狱”

更糟糕的是,没有办法进行错误检查,所以任何一个回调都可能会作为一个未处理的Promise rejection 而引发不易察觉的地失败。

尝试2 – 链式promise

让我们看看我们是不是能改进一下

function promiseChain () {
  const api = new Api()
  let user, friends
  api.getUser()
    .then((returnedUser) => {
      user = returnedUser
      return api.getFriends(user.id)
    })
    .then((returnedFriends) => {
      friends = returnedFriends
      return api.getPhoto(user.id)
    })
    .then((photo) => {
      console.log('promiseChain', { user, friends, photo })
    })
}

promise的一个很好的特性就是他们能够通过在每个回调内部返回另外一个promise对象而进行链式操作。这个方法可以将所有的回调视作为平级的。此外,我们还可以使用箭头函数来缩写回调的表达式。

这个变体明显比之前的那个尝试更易读,而且还有很好的序列感。然而,很遗憾,依旧很冗长,看起来还有点复杂

尝试3 – Async/Await

有没有可能我们不使用任何的回调函数?不可能吗?有想过只用7行就实现它的可能性吗?

async function asyncAwaitIsYourNewBestFriend () {
  const api = new Api()
  const user = await api.getUser()
  const friends = await api.getFriends(user.id)
  const photo = await api.getPhoto(user.id)
  console.log('asyncAwaitIsYourNewBestFriend', { user, friends, photo })
}

变得更好了有没有?在promise之前调用await暂停了函数流直到promise 处于resolved状态,然后将结果赋值给等号左边的变量。这个方式能让我们编写一个就像是一个正常的同步命令一样的异步操作流程。

我想你现在和我一样,对这个特性感到十分的激动有没有?!

注意“async”关键词是在整个函数声明的开始声明的。我们必须要这么做,因为其实它将整个函数转化成为一个promise。我们将会在稍后研究它。

LOOPS(循环)

Async/await让以前的十分复杂的操作变得特别简单,比如说, 加入我们想按顺序取回每个用户的朋友列表该怎么办?

尝试1 – 递归的promise循环

下面是如何按照顺序获取每个朋友列表的方式,这可能看起来很像很普通的promise。

function promiseLoops () {  
  const api = new Api()
  api.getUser()
    .then((user) => {
      return api.getFriends(user.id)
    })
    .then((returnedFriends) => {
      const getFriendsOfFriends = (friends) => {
        if (friends.length > 0) {
          let friend = friends.pop()
          return api.getFriends(friend.id)
            .then((moreFriends) => {
              console.log('promiseLoops', moreFriends)
              return getFriendsOfFriends(friends)
            })
        }
      }
      return getFriendsOfFriends(returnedFriends)
    })
}

我们创建了一个内部函数用来通过回调链式的promises获取朋友的朋友,直到列表为空。O__O 我们的确实现了功能,很棒棒,但是我们其实使用了一个十分复杂的方案来解决一个相当简单的任务。

注意 – 使用promise.all()来尝试简化PromiseLoops()函数会导致它表现为一个有着完全不同的功能的函数。这个代码段的目的是按顺序(一个接着一个)运行操作,但Promise.all是同时运行所有异步操作(一次性运行所有)。但是,值得强调的是, Async/await 与Promise.all()结合使用仍旧十分的强大,就像我们下一个小节所展示的那样。

尝试2 – Async/Await的for循环

这个可能就十分的简单了。

async function asyncAwaitLoops () {
  const api = new Api()
  const user = await api.getUser()
  const friends = await api.getFriends(user.id)

  for (let friend of friends) {
    let moreFriends = await api.getFriends(friend.id)
    console.log('asyncAwaitLoops', moreFriends)
  }
}

不需要写任何的递归Promise,只有一个for循环。看到了吧,这就是你的人生益友-Async/Await

PARALLEL OPERATIONS(并行操作)

逐个获取每个朋友列表似乎有点慢,为什么不采取并行执行呢?我们可以使用async/await 来实现这个需求吗?

显然,可以的。你的朋友它可以解决任何问题。:)

async function asyncAwaitLoopsParallel () {
  const api = new Api()
  const user = await api.getUser()
  const friends = await api.getFriends(user.id)
  const friendPromises = friends.map(friend => api.getFriends(friend.id))
  const moreFriends = await Promise.all(friendPromises)
  console.log('asyncAwaitLoopsParallel', moreFriends)
}

为了并行的运行这些操作,要先生成成运行的promise数组,并把它作为一个参数传给Promise.all()。它返回给我们一个唯一的promise对象可以让我们进行await, 这个promise对象一旦所有的操作都完成了就将会变成resolved状态。

Error handling (错误处理)

然而,这篇文章到目前为止还没有说到那个异步编程的重要问题:错误处理。 很多代码库的灾难源头就在于异步的错误处理通常涉及到为每个操作写单独的错误处理的回调。因为将错误放到调用堆栈的顶部会很复杂,并且通常需要在每个回调的开始明确检查是否有错误抛出。这种方法是十分繁琐冗长而且容易出错的。况且,在一个promise中抛出的任何异常如果没有被正确捕获的话,都会产生一个不被察觉的失败,从而导致代码库有因为不完整错误检验而产生的“不可见错误”。

让我们重新回到之前的例子中给每一种尝试添加错误处理。我们将在获取用户图片之前使用一个额外的函数api.throwError()来检测错误处理。

尝试1 – promise的错误回调函数

让我们来看看最糟糕的写法:

function callbackErrorHell () {
  const api = new Api()
  let user, friends
  api.getUser().then(function (returnedUser) {
    user = returnedUser
    api.getFriends(user.id).then(function (returnedFriends) {
      friends = returnedFriends
      api.throwError().then(function () {
        console.log('Error was not thrown')
        api.getPhoto(user.id).then(function (photo) {
          console.log('callbackErrorHell', { user, friends, photo })
        }, function (err) {
          console.error(err)
        })
      }, function (err) {
        console.error(err)
      })
    }, function (err) {
      console.error(err)
    })
  }, function (err) {
    console.error(err)
  })
}

太恶心了。除了真的很长很丑这个缺点之外,控制流也是非常不直观,因为他是从外层进入,而不是像正常的可读性高的代码一样那种是由上至下的。太糟糕了,我们继续第二个尝试。

尝试2 – 链式promise捕获方法

我们可以通过使用一种promise-catch组合(先promise再捕获再promise再再捕获)的方式来改进一下。

function callbackErrorPromiseChain () {
  const api = new Api()
  let user, friends
  api.getUser()
    .then((returnedUser) => {
      user = returnedUser
      return api.getFriends(user.id)
    })
    .then((returnedFriends) => {
      friends = returnedFriends
      return api.throwError()
    })
    .then(() => {
      console.log('Error was not thrown')
      return api.getPhoto(user.id)
    })
    .then((photo) => {
      console.log('callbackErrorPromiseChain', { user, friends, photo })
    })
    .catch((err) => {
      console.error(err)
    })
}

显然比之前的好太多,通过利用链式promise的最后的那个单个的catch函数,我们可以为所有的操作提供单个错误处理。但是,依旧有点复杂,我们还是必须要使用特殊的回调函数来处理异步错误,而不是像处理普通的JavaScript错误一样处理异步错误。

尝试3 – 正常的try/catch块

我们可以做的更好。

async function aysncAwaitTryCatch () {
  try {
    const api = new Api()
    const user = await api.getUser()
    const friends = await api.getFriends(user.id)

    await api.throwError()
    console.log('Error was not thrown')

    const photo = await api.getPhoto(user.id)
    console.log('async/await', { user, friends, photo })
  } catch (err) {
    console.error(err)
  }
}

这里,我们将整个操作封装在一个正常的try/catch 块中。这样的话,我们就可以使用同样的方式从同步代码和一步代码中抛出并捕获错误。显然,简单的多;)

Composition(组合)

我在之前提到说,任何带上async 标签的函数实际上返回了一个promise对象。这可以让我们组合异步控制流变得十分的简单。

比如说,我们可以重新配置之前的那些例子来返回用户数据而不是输出它,然后我们可以通过调用async函数作为一个promise对象来检索数据。

async function getUserInfo () {
  const api = new Api()
  const user = await api.getUser()
  const friends = await api.getFriends(user.id)
  const photo = await api.getPhoto(user.id)
  return { user, friends, photo }
}

function promiseUserInfo () {
  getUserInfo().then(({ user, friends, photo }) => {
    console.log('promiseUserInfo', { user, friends, photo })
  })
}

更好的是,我们也可以在接收的函数中使用async/await语法,从而生成一个完全清晰易懂,甚至很精炼的异步编程代码块。

async function awaitUserInfo () {
  const { user, friends, photo } = await getUserInfo()
  console.log('awaitUserInfo', { user, friends, photo })
}

如果我们现在需要检索前十个用户的所有数据呢?

async function getLotsOfUserData () {
  const users = []
  while (users.length < 10) {
    users.push(await getUserInfo())
  }
  console.log('getLotsOfUserData', users)
}

要求并发的情况下呢?还要有严谨的错误处理呢?

async function getLotsOfUserDataFaster () {
  try {
    const userPromises = Array(10).fill(getUserInfo())
    const users = await Promise.all(userPromises)
    console.log('getLotsOfUserDataFaster', users)
  } catch (err) {
    console.error(err)
  }
}

Conclusion(结论)

随着单页JavaScript web程序的兴起和对NodeJS的广泛采用,如何优雅的处理并发对于JavaScript开发人员来说比任何以往的时候都显得更为重要。Async/Await缓解了许多因为控制流问题而导致bug遍地的这个困扰着JavaScript代码库数十年的问题,并且几乎可以保证让任何异步代码块变的更精炼,更简单,更自信。而且近期async/await 已经在几乎所有的主流浏览器以及nodejs上面获得全面支持,因此现在正是将这些技术集成到自己的代码实践以及项目中的最好时机。

最后修改:2023 年 11 月 24 日
如果觉得我的文章对你有用,请随意赞赏