本文最初发布于 Patrick Triest 的个人技术博客,经原作者授权由InfoQ 中文站翻译并分享。
我是如何放弃编写回调函数并爱上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 项目中的做法非常类似。要实现非常简单的功能,结果代码块变得非常冗长且具有很深的嵌套,结尾处的代码甚至变成了这种样子:
}) }) }) } {1}
在真实的代码库中,每个回调函数可能会非常长,这可能会导致庞大且深层交错的函数。处理这种类型的代码,在回调中继续使用回调,就是通常所谓的“回调地狱”。
更糟糕的是,这里没有错误检查,所以其中任何一个回调都可能会悄无声息地发生失败,表现形式则是未处理的 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 的广泛支持,现在是一个非常好的时机将其集成到你自己的代码实践和项目之中。
感谢徐川对本文的审校。
评论