从回调地狱到Promise?

本文属于原创文章,转载请注明–来自桃源小盼的博客

前言

如果对事件循环、回调函数和promise了解很少,建议积攒一点使用经验,再阅读本文会更好。

回调地狱这个事,可能很多人并没有那么深的痛苦感受,因为最开始暴露这个问题的是在node.js中。

回调函数是什么?

在JavaScript这个世界里,函数是一等公民,再加上闭包这个特性。结果是在异步(未来将要做的事情)中,回调函数被使用的最多,在es6之前也没有别的方案可选。

1
2
3
4
5
6
7
8
9
10
11
12
// 一个异步回调的示例
function foo () {
console.log('foo')
}

function doSomething(cb) {
setTimeout(function() {
cb()
}, 1000)
}

doSomething(foo)

缺乏顺序性的表达

异步函数的执行顺序与代码编写的顺序不一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
function doSomething(cb) {
setTimeout(function() {
cb()
}, 1000)
}

function doB () {
console.log('B')
}

console.log('A')
doSomething(doB)
console.log('C')

从代码的书写顺序看来,应该打印A B C,而实际打印A C B。有经验的开发真很容易就知道结果,只是因为我们太熟悉了,其实顺序一致的代码可读性会提升很多。

当面对复杂的代码时,需要耗费很大的精力来搞清楚代码真正的执行顺序,这种不一致会导致以下几个状况:

  • 没有一个宏观上顺序性的结构来表达一大块逻辑的真实执行顺序
  • 需要仔细的阅读几乎每个函数,才能搞清楚真真的执行顺序
  • 阅读的过程中,会涉及很多文件和函数,大量的信息让大脑陷入混乱

而这个问题看似无关轻重,实则是个很基础的关键问题。

再来看promise的链式表达:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function doB () {
return new Promise((resolve) => {
setTimeout(() => {
console.log('B')
resolve()
}, 0)
})
}

Promise.resolve()
.then(() => {
console.log('A')
})
.then(doB)
.then(() => {
console.log('C')
})

promise的执行顺序是可预期的,书写顺序和执行结果保持了一致,我们清楚地知道最终结果是A B C

控制反转带来的可信任问题

一般我们使用自己编写的回调,出现问题至少还能自己修改。假如使用第三方工具库,我们并不清楚他们是如何执行的。我们能百分百信任它吗?

答案显然是无法信任,我们无法预知第三方函数的回调会如何执行,是同步还是异步,是执行一次还是多次?

下面我们将详细分析这些情况:

  • 调用回调时机:过早或者过晚
  • 调用回调次数:未调用或者多次调用
  • 吞掉可能出现的错误和异常

过早

回调可能是异步,可能是同步,如果是同步,那就早调用了。

有时候我们并不知道doSomething函数里有没有异步执行,最终的结果真是不可预测。如果回调是立即执行,结果是A B C。如果回调延迟执行,结果是A C B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 假设这个函数是第三方提供的,不一定是延迟执行
function doSomething(cb) {
// 可能立即执行
cb()
// 也可能延迟执行
setTimeout(function() {
cb()
}, 0)
}

function doB () {
console.log('B')
}

console.log('A')
doSomething(doB)
console.log('C')

Promise出现之前的一种解决方式:使用setTimeout(…, 0)的方式,主动异步。

1
2
3
4
5
6
7
8
9
10
11
12
13
function doSomething(cb) {
// 省略,同上
}

function doB () {
setTimeout(() => {
console.log('B')
}, 0)
}

console.log('A')
doSomething(doB)
console.log('C')

使用Promise的方式:

1
2
3
4
5
6
7
8
9
10
11
function doSomething(cb) {
return Promise.resolve(cb)
}

function doB () {
console.log('B')
}

console.log('A')
doSomething().then(doB)
console.log('C')

promise一定是异步的,会在下一次异步时间点调用。

过晚

跟早触发类似,如果我们不仔细查看,会认为结果是A B。但嵌套很深的时候,会出现意想不到的抢先执行。最终结果是A C B

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function doSomething(cb) {
cb()
}

function doA () {
console.log('A')
doC()
}

function doB () {
setTimeout(() => {
console.log('B')
}, 0)
}

function doC () {
setTimeout(() => {
console.log('C')
}, 0)
}

doSomething(doA)
doSomething(doB)

如果用Promise,就不会出现这种情况。因为then的回调是在下一个异步时机才会被加入到微任务队列中。

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
function doA () {
let a = new Promise(resolve => {
console.log('A')
resolve()
})

return a.then(doC)
}

function doB () {
return new Promise(resolve => {
console.log('B')
resolve()
})
}

function doC () {
return new Promise(resolve => {
console.log('C')
resolve()
})
}

doA().then()
doB().then()

未调用

在回调的执行过程中,如果出现异常或错误。可能会存在没执行回调的情况。

1
2
3
4
5
6
7
8
9
function doSomething(cb) {
// cb() 假设异常没执行
}

function doA () {
console.log('A')
}

doSomething(doA)

我们可以用一个闭包来封装一下超时异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function doSomething(cb) {
// cb() 假设异常没执行
}

function createDoA() {
let t = setTimeout(() => {
console.log('timeout')
}, 3000)


return function doA () {
clearTimeout(t)
console.log('A')
}
}

doSomething(createDoA())

promise的方式就不太一样了,promise一旦被完成或者被拒绝,它就一定会触发。
不过promise也可能存在永远不决议的情况。这时可以使用延迟处理。默认提供了race方法,会优雅一点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function doA () {
return new Promise(resolve => {
})
}

function timeout () {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('timeout')
reject('timeout')
}, 3000)
})
}

Promise.race([
doA(),
timeout()
]).then().catch(err => {
console.log('err:', err)
})

过多

一个很普通的回到,很长一段时间里都是调用一次,突然某一天出现异常,多调用了几次,然后又导致了其他问题。这种不可控,是让人比较抓狂的事情,遇到过一次,就会再次担心。

1
2
3
4
5
6
7
8
9
10
11
function doSomething(cb) {
// 异常多调用了
cb()
cb()
}

function doA () {
console.log('A')
}

doSomething(doA)

一般我们只能在外部记录一个值,来防止重复执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function doSomething(cb) {
cb()
cb()
}

// 记录是否被调用
let used = false

function doA () {
if (used) {
return
}
used = true
console.log('A')
}

doSomething(doA)

而promise就没有这种担忧了,因为promise只能被决议一次,所以即使多次调用,也不会执行。

1
2
3
4
5
6
7
8
9
10
11
12
function doSomething() {
return new Promise(resolve => {
resolve()
resolve()
})
}

function doA () {
console.log('A')
}

doSomething().then(doA)

吞掉异常

回调过程中发生异常,如果我们不主动捕获,并不能获得错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
function doSomething(cb) {
setTimeout(() => {
throw new Error('error')
cb()
})
}

function doA () {
console.log('A')
}

doSomething(doA)

我们必须要手动捕获,但是try&catch方式无法捕获异步错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function doSomething(cb) {
throw new Error('error')

cb()
}

function doA () {
console.log('A')
}

try {
doSomething(doA)
} catch (err) {
// 捕获不到错误
console.log('catch', err)
}

所以社区形成了error-first风格的异常处理,这种风格在一个函数内部同时处理成功和异常逻辑。当多级error-first嵌套时,又回到了回调地域,而且是更复杂的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function doSomething(cb) {
cb(new Error('error'), { value: 'A'})
// cb(null, { value: 'A'})
}

function doA (error, res) {
if (error) {
console.log(error)
return
}
console.log(res.value)
}

doSomething(doA)

但是promise这种分离回调的风格,将成功和异常分开处理了:

1
2
3
4
5
6
7
8
9
10
11
12
13
function doSomething() {
return new Promise((resolve, reject) => {
throw new Error('error')

resolve()
})
}

doSomething().then(res => {
console.log('A')
}).catch(err => {
console.log('catch:', err)
})

多个异步回调统筹协调的重复逻辑

有些异步任务需要同时成功,有些只需要一个成功即可,或者多个异步的等待执行,等等各种情况,会在回调函数中经常出现。多个异步任务的处理,会变得复杂,修改难度增大。代码开始难以维护了。

虽然我们可以自己封装或是使用知名类库,但总是风格各异。最终的局面就是不同的项目或不同的团队使用方式千差万别。

而Promise为我们提供了一系列的函数,可以更方便地应对复杂情况:

  • Promise.all(): 所有都成功
  • Promise.race():任意一个成功或拒绝
  • Promise.allSettled():所有都成功或失败
  • Promise.any():任意一个成功

promise崛起前传

人类大脑对于事情的计划方式是线型的、阻塞的、单线程的语义,但是回调表达异步流程的方式是非线型的、非顺序的,这使得正确推导代码的执行顺序难度很大。

回调暗中把控制权交给了第三方,这种控制转移导致一系列的信任问题,即便有一些方法能缓解信任问题,但也产生了更难维护的代码。

于是社区出现了各种各样的方案,都只是够用,还不够好和优雅。代码复杂度也随之提高了。

而promise的出现,就是为了解决以上的问题,它在一定程度上缓解了这些问题,不过promise最终并没有彻底解决这些问题,同时又出现了新的问题。

如果说回调地域是恶龙,那promise就是屠龙少年。戏剧性的一幕来了,promise成为了新的恶龙,那谁是下一个屠龙少年呢?

参考

  • MDN Promise
  • 《JavaScript高级程序设计》
  • 《你不知道的JavaScript中卷》