async/await 是如何让代码更加简洁的?

async/await能够缓解很多易于引入缺陷的控制流问题,这些问题已经困扰JavaScript代码库许多年了。同时,它还能确保异步代码块更加简短、更加简洁、更加清晰。

我是如何放弃编写回调函数并爱上JavaScript ES8的

现代的JavaScript项目有时候会面临失控的危险。其中有个主要的原因就是处理异步任务中的混乱,它们会导致冗长、复杂和深度嵌套的代码块。JavaScript现在为这种操作提供了新的语法,它甚至能够将最复杂的异步操作转换成简洁且具有高度可读性的代码。

背景

AJAX(异步JavaScript与XML)

首先,我们来回顾一下历史。在20世纪90年代,在异步JavaScript方面,Ajax是第一个重大突破。这项技术允许Web站点在HTML加载完之后,拉取和展现新的数据,当时,大多数的Web站点为了进行内容更新,都会再次下载整个页面,因此这是一个革命性的理念。这项技术(因为jQuery中打包了辅助函数使其得以流行开来)主导了本世纪前十年的Web开发,如今,Ajax是目前Web站点用来获取数据的主要技术,但是XML在很大程度上被JSON所取代了。

Node.js

当Node.js在2009年首次发布时,服务器环境的主要关注点在于允许程序优雅地处理并发。当时,大多数的服务器端语言通过阻塞代码执行的方式来处理I/O操作,直到操作完成为止。NodeJS却采用了事件轮询的架构,这样的话,开发人员可以设置“回调(callback)”函数,该函数会在非阻塞的异步操作完成之后被调用,这与Ajax语法的工作原理是类似的。

Promise

几年之后,在Node.js和浏览器环境中都出现了一个新的标准,名为“Promise”,它提供了强大且标准的方式来组合异步操作。Promise依然使用基于回调的格式,但是提供了一致的语法来链接(chain)和组合异步操作。Promise最初是由流行的开源库所倡导的库,在2015年最终作为原生特性添加到了JavaScript中。

Promise是一项重要的功能改善,但它们依然经常会产生冗长且难以阅读的代码。

现在,我们有了一种解决方案。

Async/await是一种新的语法(借鉴自.NET and C#),它允许我们在组合Promise时,就像正常的同步函数那样,不需要使用回调。对于JavaScript语言来说,这是非常棒的新特性,它是在JavaScript ES7中添加进来的,能够用来极大地简化已有的JS应用程序。

样例

接下来,我们将会介绍几个代码样例。

这里并不需要其他的库。在最新的Chrome、Firefox、Safari和Edge中, async/await已经得到了完整的支持,所以你可以在浏览器的控制台中尝试这些样例。另外,async/await能够用于Node.js 7.6及以上的版本,而且Babel和Typescript转译器也支持该语法,所以现在它能够用到任意的JavaScript项目中。

搭建

如果你想要在自己的机器上跟着运行这些代码的话,那么将会用到这个虚拟的API类。这个类模拟网络调用,返回Promise,这个Promise将会在调用200ms之后以简单示例数据的方式完成处理。

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

每个样例都会按顺序执行三个相同的操作:检索某个用户、检索他们的好友、获取他们的图片。最后,我们会将所有的三个结果打印在控制台上。

第一次尝试:嵌套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拒绝。

第二次尝试: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来实现的。通过这种方式,我们能够保证所有的回调处于相同的嵌套级别。我们在这里还使用了箭头函数,简化了回调函数的声明。

这个变种形式显然比前面的更易读,也更加具有顺序性,但看上去依然非常冗长和复杂。

第三次尝试: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能够让很多在此之前非常复杂的操作变得很简便。例如,如果我们想要顺序地获取某个用户的好友的好友,那该怎么实现呢?

第一次尝试:递归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)
    })
}

我们创建了一个内部函数,该函数会以Promise链的形式递归获取好友的好友,直至列表为空为止。它完全是函数式的,这一点非常好,但对于这样一个非常简单的任务来说,这个方案依然非常复杂。

注意:如果希望通过Promise.all()来简化promiseLoops()函数的话,将会导致明显不同的函数行为。本例的意图是展示顺序操作(每次一个),而Promise.all()用于并发(所有操作同时)运行异步操作。Promise.all()与async/await组合使用会有很强的威力,我们在下面的章节中将会进行讨论。

第二次尝试:Async/Await For循环

采用Async/Await之后看起来就容易多了:

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-loop即可,所以async/await是能够帮助我们的好朋友。

并行操作

按照一个接一个的顺序获取每个好友似乎有些慢,为什么不用并行的方式来进行操作呢?借助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它完成,当所有的操作都结束时,它就会进行resolve处理。

错误处理

在异步编程中,还有一个主要的问题我们没有解决,那就是错误处理。它是很多代码库的软肋,异步错误处理一般要涉及到为每个操作编写错误处理的回调。将错误传递到调用堆栈的顶部可能会非常复杂,通常需要在每个回调开始的地方显式检查是否有错误抛出。这种方式冗长繁琐并且容易出错。此外,如果没有恰当地进行处理,Promise中抛出的异常将导致悄无声息地失败,这会产生代码库中错误检查不全面的“不可见的错误”。

我们再看一下样例,为它们依次添加错误处理功能。为了测试错误处理,我们在获取用户的照片之前,将会调用一个额外的函数,“api.throwError()”。

第一次尝试: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)
  })
}

这是非常恐怖的一种写法。除了非常冗长和丑陋之外,控制流非常不直观,因为它是从输出接入的,而不像正常的、易读的代码库那样,从顶部到底部进行编写。

第二次尝试:Promise链的“Catch”方法

我们可以使用Promise的“catch”方法,对此进行一些改善。

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错误那样来进行处理。

第三次尝试:正常的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代码块中。通过这种方式,我们可以按照完全相同的方式,抛出和捕获同步代码和异步代码中的错误。这种方式简单了很多。

组合

我在前面的内容中曾经提到过,带有“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)
  }
}

结论

随着单页JavaScript Web应用的兴起和Node.js的广泛采用,对于JavaScript开发人员来说,优雅地处理并发变得比以往更加重要。async/await能够缓解很多易于引入缺陷的控制流问题,这些问题已经困扰JavaScript代码库许多年了。同时,它还能确保异步代码块更加简短、更加简洁、更加清晰。随着主流浏览器和Node.js的广泛支持,现在是一个非常好的时机将其集成到你自己的代码实践和项目之中。

阅读余下内容

一条回应:“async/await 是如何让代码更加简洁的?”

发表评论

电子邮件地址不会被公开。 必填项已用*标注


京ICP备12002735号