JavaScript 中的 Promise

JavaScript 中的 Promise

最后更新于 2020-11-14

最近在回过头来看之前整理过的 Promise 相关内容,发现有许多不完善或是遗漏的地方,所以打算抽些时间重新的梳理一下 Promise 相关内容,从什么是 Promise 开始,到它的各种使用方式,最后我们再来手动模拟实现一个 Promise 了解一下它的运行过程(整理完发现内容较多,所以另起篇幅,见 Promise 的实现),下面就让我们先从为什么要使用 Promise 开始看起

为什么要使用 Promise

我们在接触一个新东西的时候,都应该先了解一下我们为什么要使用它,关于 Promise 这个东西,官方的说法是,Promise 是一个对象,它代表了一个异步操作的最终完成或者失败,我们先来看一个示例,示例很简单,就是读取当前目录下的 1.txt 这个文件,然后在控制台输出这个文件内容

1
2
3
4
5
var fs = require('fs')

fs.readFile('1.txt', 'utf8', function (err, data) {
console.log(data)
})

看起来很简单,然后我们现在再进一步,读取两个文件,然后在控制台分别输出这两个文件内容

1
2
3
4
5
6
7
8
var fs = require('fs')

fs.readFile('1.txt', 'utf8', function (err, data) {
console.log(data)
fs.readFile('2.txt', 'utf8', function (err, data) {
console.log(data)
})
})

要是读取更多的文件呢?

1
2
3
4
5
6
7
8
9
10
11
var fs = require('fs')

fs.readFile('1.txt', 'utf8', function (err, data) {
fs.readFile('2.txt', 'utf8', function (err, data) {
fs.readFile('3.txt', 'utf8', function (err, data) {
fs.readFile('4.txt', 'utf8', function (err, data) {
// ...
})
})
})
})

通过上面这个简单的示例我们可以发现,在传统的异步编程中,如果异步之间存在依赖关系,我们就需要通过层层嵌套回调来满足这种依赖,如果嵌套层数过多,可读性和可维护性都变得很差,产生所谓回调地狱,而 Promise 将回调嵌套改为链式调用,增加了可读性和可维护性,下面我们就来看看如何使用 Promise 来改写上面的示例

Promise

那么什么是 Promise 呢?首先它是一个对象,它和 JavaScript 普通的对象没什么区别,同时它也是一种规范,跟异步操作约定了统一的接口,表示一个异步操作的最终结果,以同步的方式来写代码,执行的操作是异步的,但又保证程序执行的顺序是同步的,可以总结为以下这些特点

  • 只有三种状态,未完成,完成(fulfilled)和失败(rejected
  • 状态可以由未完成转换成完成,或者未完成转换成失败
  • 状态转换只发生一次
  • 状态转换完成后就是外部『不可变』的值,我们可以安全地把这个值传递给第三方,并确信它不会被有意无意的修改(特别是对于多方查看同一个 Promise 状态转换的情况)

下面我们就来看看如何使用 Promise 来改写我们之前的读取文件的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var fs = require('fs')

function readFile() {
return new Promise(function (resolve, reject) {
fs.readFile('1.txt', 'utf-8', function (err, data) {
if (err) {
reject(err)
} else {
resolve(data)
}
})
})
}

readFile().then(function (data) {
console.log(data)
}).catch(function (err) {
console.log(err)
})

我们将其封装成了一个 readFile() 的函数,在内部我们返回了一个新的 Promise,这样在调用了之后就可以使用 then 方法来接收它的成功和失败的回调,是不是看上去清爽很多,这也是 Promise 最为基本的使用方式了,下面我们就来稍微的深入一些,来看看 Promise 提供的各种方法以及如何进行错误处理的

Promise.resolve()

一般情况下我们都会使用 new Promise() 来创建 Promise 对象,但是除此之外我们也可以使用其他方法,比如静态方法 Promise.resolve(value) 可以认为是 new Promise() 方法的快捷方式,比如 Promise.resolve(42) 可以认为是以下代码的语法糖

1
2
3
new Promise(function (resolve) {
resolve(42)
})

在这段代码中的 resolve(42) 会让这个 Promise 对象立即进入确定(即 resolved)状态,并将 42 传递给后面 then 里所指定的 onFulfilled 函数,方法 Promise.resolve(value) 的返回值也是一个 Promise 对象,所以我们可以像下面那样接着对其返回值进行 .then 调用

1
2
3
Promise.resolve(42).then(function (value) {
console.log(value)
})

Promise.resolve 方法另一个作用就是将 Thenable 对象转换为 Promise 对象,而所谓的 Thenable 对象,简单来说就是一个非常类似 Promise 的东西,就像类数组一样,Thenable 指的是一个具有 .then 方法的对象,最简单的例子就是 $.ajax(),因为它的返回值是 jqXHR Object 对象,这个对象具有 .then 方法,在这种情况下我们就可以使用 Promise.resolve 来将其转换为一个 Promise 对象

1
2
3
4
5
6
// 返回 Promise 对象
var promise = Promise.resolve($.ajax(url))

promise.then(function (value) {
console.log(value)
})

但是这里有一个需要注意的地方,jqXHR Object 对象虽然继承了来自 Deferred Object 的方法和属性,但是 Deferred Object 并没有遵循 Promises/A+ES6 Promises 标准,所以即使看上去这个对象转换成了一个 Promise 对象,但是会出现缺失部分信息的问题,这个问题的根源在于 jQueryDeferred Objectthen 方法机制与 Promise 不同,所以我们应该注意,即使一个对象具有 .then 方法,也不一定就能作为 ES6 Promises 对象使用

针对于以上内容,我们简单的总结一下就是,可以认为 Promise.resolve 方法的作用就是将传递给它的参数填充(Fulfilled)到 Promise 对象后并返回这个 Promise 对象,此外 Promise 的很多处理内部也是使用了 Promise.resolve 算法将值转换为 Promise 对象后再进行处理的

Promise.reject()

Promise.reject(error) 是和 Promise.resolve(value) 类似的静态方法,是 new Promise() 方法的快捷方式,比如 Promise.reject(new Error('出错了')) 就是下面代码的语法糖形式

1
2
3
new Promise(function (resolve, reject) {
reject(new Error('出错了'))
})

这段代码的功能是调用该 Promise 对象通过 then 指定的 onRejected 函数,并将错误(Error)对象传递给这个 onRejected 函数

1
2
3
Promise.reject(new Error('出错了')).catch(function (error) {
console.error(error)
})

它和 Promise.resolve(value) 的不同之处在于 Promise 内调用的函数是 reject 而不是 resolve,一般使用较少,不过在编写测试或是问题排查的情况下还是可以用得上的

then()

我们在前面的章节里大致已经了解了 Promise 基本的实例方法 thencatch 的使用方式,想必我们也都已经知道了 .then().catch() 这种链式方法的写法了,其实在 Promise 里可以将任意个方法连在一起作为一个执行链

1
2
3
4
5
6
7
aPromise.then(function taskA(value) {
// Task A
}).then(function taskB(vaue) {
// Task B
}).catch(function onRejected(error) {
console.log(error)
})

如果把在 then 中注册的每个回调函数称为 Task 的话,那么我们就可以通过 Promise 执行链方式来编写能以 Task A ==> Task 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 taskA() {
console.log(`Task A`)
}

function taskB() {
console.log(`Task B`)
}

function onRejected(error) {
console.log(`Catch Error: A or B`, error)
}

function finalTask() {
console.log(`Final Task`)
}

var promise = Promise.resolve()

promise
.then(taskA)
.then(taskB)
.catch(onRejected)
.then(finalTask)

上述代码的执行流程,如果用一张图来描述一下的话,像下面的图那样

可以发现,虽然我们没有为 then 方法指定第二个参数(onRejected),但是我们会发现 Task ATask B 都有指向 onRejected 的线出来,这些线的意思是在 Task ATask B 的处理中,在下面的情况下就会调用 onRejected 方法

  • 发生异常的时候
  • 返回了一个 Rejected 状态的 Promise 对象

我们在 Promise 中的处理习惯上都会采用 try-catch 的风格,当发生异常的时候,会被 catch 捕获并被由在此函数注册的回调函数进行错误处理,但是大多数情况下我们对于异常处理策略是通过返回一个 Rejected 状态的 Promise 对象来实现的,这种方法不通过使用 throw 就能在 Promise 执行链中对 onRejected 进行调用,但是针对上面的示例还有一点需要注意的,那就是由于在 onRejectedFinal Task 后面没有 catch 处理了,因此在这两个 Task 中如果出现异常的话将不会被捕获

下面我们再来看一个具体的关于 Task A ==> onRejected 的例子

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
function taskA() {
console.log(`Task A`)
throw new Error(`throw Error @ Task A`)
}

function taskB() {
// 不会被调用
console.log(`Task B`)
}

function onRejected(error) {
// => `throw Error @ Task A`
console.log(error)
}

function finalTask() {
console.log(`Final Task`)
}

var promise = Promise.resolve()

promise
.then(taskA)
.then(taskB)
.catch(onRejected)
.then(finalTask)

运行流程如下所示

运行以后可以发现,Task B 是不会被调用的,在本例中我们在 Task A 中使用了 throw 方法故意制造了一个异常,但在实际中想主动进行 onRejected 调用的时候,应该返回一个 Rejected 状态的 Promise 对象,关于这种两种方式的异同,我们会在下面进行介绍

我们仔细观察之前的示例可以发现,其实我们中间的 Task 都是相互独立的,只是被简单调用而已,但是这时候如果 Task A 想给 Task B 传递一个参数我们该怎么办呢?答案非常简单,那就是在 Task Areturn 一个返回值,这样就会在 Task 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 doubleUp(value) {
return value * 2
}

function increment(value) {
return value + 1
}

function output(value) {
// => (1 + 1) * 2
console.log(value)
}

var promise = Promise.resolve(1)

promise
.then(increment)
.then(doubleUp)
.then(output)
.catch(function (error) {
// 如果执行链中出现异常会被调用
console.error(error)
})

运行流程如下所示

每个方法中 return 的值不仅只局限于字符串或者数值类型,也可以是对象或者 Promise 对象等复杂类型,return 的值会由 Promise.resolve() 进行相应的包装处理,因此不管回调函数中会返回一个什么样的值,最终 then 的结果都是返回一个新创建的 Promise 对象,也就是说 then 不仅仅是注册一个回调函数那么简单,它还会将回调函数的返回值进行变换,创建并返回一个新的 Promise 对象

从代码来看的话,可能会以为 .then().catch() 的方式像是针对最初的 Promise 对象进行了一连串的执行链调用,然而实际上不管是 then 还是 catch 都会返回了一个新的 Promise 对象,下面我们就来看看如何确认这两个方法返回的到底是不是新的 Promise 对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var aPromise = new Promise(function (resolve) {
resolve(100)
})

var thenPromise = aPromise.then(function (value) {
console.log(value)
})

var catchPromise = thenPromise.catch(function (error) {
console.error(error)
})

console.log(aPromise !== thenPromise) // => true
console.log(thenPromise !== catchPromise) // => true

=== 是严格相等比较运算符,我们可以看出这三个对象都是互不相同的,这也就证明了 thencatch 都返回了和调用者不同的 Promise 对象,也就是如下图所示

如果我们知道了 then 方法每次都会创建并返回一个新的 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
26
27
28
29
30
31
32
33
// 第一种情况,对同一个 Promise 对象同时调用 `then` 方法
var aPromise = new Promise(function (resolve) {
resolve(100)
})

aPromise.then(function (value) {
return value * 2
})

aPromise.then(function (value) {
return value * 2
})

aPromise.then(function (value) {
// => 100
console.log(value)
})

// VS

// 第二种情况,对 `then` 进行链式调用
var bPromise = new Promise(function (resolve) {
resolve(100)
})

bPromise.then(function (value) {
return value * 2
}).then(function (value) {
return value * 2
}).then(function (value) {
// => 100 * 2 * 2
console.log(value)
})

通过对比,我们可以发现两种方式的结果是不一样的

  • 第一种方式当中并没有使用 Promise 的执行链形式,这在 Promise 中是应该极力避免的写法,这种写法中的 then 调用几乎是在同时开始执行的,而且传给每个 then 方法的 value 值都是 100
  • 第二种写法则采用了执行链的方式将多个 then 方法调用串连在了一起,各函数也会严格按照 resolve => then => then => then 的顺序执行,并且传给每个 then 方法的 value 的值都是前一个 Promise 对象通过 return 返回的值

但是我们需要注意下面这种使用方式

1
2
3
4
5
6
7
8
function badAsyncCall() {
var promise = Promise.resolve()
promise.then(function () {
// 任意处理
return newVar
})
return promise
}

这种写法有很多问题,首先在 .then() 中产生的异常不会被外部捕获,此外也不能得到 then 的返回值(即使它有返回值),这是因为每次 .then() 的调用都会返回一个新创建的 Promise 对象,因此我们需要像上述方式二那样,采用链式调用的方式,修改后的代码如下所示

1
2
3
4
5
6
7
function anAsyncCall() {
var promise = Promise.resolve()
return promise.then(function () {
// 任意处理
return newVar
})
}

有了上面的知识点铺垫以后,我们就可以来解决我们在实际使用场景当中所遇到的问题,比如在接口返回的数据量非常大的时候,并且如果集中在其中某一个接口来处理的话过于庞大,这时我们可以考虑在多个 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
26
27
28
29
30
31
32
33
34
35
// 后端返回的数据
let result = {
aList: [
//...
],
bList: [
//...
],
cList: [
//...
],
//...
}

function getInfo() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(result)
}, 500)
})
}

getInfo().then(res => {
let { aList } = res // 使用 aList 进行操作
console.log(aList) // 为下一个 then 方法返回 res
return res
}).then(res => {
let { bList } = res // 同理
console.log(bList)
return res
}).then(res => {
let { cList } = res // 同理
console.log(cList)
return res
})

catch()

我们在上面的 then() 章节当中已经简单地使用了 catch() 方法,实际上 catch() 只是 promise.then(undefined, onRejected) 方法的一个别名而已,也就是说这个方法用来注册当 Promise 对象状态变为 Rejected 时的回调函数,但是也有一些我们需要注意的地方,如下

1
2
3
4
5
var promise = Promise.reject(new Error(`message`))

promise.catch(function (error) {
console.error(error)
})

如上代码在 IE8 及以下版本则会出现 identifier not found 的语法错误,这是因为 IE8 及以下版本都是基于 ECMAScript 3 来实现的,因此不能将 catch 作为属性来使用,也就不能编写类似 promise.catch() 的代码,在这种情况下我们就可以采用 中括号标记法

1
2
3
4
5
var promise = Promise.reject(new Error(`message`))

promise['catch'](function (error) {
console.error(error)
})

或者我们不单纯的使用 catch,而是使用 then 也是可以避免这个问题的

1
2
3
4
5
var promise = Promise.reject(new Error(`message`))

promise.then(undefined, function (error) {
console.error(error)
})

由于 catch 标识符可能会导致问题出现,因此一些类库也采用了 caught 作为函数名,而函数要完成的工作是一样的,而且很多压缩工具自带了将 promise.catch 转换为 promise['catch'] 的功能,所以可能不经意之间也能帮我们解决这个问题

then() 和 catch()

在之前的章节当中,我们提到过 .catch 也可以理解为 promise.then(undefined, onRejected),所以在本节当中我们就来具体的看一看 .thencatch 有什么异同,先来看下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function throwError(value) {
throw new Error(value)
}

// 第一种情况,onRejected 不会被调用
function badMain(onRejected) {
return Promise.resolve(42).then(throwError, onRejected)
}

// 第二种情况,有异常发生时 onRejected 会被调用
function goodMain(onRejected) {
return Promise.resolve(42).then(throwError).catch(onRejected)
}

// 运行示例
badMain(function () {
console.log(`BAD`)
})

goodMain(function () {
console.log(`GOOD`)
})

在上面的代码中,badMain 是一个不太好的实现方式(但也不是说它有多坏),goodMain 则是一个能非常好的进行错误处理的版本,为什么说 badMain 不好呢?因为虽然我们在 .then 的第二个参数中指定了用来错误处理的函数,但实际上它却不能捕获第一个参数 onFulfilled 指定的函数(本例为 throwError)里面出现的错误,也就是说这时候即使 throwError 抛出了异常,onRejected 指定的函数也不会被调用

与此相对的是,goodMain 的代码则遵循了 throwError ==> onRejected 的调用流程,这时候 throwError 中出现异常的话,在会被执行链中的下一个方法,即 .catch 所捕获,进行相应的错误处理,.then 方法中的 onRejected 参数所指定的回调函数,实际上针对的是其 Promise 对象或者之前的 Promise 对象,而不是针对 .then 方法里面指定的第一个参数,即 onFulfilled 所指向的对象,这也是 thencatch 表现不同的原因

这种情况下 then 是针对 Promise.resolve(42) 的处理,在 onFulfilled 中发生异常,在同一个 then 方法中指定的 onRejected 也不能捕获该异常,在这个 then 中发生的异常,只有在该执行链后面出现的 catch 方法才能捕获,当然,由于 .catch 方法是 .then 的别名,我们使用 .then 也能完成同样的工作,只不过使用 .catch 的话意图更明确,更容易理解

1
Promise.resolve(42).then(throwError).then(null, onRejected)

Promise 的构造函数,以及被 then 调用执行的函数基本上都可以认为是在 try-catch 代码块中执行的,所以在这些代码中即使使用 throw,程序本身也不会因为异常而终止,但是如果在 Promise 中使用 throw 语句的话,会被 try-catch 住,最终 Promise 对象也变为 Rejected 状态

1
2
3
4
5
6
7
8
var promise = new Promise(function (resolve, reject) {
throw new Error(`message`)
})

promise.catch(function (error) {
// => `message`
console.error(error)
})

以上代码虽然可以正常运行,但是如果想把 Promise 对象状态设置为 Rejected 状态的话,使用 reject 方法则更显得合理,所以上面的代码可以改写为下面这样

1
2
3
4
5
6
7
8
var promise = new Promise(function (resolve, reject) {
reject(new Error(`message`))
})

promise.catch(function (error) {
// => `message`
console.error(error)
})

其实我们也可以这么来考虑,在出错的时候我们并没有调用 throw 方法,而是使用了 reject,那么给 reject 方法传递一个 Error 类型的对象也就很好理解了,在 Promise 构造函数中,有一个用来指定 reject 方法的参数,建议使用这个参数而不是依靠 throwPromise 对象的状态设置为 Rejected 状态

那么如果像下面那样想在 then 中进行 reject 的话该怎么办呢?

1
2
3
4
5
6
7
8
9
10
11
var promise = Promise.resolve()

promise.then(function (value) {
setTimeout(function () {
// 经过一段时间后还没处理完的话就进行 reject - 2
}, 1000)
// 比较耗时的处理 - 1
somethingHardWork()
}).catch(function (error) {
// 超时错误 - 3
})

上面的超时处理,需要在 then 中进行 reject 方法调用,但是传递给当前的回调函数的参数只有前面的 Promise 对象,在这种情况下该怎么办呢?

在这里我们再次回忆下 then 的工作原理,在 then 中注册的回调函数可以通过 return 返回一个值,这个返回值会传给后面的 thencatch 中的回调函数,而且 return 的返回值类型不光是简单的字面值,还可以是复杂的对象类型,比如 Promise 对象等

这时候,如果返回的是 Promise 对象的话,那么根据这个 Promise 对象的状态,在下一个 then 中注册的回调函数中的 onFulfilledonRejected 的哪一个会被调用也是能确定的

1
2
3
4
5
6
7
8
var promise = Promise.resolve()

promise.then(function () {
var retPromise = new Promise(function (resolve, reject) {
// resolve or reject 的状态决定 onFulfilled or onRejected 的哪个方法会被调用
})
return retPromise
}).then(onFulfilled, onRejected)

比如上面这个示例,后面的 then 调用哪个回调函数是由 Promise 对象的状态来决定的,也就是说这个 retPromise 对象状态为 Rejected 的时候,会调用后面 then 中的 onRejected 方法,这样就实现了即使在 then 中不使用 throw 也能进行 reject 处理了

1
2
3
4
5
6
7
8
9
var onRejected = console.error.bind(console)
var promise = Promise.resolve()

promise.then(function () {
var retPromise = new Promise(function (resolve, reject) {
reject(new Error(`this promise is rejected`))
})
return retPromise
}).catch(onRejected)

使用 Promise.reject 的话还能再将代码进行简化

1
2
3
4
5
6
var onRejected = console.error.bind(console)
var promise = Promise.resolve()

promise.then(function () {
return Promise.reject(new Error(`this promise is rejected`))
}).catch(onRejected)

Promise.all()

Promise.all 实际上是一个 Promise,接收一个 Promise 数组(或一个可迭代的对象)做为参数,然后当其中所有的 Promise 都变为 resolved 状态,或其中一个变为 rejected 状态,便会执行回调函数,来看下面代码

1
2
3
4
5
Promise.all([promise1, promise2, promise3])
.then(result => {
console.log(result)
})
.catch(error => console.log(`Error in promises ${error}`))

你可以看到,我们将一个数组传递给了 Promise.all,并且当三个 Promise 都转为 resolved 状态时,Promise.all 完成并在控制台输出,再来看看下面这个示例,经过给定时间会执行 resolve

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const timeOut = (t) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(`Completed in ${t}`)
}, t)
})
}

// Resolve 一个正常的 Promise
timeOut(1000)
.then(result => console.log(result)) // Completed in 1000

// 使用 Promise.all
Promise.all([timeOut(1000), timeOut(2000)])
.then(result => console.log(result)) // [`Completed in 1000`, `Completed in 2000`]

在上面的示例中,Promise.all2000ms 之后 resolved,并且在控制台上输出结果数组,但是我们可以发现,输出的 Promise 的顺序是固定的,也就是说每个 Promise 的结果(resolvereject 时传递的参数值)和传递给 Promise.allPromise 数组的顺序是一致的

以上就是 Promise.all 的基本用法,下面我们来看一些在实际项目中的应用,比如同步多个异步请求,在实际的项目中,页面通常需要将多个异步请求发送到后台,然后等到后台结果返回后,再开始渲染页面,有时候我们可能会这样进行处理

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
32
33
34
35
36
37
38
function getAList() {
return new Promise((resolve, reject) => {
// 假设我们向服务器发出异步请求
setTimeout(function () {
resolve('AList')
}, 300)
})
}

function getBList() {
return new Promise((resolve, reject) => {
// 假设我们向服务器发出异步请求
setTimeout(function () {
resolve('BList')
}, 500)
})
}

function getCList() {
return new Promise((resolve, reject) => {
// 假设我们向服务器发出异步请求
setTimeout(function () {
resolve('CList')
}, 700)
})
}

getAList().then(function (data) {
// 渲染数据
})

getBList().then(function (data) {
// 渲染数据
})

getCList().then(function (data) {
// 渲染数据
})

上面的代码确实有效,但是有两个缺陷

  • 每次我们从服务端请求数据时,我们都需要编写一个单独的函数来处理数据,这将导致代码冗余,并且不便于将来的升级和扩展
  • 每个请求花费的时间不同,导致函数会异步渲染三次页面,会使用户感觉页面卡顿

现在我们可以使用 Promise.all 来优化我们的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function getAList() {
// ...
}

function getBList() {
// ...
}

function getCList() {
// ...
}

function initLoad() {
Promise.all([getAList(), getBList(), getCList()]).then(res => {
// 渲染数据
}).catch(err => {
// ...
})
}

initLoad()

这样代码看上去就清爽了不少,在所有请求完成后,我们在统一处理数据,但是如果有异常的话,该如何处理呢?在上面的示例中,我们可以按照下面的方式来进行异常处理

1
2
3
4
5
Promise.all([p1, p2]).then(res => {
// ...
}).catch(error => {
// 异常处理
})

众所周知,Promise.all 的机制是,只要做为参数的 Promise 数组中的任何一个 Promise 抛出异常时,无论其他 Promise 成功或失败,整个 Promise.all 函数都会进入 catch 方法,但实际上,我们经常希望即使一个或多个 Promise 抛出异常,我们仍希望 Promise.all 继续正常执行,例如在上面的例子中,即使在 getAList() 中发生异常,只要在 getBList()getCList() 中没有发生异常,我们仍然希望该程序继续执行,为了满足这个需求,我们可以使用一个技巧来增强 Promise.all 的功能

1
2
3
Promise.all([p1.catch(error => error), p2.catch(error => error)]).then(res => {
// ...
})

这样一来,即使一个 Promise 发生异常,也不会中断 Promise.all 中其它 Promise 的执行,应用到前面的示例,结果是这样的

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function getAList() {
return new Promise((resolve, reject) => {
setTimeout(function () {
// 假设这里 reject 一个异常
reject(new Error('error'))
}, 300)
})
}

function getBList() {
// ...
}

function getCList() {
// ...
}


function initLoad() {
Promise.all([
getAList().catch(err => err),
getBList().catch(err => err),
getCList().catch(err => err)
]).then(res => {

if (res[0] instanceof Error) {
// 处理异常
} else {
// 渲染数据
}

if (res[1] instanceof Error) {
// 处理异常
} else {
// 渲染数据
}

if (res[2] instanceof Error) {
// 处理异常
} else {
// 渲染数据
}
})
}

initLoad()

Promise.race()

Promise.race 的参数与 Promise.all 相同,可以是一个 Promise 数组或一个可迭代的对象,Promise.race() 方法返回一个 Promise 对象,一旦迭代器中的某个 Promisefulfilledrejected 状态,就会返回结果或者错误信息,我们来看下面这个定时功能的示例

当我们从后端服务器异步请求资源时,通常会限制时间,如果在指定时间内未接收到任何数据,则将引发异常,所幸 Promise.race 可以帮我们解决这个问题

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
function requestImg() {
var p = new Promise(function (resolve, reject) {
var img = new Image()
img.onload = function () {
resolve(img)
}
img.src = '...'
})
return p
}

// 定时功能的延迟函数
function timeout() {
var p = new Promise(function (resolve, reject) {
setTimeout(function () {
reject(`Picture request timeout`)
}, 5000)
})
return p
}

Promise
.race([requestImg(), timeout()])
.then(function (results) {
// 该资源请求在指定时间内完成
console.log(results)
})
.catch(function (reason) {
// 该资源请求被在指定时间内没有完成
console.log(reason)
})

Promises.finally()

在上面我们介绍了 Promise.all()Promise.race(),下面我们再来考虑另外一种情况,那就是如何让一个函数无论 Promise 对象成功和失败都能被调用呢?

在这种情况下,就要用到 Promises.finally() 这个方法了,Promises.finally() 方法返回一个 Promise,在 Promise 执行结束时,无论结果是 fulfilled 或者是 rejected,在执行 then()catch() 后,都会执行 finally 指定的回调函数,这为指定执行完 Promise 后,无论结果是 fulfilled 还是 rejected 都需要执行的代码提供了一种方式,避免同样的语句需要在 then()catch() 中各写一次的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resolve('success').then(result => {
console.log('then: ', result)
return Promise.resolve(result)
}).catch(err => {
console.error('catch: ', err)
return Promise.reject(err)
}).finally(result => {
console.info('finally: ', result)
})

// then: success
// finally: undefined
// Promise {<resolved>: 'success'}

经典示例

在上面我们介绍了 Promise 的基本概念和一些 API 的用法,下面我们就来通过一些实际案例加深一下对于 Promise 的理解

红绿灯问题

一个经典的题目,黄灯一秒亮一次,绿灯两秒亮一次,红灯三秒亮一次,如何让三个灯不断交替重复亮灯?三个亮灯函数已经存在

1
2
3
4
5
6
7
8
9
10
11
function red() {
console.log('red')
}

function green() {
console.log('green')
}

function yellow() {
console.log('yellow')
}

这种情况,我们可以考虑使用 Promise 来实现

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

function step() {
Promise.resolve().then(_ => {
return light(3000, red)
}).then(_ => {
return light(2000, green)
}).then(_ => {
return light(1000, yellow)
}).then(_ => {
step()
})
}

step()

Promise 和 setTimeout

最后我们再来看看平常可能会经常遇到的一类问题,那就是 PromisesetTimeout 的执行先后顺序的问题,先来看下面这个示例

1
2
3
4
5
6
7
8
9
setTimeout(function () {
console.log(1)
}, 0)

new Promise(function (resolve) {
resolve()
}).then(function () {
console.log(2)
})

执行结果依次为 21,至于为什么会这样,简单来说就是 Promise 的任务会在当前事件循环末尾中执行,而 setTimeout 中的任务是在下一次事件循环执行,所以 Promise 的执行顺序是高于 setTimeout 的,至于原因,则是因为在 ES6 当中,有一个新的概念建立在『事件循环队列』之上,叫做『任务队列』

简单的理解就是,它是挂在事件循环队列的每个 Tick 之后的一个队列,在事件循环的每个 Tick 中,可能出现的异步动作不会导致一个完整的新事件添加到事件循环队列中,而会在当前 Tick 的任务队列末尾添加一个项目(任务)

一个任务可能引起更多任务被添加到同一个队列末尾,所以理论上说,任务循环可能无限循环(一个任务总是添加另一个任务,以此类推)进而导致程序的无限循环,无法转移到下一个事件循环 Tick,从概念上看,这和代码中的无限循环(类似 while(true))的体验几乎是一样的

有了以上的了解以后,我们再来看看下面这个稍微复杂点的的综合案例

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
// 一
for (var i = 0 i < 5 i++) {
console.log(i)
}


// 二
for (var i = 0 i < 5 i++) {
setTimeout(function () {
console.log(i)
}, 1000 * i)
}


// 三
for (var i = 0 i < 5 i++) {
(function (i) {
setTimeout(function () {
console.log(i)
}, 1000 * i)
})(i)
}


// 四
for (let i = 0 i < 5 i++) {
setTimeout(function () {
console.log(i)
}, 1000 * i)
}


// 五
for (var i = 0 i < 5 i++) {
(function () {
setTimeout(function () {
console.log(i)
}, 1000 * i)
})(i)
}


// 六
for (var i = 0 i < 5 i++) {
setTimeout((function (i) {
console.log(i)
})(i), i * 1000)
}


// 七
setTimeout(function () {
console.log(1)
}, 0)

new Promise(function executor(resolve) {
console.log(2)
for (var i = 0 i < 10000 i++) {
i == 9999 && resolve()
}
console.log(3)
}).then(function () {
console.log(4)
})

console.log(5)


// 八
setTimeout(function () {
console.log(1)
}, 0)

new Promise(function executor(resolve) {
console.log(2)
for (var i = 0 i < 10000 i++) {
i == 9999 && resolve()
}
setTimeout(function() {
console.log(6)
}, 0)
console.log(3)
}).then(function () {
console.log(4)
})

console.log(5)
  1. 最基本的,输出 0 ~ 4
  2. setTimeout 会延迟执行,那么执行到 console.log 的时候,其实 i 已经变成 5 了,所以结果为 55(每一秒输出一个 5
  3. 三当中使用了闭包,而四当中将 var 变成了 let,结果同样是 04
  4. 去掉 function() 中的 i,内部就没有对 i 保持引用,结果还是 55
  5. 如果修改成六这样,立即执行函数会立即执行,所以会立即输出 0 ~ 4 而不会延迟
  6. 最后两个有些复杂,但是原理是类似的,我们来简单剖析一下

都被改写成了 Promise,但是首先需要明确的是,Promise 的任务会在当前事件循环末尾中执行,而 setTimeout 中的任务是在下一次事件循环执行,首先是一个 setTimeout,所以其中的任务是会在下一次事件循环中才会执行,因此开始肯定不会输出 1,然后是一个 Promise,里面的函数是会立即执行的,所以首先输出 23

这里需要注意的是,Promisethen 应当会放到当前 Tick 的最后,但是还是在当前 Tick 中(而不是下一次事件循环),所以会先输出 5 然后才会输出 4,最后轮到下一个 Tick 才会输出 1,所以结果为 2 3 5 4 1,至于最后一个,和第七个的原理是一样的,结果为 2 3 5 4 1 6

关于 PromisesetTimeout 两者间具体的差异可以参考之前整理过的 JavaScript 并发模型 来了解更多

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×