Promise

在深入介绍 Service Worker 之前,先来了解一下 Promise API。因为 Service Worker 的所有的异步接口内部都采用 Promise 来实现,因此学习了 Promise 讲能够有助于对 Service Worker 的理解。此外,本文还介绍了 Promise 的可靠性,链式调用的原理,并行执行的原理等较为深入的内容,感兴趣的读者也可以通过本文加深对 Promise 的理解。

什么是 Promise

Promise 是 ES6 引入的一种异步编程的解决方案,通过 Promise 对象来提供统一的异步状态管理方法。

过去我们通常使用注册异步回调函数的形式来进行异步编程,这里的异步回调实际上是具体的异步函数与开发者的接口约定,并不存在任何的标准,因此回调的注册形式、触发方式、异步状态管理等等都得不到统一且稳定的保证。同时这种异步回调的写法不利于状态管理,在处理多个异步过程的时候容易走进回调地狱,因此 JavaScript 异步编程需要一个统一且可靠的方案来进行异步状态管理,因此 Promise 应运而生。事实上 Promise 是社区推动的产物,在早期就出现了比如 $.Deferred、Bluebird 等库用于解决异步状态管理和回调地狱的问题,并最终促进并推动了 Promise 写进了 ES6 规范当中。

Promise 基本用法

一般在使用 Promise 对象的时候,首先需要对其进行实例化:

  1. let promise = new Promise((resolve, reject) => {
  2. if (/* 操作成功 */) {
  3. resolve(value)
  4. } else {
  5. reject(error)
  6. }
  7. })

其中实例化的 promise 对象为异步状态的管理容器,resolve()reject() 则是用于控制 promise 状态的方法。

Promise 具有三种状态:

  • ‘pending’:初始状态,代表异步过程仍在进行中,尚未判定成功或者失败;
  • ‘fulfilled’:操作成功。通过调用 resolve() 方法,promise 状态将由 ‘pending’ 变更为 ‘fulfilled’;
  • ‘rejected’:操作失败。通过调用 reject() 方法,promise 状态将变更为 ‘rejected’。

在调用 resolve()reject() 方法的时候可以传入任意值,比如 resolve('操作成功')reject(Error('操作失败')) 等等,这个值会作为监听状态变更的回调函数的参数透传出去。

Promise 提供了 .then(onFulfilled, onRejected).catch(onRejected) 等原型链方法用于注册状态变更所触发的回调函数。其中 .catch(onRejected) 等价于 .then(null, onRejected),因此为了行文方便,在没有特殊说明的情况下,后续所提到的 .then() 方法均用于指代 .then().catch()

下面的示例演示了 Promise 的基本使用方式。在这个例子中创建了一个 Promise 对象,并且利用 setTimeout() 方法在 1 秒后触发 Promise 的状态变更,状态变更后便会触发 onFulfilled 回调函数并在控制台打印出 Promise 的返回值。

  1. let promise = new Promise(resolve => {
  2. setTimeout(() => {
  3. resolve('执行完成!')
  4. }, 1000)
  5. })
  6. // 1 秒后打印“执行完成”
  7. promise.then(value => {
  8. console.log(value)
  9. })
  10. // 此时不会执行 onRejected 回调
  11. promise.catch(error => {
  12. console.log(error)
  13. })

同理,1 秒后将 Promise 状态变更为失败则是调用 reject() 方法,可采用 .then().catch() 方法进行 onRejected 回调的注册:

  1. let promise = new Promise((resolve, reject) => {
  2. setTimeout(() => {
  3. reject('操作失败!')
  4. }, 1000)
  5. })
  6. promise.then(
  7. // 不会进入 onFulfilled 回调
  8. value => {
  9. console.log(value)
  10. },
  11. // 1 秒后打印“操作失败![1]”
  12. error => {
  13. console.log(error + '[1]')
  14. }
  15. )
  16. // 1 秒后打印“操作失败![2]”
  17. promise.catch(error => {
  18. console.log(error + '[2]')
  19. })

当回调函数执行出错时,promise 的状态同样会变更为 ‘rejected’:

  1. let promise = new Promise((resolve, reject) => {
  2. throw Error('操作失败!')
  3. })
  4. promise.catch(error => {
  5. // 打印“操作失败!”
  6. console.log(error.message)
  7. })

在一些复杂的异步场景当中,我们还可以使用变量将 resolve 和 reject 缓存下来,等到需要变更 promise 状态的时候再去触发它们,这种情形在配合上各种闭包写法,可以实现很多神奇的功能:

  1. let resolve
  2. let reject
  3. let promise = new Promise((res, rej) => {
  4. resolve = res
  5. reject = rej
  6. })
  7. promise.then(value => {
  8. console.log(value)
  9. })
  10. /* 一些神仙操作 */
  11. if (/* 异步操作成功 */) {
  12. resolve(value)
  13. } else {
  14. reject(error)
  15. }

Promise 提供了 Promise.resolve(value)Promise.reject(error) 来快速获得一个确定状态以及返回值的 Promise 对象,在一些特定的使用场景下,这两个函数能够起到简化代码的作用。

  1. let p1 = Promise.resolve(12345)
  2. // 等价于
  3. let p1 = new Promise(resolve => {
  4. resolve(12345)
  5. })
  6. let p2 = Promise.reject(Error('出错了'))
  7. // 等价于
  8. let p2 = new Promise((resolve, reject) => {
  9. reject(Error('出错了'))
  10. })

Promise 的可靠性

Promise 作为异步状态的管理方案,首先要解决的是状态管理的可靠性问题,这里包括操作的可靠性和状态的可靠性两个方面,Promise 通过以下特点来依次解决这些可靠性问题。

统一的格式

Promise 对象统一了异步状态管理的格式,经过 Promise 包装的异步过程将具有统一的状态变更方式,统一的 API 以及统一的回调函数格式。这样就再也不需要为过去不同形式的回调函数所困扰。

我们可以做个对比,在过去采用回调函数的机制进行异步编程时,写法五花八门:

  1. // ajax 风格的回调写法
  2. run({
  3. success (value) {
  4. console.log('执行成功!')
  5. },
  6. error (error) {
  7. console.log('执行失败!')
  8. }
  9. })
  10. // nodejs 风格的回调写法
  11. run((error, result) => {
  12. if (error) {
  13. console.log('执行失败!')
  14. } else {
  15. console.log('执行成功!')
  16. }
  17. })
  18. // 事件监听风格的回调写法
  19. run.onsuccess = (result) => {
  20. console.log('执行成功!')
  21. }
  22. run.onfail = (error) => {
  23. console.log('执行失败!')
  24. }
  25. run()

而 Promise 只有一种写法,完成了格式上的统一,这也为下一节将要介绍的 Promise 链式调用提供基础:

  1. let promise = run()
  2. promise.then(result => {
  3. console.log('执行成功!')
  4. })
  5. promise.catch(result => {
  6. console.log('执行失败!')
  7. })

Promise 状态不受外部影响

Promise 只能通过 resolve()reject() 方法控制 Promise 的状态,这个状态无法被外部直接访问,也没有提供任何方法从外部修改状态,这就保证了 Promise 状态不受外部影响。

Promise 状态具有确定性

Promise 对象一旦从初始状态(pending)变更为执行成功(fulfilled)或者执行失败(rejected),那么这个状态就被完全确定下来了,不会被后续的任何操作所影响,即便在此后多次调用 resolve()reject(),这个 Promise 对象的状态也将永远是这个初次变更时的状态。同时,初次调用 resolve 或者 reject 所传入的参数也将会固定下来。

  1. let promise = new Promise((resolve, reject) => {
  2. // 初次触发状态变更为 fulfilled,
  3. // 同时记录返回值为 1 并触发 onFulfilled 回调函数
  4. resolve(1)
  5. // 后续的操作都不会影响状态,
  6. // 也不会覆盖掉返回值,
  7. // 也不会多次触发 onFulfilled 回调
  8. reject(2)
  9. resolve(3)
  10. reject(4)
  11. })
  12. // 打印 1
  13. promise.then(value => {
  14. console.log(value)
  15. })
  16. // 不会进入该 onRejected 回调
  17. promise.catch(error => {
  18. console.log(error)
  19. })

Promise 的这一特性确保了异步过程最终状态的确定性,不用担心这个状态在后续的任何时候发生变更。

Promise 回调函数是一次性的

由于 Promise 对象上注册的回调函数只会至多触发一次,这个特点规避了过去基于基于回调函数的异步编程当中回调函数执行次数不受控制的问题。在 Promise 的这套机制下,希望触发几次回调,就注册几个回调函数即可。

  1. // 假设异步函数的实现机制如下,会存在多次调用 callback 的情况
  2. function run (callback) {
  3. setInterval(callback, 1000)
  4. }
  5. // 采用 Promise 进行包装,就能够避免这一问题
  6. let promise = new Promise(resolve => {
  7. run(resolve)
  8. })
  9. // 只会触发一次
  10. promise.then(() => {
  11. console.log('执行完成!')
  12. })

Promise 不存在回调过早问题

由于 Promise 的状态具有确定性,一旦固定下来后便不会发生任何更改,因此在任何时候注册回调函数都可以监听到 Promise 的状态。如果回调函数在状态变更前注册,则会等待状态变更时触发;当注册时状态已经确定下来,那么 Promise 会立即调用这个函数并传入相应的返回值。这就解决了过去回调函数机制可能存在的回调过早问题(即事件在回调注册前触发导致回调监听失效),在 Promise 机制的保证下,这种问题不会发生。

下面举个例子演示后注册的 onFulfilled 回调获取返回值的情况:

  1. let promise = new Promise((resolve, reject) => {
  2. // 1 秒时触发状态变更为 fulfilled
  3. setTimeout(() => {
  4. resolve('操作成功!')
  5. }, 1000)
  6. })
  7. // 0 秒时注册 onFulfilled
  8. promise.then(value => {
  9. console.log(value + '[1]')
  10. })
  11. // 2 秒时注册 onFulfilled
  12. setTimeout(() => {
  13. promise.then(value => {
  14. console.log(value + '[2]')
  15. })
  16. }, 2000)

这段代码的控制台输出结果为:

  1. # (...1s)
  2. 操作成功![1]
  3. # (...2s)
  4. 操作成功![2]

可以看到,第 0 秒注册的回调函数在第 1 秒 promise 对象状态变更的时候触发,同时第 2 秒注册的的回调函数会立即触发并成功获得返回值。

这一特性确保了在任何时候注册 promise 的回调函数都不会错过异步返回的结果,这个点在回调函数的年代很难被保证的。

Promise 的回调函数之间不会相互影响

同一个 Promise 上注册的回调函数彼此相互隔离,因此个别回调函数执行出错并不会影响到其他回调函数的正常执行。

  1. let promise = new Promise(resolve => {
  2. setTimeout(() => {
  3. resolve('操作成功!')
  4. }, 1000)
  5. })
  6. // 1 秒后执行回调并抛错
  7. promise.then(value => {
  8. throw Error('出错了')
  9. })
  10. // 永远不会进到 onRejected 回调中
  11. // 因为onFulfilled 执行出错不会影响 promise 的状态
  12. promise.catch(error => {
  13. console.log(error)
  14. })
  15. // 1 秒后打印“操作成功!”
  16. promise.then(value => {
  17. console.log(value)
  18. })

Promise 回调函数执行的时序是确定的

首先举个例子来说明问题。假设目前存在一个函数 run(),它可以传入回调函数作为参数,那么相应的代码如下所示:

  1. console.log('a')
  2. run(() => {
  3. console.log('b')
  4. })
  5. console.log('c')

在不知道 run 函数的内部实现之前,我们完全无法预测这段代码的执行结果。比如以下这两种实现方式,其打印的结果是完全不一样的:

  1. function run (callback) {
  2. callback()
  3. }
  4. // 打印 a b c
  5. /*****/
  6. function run (callback) {
  7. setTimeout(callback)
  8. }
  9. // 打印 a c b

但如果 run 函数通过 Promise 的方式来实现,并且回调函数放到 .then 方法当中执行,那么我们就可以很明显地知道打印结果一定是“a c b”:

  1. console.log('a')
  2. run().then(() => {
  3. console.log('b')
  4. })
  5. console.log('c')
  6. // 打印 a c b

这里涉及到 microtask、JavaScript 事件循环机制相关 的概念,感兴趣的同学可以搜索相应关键字进行深入了解。

小节

总的来说,Promise 通过一系列特性解决了过去异步编程当中存在的可靠性问题,使得我们能够以一种更为简单而规整的方式去获取和管理异步状态。

Promise 的串行执行与链式调用

在开篇 Promise 的演示当中一个最为亮眼的特点就是,通过一连串的 .then() 链式调用来实现多个异步方法的顺序执行问题:

  1. run1()
  2. .then(run2)
  3. .then(run3)
  4. .then(run4)
  5. .catch(error => {
  6. console.log('执行出错')
  7. })

那么接下来我们将从 .then() 出发,一步一步地弄明白其中的 Promise 传递过程,并最终理解 Promise 的链式调用机制。

Promise.prototype.then

.then(onFulfilled, onRejected) 是 Promise 的原型链方法,用于注册 Promise 对象状态变更时的回调函数。它接受两个回调函数作为参数,分别在 Promise 变更为不同状态时触发,其中 onRejected 可以缺省。

  1. promise.then(
  2. result => {
  3. console.log('执行成功!')
  4. },
  5. error => {
  6. console.log('执行失败!')
  7. }
  8. )

.then() 方法会创建并返回一个新的 Promise 对象(用 p2 指代,当前监听的 Promise 对象用 p1 指代),用于表征回调函数的执行情况。这个过程满足如下规则:

  • p1 的状态只决定了何时执行回调以及执行哪种类型的回调,并不会影响到 p2 状态;
  • p2 的初始状态为 ‘pending’,当回调函数执行成功时状态变更为 ‘fulfilled’,如果回调执行过程抛出异常则变更为 ‘rejected’;
  • 回调函数的返回值 value 将作为 p2 触发状态变更时 resolve(value) 的参数将其传递下去。

这里存在一个有意思的地方,由于回调函数可以返回任何的结果,因此返回一个 Promise 对象也是可行的。我们在这里用 p3 来指代这个 Promise 对象,在这种情况下首先明确 p2 与 p3 两个不同的 Promise 对象,但是 p2 与 p3 的状态是一致的,这里的“一致”包括最终的状态、状态触发的时机以及返回值的一致性。我们来举例说明这个过程:

  1. let p1 = new Promise(resolve => {
  2. resolve('[p1]')
  3. })
  4. let p2 = new Promise(resolve => {
  5. resolve(p1)
  6. })
  7. // 打印 false
  8. console.log(p1 === p2)
  9. // 打印 “[p1]”
  10. p2.then(value => {
  11. console.log(value)
  12. })
  • 当 p1 需要调用的回调函数不存在时,则会调用 p2 的 resolve(p1) 方法,将这个状态持续传递下去;
  1. // 产生一个 rejected 状态的 Promise 对象
  2. let p1 = new Promise((resolve, reject) => {
  3. reject('[p1]')
  4. })
  5. // 当前注册的 onFulfilled 回调不会触发
  6. // 同时 onRejected 回调并未注册,因此 p1 的状态会继续向下传递:
  7. let p2 = p1.then(value => {
  8. console.log(value)
  9. })
  10. // 打印 '[p1]'
  11. p2.catch(error => {
  12. console.log(error)
  13. })

以上这些就给异步状态提供了可传递性,为 Promise 的链式调用提供了状态传递的基础。

下面通过一些例子来说明 .then() 方法在不同情况下的执行结果。

1. 正常顺序执行

  1. // 获取初始 promise 对象
  2. let promise = new Promise(resolve => {
  3. setTimeout(() => {
  4. resolve('执行成功!')
  5. }, 1000)
  6. })
  7. // onFulfilled 回调执行完成
  8. // 因此 p1 状态变更为 'fulfilled'
  9. let p1 = promise.then(result => {
  10. return result + '[1]'
  11. })
  12. // 1 秒后打印“执行成功![1]”
  13. let p2 = p1.then(result => {
  14. console.log(result)
  15. })

2. 错误处理

  1. // 获取初始 promise 对象
  2. let promise = new Promise(resolve => {
  3. // 1 秒后触发执行失败
  4. setTimeout(() => {
  5. reject('执行失败!')
  6. }, 1000)
  7. })
  8. // 1 秒后打印“执行失败”
  9. // 同时由于 onRejected 回调执行完成
  10. // p1 状态变更为 'fulfilled'
  11. let p1 = promise.catch(error => {
  12. console.log(error)
  13. })
  14. // 打印 undefined,因为 p1 注册的回调没有任何返回
  15. let p2 = p1.then(value => {
  16. console.log(value)
  17. })

3. 执行回调时抛出异常

  1. // 获取初始 promise 对象
  2. let promise = new Promise(resolve => {
  3. // 1 秒后触发执行成功
  4. setTimeout(() => {
  5. resolve('执行成功!')
  6. }, 1000)
  7. })
  8. // 1 秒后执行回调并抛出异常
  9. // 此时 p1 状态变更为 'rejected'
  10. let p1 = promise.then(value => {
  11. throw Error('执行异常!')
  12. })
  13. // 打印“执行异常!”并返回字符串
  14. // 由于该回调执行完成因此 p2 状态变更为 'fulfilled'
  15. let p2 = p1.catch(error => {
  16. console.log(error.message)
  17. return '恢复正常!'
  18. })
  19. // 打印“恢复正常!”
  20. // 同时 p3 状态变更为 'fulfilled'
  21. let p3 = p2.then(value => {
  22. console.log(value)
  23. })

4. 回调函数返回 Promise 对象

  1. // 初始 Promise 对象,2 秒后执行成功并返回 '[p1]'
  2. let p1 = new Promise(resolve => {
  3. setTimeout(() => {
  4. resolve('[p1]')
  5. }, 2000)
  6. })
  7. let p2 = p1.then(result => {
  8. return new Promise(resolve => {
  9. setTimeout(() => {
  10. resolve('[p3]')
  11. }, 1000)
  12. })
  13. })
  14. // 3 秒后打印 '[p3]'
  15. p2.then(result => {
  16. console.log(result)
  17. })

通过这个机制就实现了多个异步过程的串行执行,只需要将所有的异步过程统一使用 Promise 进行包裹,并且将下一个异步过程的 Promise 对象作为上一个异步过程 Promise 对象的 onFulfilled 回调函数的返回值即可。

Promise 的链式调用

通过前面的举例可以看到 .then() 方法是 Promise 对象的原型链方法,并且其返回值同样也是个 Promise 对象,因此只要把前面例子中一些无关紧要的中间变量去除掉,就实现 Promise 的链式调用了。

  1. new Promise(resolve => {
  2. setTimeout(() => {
  3. resolve('执行成功!')
  4. }, 1000)
  5. })
  6. .then(result => {
  7. console.log('步骤 [1]')
  8. return new Promise((resolve, reject) => {
  9. setTimeout(() => {
  10. reject(Error('执行异常'))
  11. }, 1000)
  12. })
  13. })
  14. .catch(error => {
  15. console.log(error.message)
  16. return '恢复正常'
  17. })
  18. .then(result => {
  19. console.log(result)
  20. })

链式调用的好处就是,可以非常直观地将多个需要按顺序执行的异步过程以一种自上而下的线性组合方式实现,在降低编码难度的同时,也增加了代码的可读性。

同时基于注册在同一 Promise 对象的回调函数彼此不相干扰的特性,我们可以在任何需要的地方进行链分叉。在下面的例子当中,假设对于初始 Promise 对象的不同状态将采取两种完全不一样的异步操作的时候,就可以这么实现:

  1. let promise = new Promise((resolve, reject) => {
  2. if (Math.random() > 0.5) {
  3. resolve()
  4. } else {
  5. reject()
  6. }
  7. })
  8. promise.then(run1)
  9. .then(run2)
  10. .then(run3)
  11. // ...
  12. promise.catch(run4)
  13. .then(run5)
  14. .then(run6)
  15. // ...
  16. promise.then(run7)
  17. .then(run8)
  18. // ...

Promise 并行执行与管理

在 JavaScript 当中,异步任务本身就是并行执行的。前面所提到的基于 Promise 的异步任务串行执行,本质上是通过 .then() 方法去控制上一个异步任务完成之后再触发下一个异步任务的执行,所以如果要改造成并行执行,只需要同步地创建这些异步任务,并对它们的 Promise 对象进行相应的管理即可。

下面的例子展示了并行获取异步数据 x 和 y,并且在 x 和 y 全部获取之后输入它们的相加结果,其中 getX()getY() 分别是 x 和 y 的异步获取方法,getXAndY() 用于同步返回 x 和 y 的结果:

  1. function getX () {
  2. return new Promise(resolve => {
  3. setTimeout(() => {
  4. resolve(1)
  5. }, 1000)
  6. })
  7. }
  8. function getY () {
  9. return new Promise(resolve => {
  10. setTimeout(() => {
  11. resolve(10)
  12. }, 2000)
  13. })
  14. }
  15. function getXAndY([promiseX, promiseY]) {
  16. let results = []
  17. return promiseX
  18. .then(x => {
  19. results.push(x)
  20. return promiseY
  21. })
  22. .then(y => {
  23. result.push(y)
  24. return results
  25. })
  26. }
  27. getXAndY([
  28. getX(),
  29. getY()
  30. ])
  31. .then(results => {
  32. console.log(x + y)
  33. })

执行结果如下:

  1. # (...2s)
  2. 11

可以看到 2s 后控制台输出了结果 11,说明 getX()getY() 是并行执行的,并且在两个 Promise 状态全部成功之后,再最终返回两者的相加结果。

这里的 getXAndY() 就属于一种并行状态管理的方案。事实上 Promise 已经提供了 Promise.all() 方法来实现同样的功能。因此上述代码可修改为使用 Promise.all() 的形式:

  1. Promise.all([
  2. getX(),
  3. getY()
  4. ])
  5. .then(results => {
  6. console.log(x + y)
  7. })

除了 Promise.all(),Promise 还提供了 Promise.race() 方法,用于获取第一个发生状态变更的 Promise 对象:

  1. Promise.race([
  2. getX(),
  3. getY()
  4. ])
  5. .then(value => {
  6. // 打印“1”,因为 x 的结果最先返回
  7. console.log(value)
  8. })
  9. Promise.race([
  10. getX(),
  11. new Promise((resolve, reject) => {
  12. reject('error')
  13. })
  14. ])
  15. // 不会进入 onFulfilled
  16. .then(value => {
  17. console.log(value)
  18. })
  19. // 打印“error”
  20. // 因为这个 Promise 最先返回
  21. .catch(error => {
  22. console.log(error)
  23. })

假如 Promise.all()Promise.race() 都无法满足应用场景,我们也可以基于 Promise 的原理与特性,自行开发相应的并行执行管理方案,在这里就不做赘述了。

总结

这篇文章介绍了 Promise 基本用法,介绍了 Promise 对象所具有的特性如何解决异步状态的可靠性问题,最后介绍了基于 Promise 的串行和并行执行的实现原理。Promise 是前端异步编程的基础,随着前端生态的不断完善,网站功能的前后端交互将会变得越来越复杂,Promise 也将会在各种复杂的异步编程当中发挥着越来越重要的作用。