停止书写回调函数并爱上ES8

Author Avatar
GeniusFunny 4月 14, 2018
  • 在其它设备中阅读本文章

停止书写回调函数并爱上ES8

以前,JavaScript项目会逐渐‘失去控制’,其中主要一个原因就是采用传统的回调函数处理异步任务时,一旦业务逻辑比较复杂,我们就难免书写一些冗长、复杂、嵌套的代码块(回调地狱),这会严重降低代码的可读性与可维护性。现在,JavaScript提供了一种新的语法糖来取代回调函数,使我们能够编写简明、可读性高的异步代码。

背景

AJAX

先来回顾一下历史。在20世纪90年代后期,Ajax是异步JavaScript的第一个重大突破。这一技术允许网站在加载HTML后获取并显示最新的数据,这是一个革命性的想法。在这之前,大多数网站会再次下载整个页面来显示更新的内容。这一技术(在jQuery中以ajax的名称流行)主导了2000-2010的web开发并且Ajax是目前网站用来获取数据的主要技术,但是XML在很大程度上取代了JSON。

NodeJS

当NodeJS在2009年首次发布时,服务器端环境的主要焦点是允许程序优雅地处理并发性。大多数服务器端语言通过阻塞代码来处理I/O操作,直到操作完成为止。相反,NodeJS使用的是事件循环机制,这样开发人员可以在非阻塞异步操作完成后,调用回调函数来处理逻辑(类似于Ajax的工作方式)。

Promises

几年后,NodeJS和浏览器环境中出现了一种新的标准,称为”Promise”,Promise提供了一种强大的、标准化的方式来组成异步操作。Promise仍然使用基于回调的格式,但为链式和组合异步操作提供了一致的语法。在2015年,由流行的开源库所倡导的Promise最终被添加为JavaScript的原生特性。
Promise是一个不错的改进,但它们仍然常常是一些冗长而难以阅读的代码块的原因。
而现在有了一个解决方案。
Async/Await是一种新的语法(从.net和C#中借用),它允许我们编写Promise,但它们看起来像是同步代码,没有回调,可以用来简化几乎任何现有的JS应用程序。Async/Await是JavaScript语言的新增的特性,在ES7中被正式添加为JavaScript的原生特性。

示例

我们将通过一些代码示例来展示Async/Await的魅力

*注:运行下面的示例不需要任何库。Async/Await已经被最新版本的Chrome、FireFox、Safari、Edge完全支持,你可以在你的浏览器控制台里运行例子。Async/Await需要运行在NodeJS 7.6版本及以上,同时也被Babel、TypeScript转译器支持。所以Async/Await可以被用于实际开发之中。*

准备

我们会使用一个虚拟的API类,你也可以在你的电脑上运行。这个类通过返回promise来模拟异步请求。正常情况下,promise被调用后,200ms后会对数据进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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)
})
}
}

每个示例依次执行如下三个操作: 获取一个用户的信息,获取该用户的朋友, 获取该用户的照片。在最后,我们会在控制台中打印这些结果。

方法一 — Nested Promise Callback Functions

使用嵌套的promise回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
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
2
3
4
     })
})
})
}

在真实的业务场景中,每个回调函数可能更复杂,代码块会以一堆充满层次感的})为结尾。“回调函数里面嵌套着回调函数嵌套着回调函数”,这就是被传说中的“回调地狱”(“回调地狱”的诞生不只是因为代码块的混乱,也源于信任问题。)。
更糟糕的是,我们为了简化,还没有做错误处理机制,如果加上了reject……细思极恐

方法二 — Promise Chain

让我们优雅起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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.prototype.then()和Promise.prototype.catch()返回Promise对象,这就使得我们可以将这些promise连接成一个promise链。通过这种方法,我们可以将这些回调函数放在一个缩进层次里。与此同时,我们使用了箭头函数简化了回调函数声明。
对比之前的回调地狱,使用promise链使得代码的可读性大大提高并且拥有着更好的序列感,但是看起来还是非常冗长并且有一点复杂。

方法三 — Async/Await

我们可不可以不写回调函数?就写7行代码能解决吗?

1
2
3
4
5
6
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 })
}

优雅多了,调用await之前我们会一直等待,直到promise被决议并将值赋值给左边的变量。通过async/await,我们可以对异步操作流程进行控制,就好像它是同步代码。

注:await必须搭配async一起使用,注意上面的函数,我们将关键字async放在了函数的声明前,这是必需的。稍后,我们会深入讨论这个问题

循环

Async/Await可以让以前很多复杂的代码变得简明。举个例子,如果我们要按序检索每个用户的朋友的朋友列表。

方法一 — Recursive Promise Loop

下面是使用传统的promise来按序获取每个朋友的朋友列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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)
})
}

我们创建在promiseLoops中创建了一个函数用于递归地去获取朋友的朋友列表。这个函数体现了函数式编程,但是对于这个简单的任务而言,这依旧是一个比较复杂的解决方案。

方法二 — Async/Await For-Loop

让我们尝试一下Async/Await

1
2
3
4
5
6
7
8
9
10
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来处理并行任务吗?
当然

1
2
3
4
5
6
7
8
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.all()会返回一个promise,一旦所有的请求完成就会决议。

错误处理

然而,在异步编程中有一个主要的问题还没解决:错误处理。在异步操作中,我们必须为每个操作编写单独的错误处理回调,在调用栈的顶部去找出正确的报错位置可能很复杂,所以我们得在每个回调开始时就去检查是否抛出了错误。所以,引入错误处理后的回调函数会比之前复杂度成倍增加,如果没有主动定位到报错的位置,这些错误甚至会被“吞掉”。
现在,我们给之前的例子添上错误处理机制。为了测试错误处理机制,我们将在真正获取到用户图片之前使用抽象类里的api.throwError()方法。

方法一 — Promise Error Callbacks

让我们看看最坏的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 Chain “Catch” Method

我们可以给promise链添加catch方法来改善一些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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添加一个错误处理取代了之前给每个回调函数添加错误处理。但是,这还是有一点复杂并且我们还是需要使用一个特殊的回调来处理异步错误而不是像对待正常的JavaScript错误那样处理它们。

方法三 — Normal Try/Catch Block

我们可以做得更好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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函数获取数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
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,这将使得整个异步模块更加明显。
如果我们要获取前面10个用户的数据呢?

1
2
3
4
5
6
7
async function getLotsOfUserData () {
const users = []
while (users.length < 10) {
users.push(await getUserInfo())
}
console.log('getLotsOfUserData', users)
}

并发呢?并且加上错误处理呢?

1
2
3
4
5
6
7
8
9
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)
}
}

结论

随着SPA的兴起和NodeJS的广泛应用,对于JavaScript开发人员来说,优雅地处理并发性比以往任何时候都要重要。Async/Await缓解了许多因为bug引起且已经影响JavaScript很多年的控制流问题,并且使得代码更加优雅。如今,主流的浏览器和NodeJS都已经支持了这些语法糖,所以现在是使用Async/Await的最好时机。

原文链接:https://blog.patricktriest.com/what-is-async-await-why-should-you-care/
掘金地址:https://juejin.im/post/5ad1cab8f265da238a30e137