【译】async/await 优点、陷阱以及如何使用

6,355 阅读7分钟

最近公事甚多,好久没学习了,自己也撸了个小网站,欢迎 star。

JavaScript async/await: The Good Part, Pitfalls and How to Use

ES7 推出的 async/await 特性对 JS 的异步编程是一个重大的改进。在不阻塞主线程的情况下,它为我们提供了使用同步代码风格去异步获取资源的能力。当然使用它也是需要一些技巧,这篇文章我们从不同角度去探索 async/await,为你展示如何正确、高效的使用它们。

async/await 优点

它最大的优点就是给我们带来同步代码风格。见代码:

// async/await
async getBooksByAuthorWithAwait(authorId) {
  const books = await bookModel.fetchAll();
  return books.filter(b => b.authorId === authorId);
}
// promise
getBooksByAuthorWithPromise(authorId) {
  return bookModel.fetchAll()
    .then(books => books.filter(b => b.authorId === authorId));
}

很显然,async/await 版本比 promise 版本更简单易懂。如果你忽略 await 关键字,那么代码就如同其他同步编程语言,如 Python

优点不仅仅是可读性,async/await 已经被浏览器原生支持。如今,所有主流浏览器已经完全支持

原生支持,意味着你不必转换代码,而更重要的是有利于调试。当你在函数的 await 代码行打上断点,然后步进到下一行时,你会发现调试器在 bookModel.fetchAll() 操作的时候进行了短暂的停留,然后才真正的步进到 .filter 代码行!这比 promise 调试更方便,因为你需要在 .fliter 代码行再打一个断点。

另一个很少被人注意到的优点是 async 关键字。它表明了 getBooksByAuthorWithAwait() 函数的返回值一定是个 promise,所以它的调用者可以使用 getBooksByAuthorWithAwait().then(...) 或者安全的使用 await getBooksByAuthorWithAwait()。见代码(错误的实践!):

getBooksByAuthorWithPromise(authorId) {
  if (!authorId) {
    return null;
  }
  return bookModel.fetchAll()
    .then(books => books.filter(b => b.authorId === authorId));
  }
}

上面的代码段中,getBooksByAuthorWithPromise 可能会返回一个 promise(正常情况)或者 null 值(异常情况),而后者这种情况,调用者无法安全的使用 .then()。而有了 async 声明,就会避免这种不确定性。

async/await 有时具有误导性

一些文章会比较 async/awaitpromise 并声称它是下一代 JS 异步编程,而我不同意这种观点。async/await 的确是一种改进,但它不过是个语法糖,不会彻底改变我们的编程风格。

本质来说,async 函数仍然是 promises。在正确的使用 async 之前,你需要理解 promise,可能你在使用 async 的过程中也需要使用到 promise

回顾一下上面代码中的 getBooksByAuthorWithAwait()getBooksByAuthorWithPromises() 函数,他们不仅功能完全相同,而且具有相同的接口。

这意味着,直接调用 getBooksByAuthorWithAwait() 会返回一个 promise

这不见得是件坏事,而多数人认为 await 可以让异步函数变为同步函数的想法才是错误的。

async/await陷阱

哪么我们在使用 async/await 会犯哪些错误呢?以下是一些常见点。

太同步化

尽管 await 能让我们的代码看起来同步化,但要牢记它们仍然是异步的内容,所以值得我们去关注代码以避免太同步化。

async getBooksAndAuthor(authorId) {
  const books = await bookModel.fetchAll();
  const author = await authorModel.fetch(authorId);
  return {
    author,
    books: books.filter(book => book.authorId === authorId),
  };
}

这段代码看上去没有什么问题,但是它是错误的。

  1. await bookModel.fetchAll() 会等待 fetchAll() 返回
  2. 紧接着 await authorModel.fetch(authorId) 才会被调用

注意到 authorModel.fetch(authorId) 并不依赖 bookModel.fetchAll() 的结果,实际上他们可以并行执行! 而在这里使用 await 会导致两个函数串行执行,而执行时间也会比并行执行长。

这是正确的做法:

async getBooksAndAuthor(authorId) {
  const bookPromise = bookModel.fetchAll();
  const authorPromise = authorModel.fetch(authorId);
  const book = await bookPromise;
  const author = await authorPromise;
  return {
    author,
    books: books.filter(book => book.authorId === authorId),
  };
}

而如果你想依次获取一个列表中的所有项,你必须依赖 promises

async getAuthors(authorIds) {
  // 错误,这会导致`串行执行`
  // const authors = _.map(
  //   authorIds,
  //   id => await authorModel.fetch(id));

  // 正确
  const promises = _.map(authorIds, id => authorModel.fetch(id));
  const authors = await Promise.all(promises);
}

简而言之,你仍然需要把工作流当成是异步的,然后尝试使用 await 去写同步代码。在更加复杂的工作流中,直接使用 promise 可能更方便。

错误处理

结合 promises,一个异步函数只有两个可能的返回值:resolve值reject值,然后我们可以使用 .then() 处理正常情况、.catch() 处理异常情况。但是 async/await 的错误处理就需要点技巧了。

try...catch

最常见(也是我推荐)的方法就是使用 try..catch。当 await 一个操作时,操作中任何 reject值 都会当作异常抛出。见代码:

class BookModel {
  fetchAll() {
    return new Promise((resolve, reject) => {
      window.setTimeout(() => { reject({'error': 400}) }, 1000);
    });
  }
}
// async/await
async getBooksByAuthorWithAwait(authorId) {
try {
  const books = await bookModel.fetchAll();
} catch (error) {
  console.log(error);    // { "error": 400 }
}

输出的错误对象正是 reject值。捕获异常之后,我们可以使用如下方法处理它们:

  • 处理异常,返回一个正常值(在 catch 代码块不使用 return 语句等同于 return undefined;,当然这也算是个正常值)。
  • 如果你想让调用者处理异常,那就抛出。你可以直接抛出异常对象,如 throw error,这样允许你在 async getBooksByAuthorWithAwait() 函数上使用 promise 链式操作(即:getBooksByAuthorWithAwait().then(...).catch(error => ...));或者使用 Error 对象包装你的错误对象,如 throw new Error(error),这样在控制台查看错误时,你可以看到完整的堆栈记录。
  • reject错误对象,如 return Promise.reject(error)。这等同于第一种做法,所以不推荐。

使用 try...catch 的好处如下:

  • 简单、传统,如果你有诸如 JavaC++ 编程语言经历,理解起来不费事。
  • 在一个 try...catch 代码块中你可以在 try 代码块包裹多行 await 语句,并且如果前置错误处理没有必要的话,你可以在一个地方(即 catch 代码块)处理错误。

这个方案仍然有它的瑕疵,try...catch 可以捕获代码块内的所有错误,包括那些不被 promises 捕获的错误。见代码:

class BookModel {
  fetchAll() {
    cb();    // `cb` 因为没有被定义所有会导致异常
    return fetch('/books');
  }
}
try {
  bookModel.fetchAll();
} catch(error) {
  console.log(error);  // 这里打印 "cb is not defined"
}

运行这段代码,你会在控制台得到 ReferenceError: cb is not defined 黑色字体输出信息。你要知道,这里的错误是通过 console.log() 输出的,并不是 JS 本身抛出(JS 抛出错误是红色字体)。有时这会很致命:如果 BookModel 被其它一些函数调用深深嵌套、包裹,其中一个调用吞并异常,那么想找到例子中的这种错误就会变得极其困难。

让函数返回所有值

Go 语言启发,另一种处理错误的方法就是允许 async 函数返回异常结果两个值(请参阅 How to write async await without try-catch blocks in Javascript),即你可以这样使用 async 函数:

[err, user] = await to(UserModel.findById(1));

我个人不建议使用这种实现,因为它把 Go 语言的风格带到了 JS,这让我感觉很不自然,但是个别情况下,使用它是极其合适的。

使用.catch()

最后一个方法就是继续使用 .catch()

回想一下 await 的作用:它等待 promise 完成工作,也请记住 promise.catch() 也会返回一个 promise!所以我们可以这些处理错误:

// 如果发生异常,但是 catch 语句没有显示返回,那么 books === undefined
let books = await bookModel.fetchAll()
  .catch((error) => { console.log(error); });

这个实现有两个瑕疵:

  • 它是 promiseasync 的混合函数。你需要理解 promise 才能读懂它。
  • 错误处理在返回之前,这不是很直观。

结论

ES7async/await 特性对 JS 异步编程是个巨大的改进。它让代码可读性更好、更方便调试。但是想要正确的使用他们,你必须彻底了解 promise。因为它只是个语法糖,它依赖的技术仍然是 promise